mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
* feat(trader): add LIGHTER DEX integration (initial implementation) Add pure Go implementation of LIGHTER DEX trader following NOFX architecture Features: - ✅ Account management with Ethereum wallet authentication - ✅ Order operations: market/limit orders, cancel, query - ✅ Position & balance queries - ✅ Zero-fee trading support (Standard accounts) - ✅ Up to 50x leverage for BTC/ETH Implementation: - Pure Go (no CGO dependencies) for easy deployment - Based on hyperliquid_trader.go architecture - Uses Ethereum ECDSA signatures (like Hyperliquid) - API base URL: https://mainnet.zklighter.elliot.ai Files: - lighter_trader.go: Core trader structure & auth - lighter_orders.go: Order management (create/cancel/query) - lighter_account.go: Balance & position queries Status: ⚠️ Partial implementation - ✅ Core structure complete - ⏸️ Auth token generation needs implementation - ⏸️ Transaction signing logic needs completion - ⏸️ Config integration pending Next steps: 1. Complete auth token generation 2. Add to config/exchange registry 3. Add frontend UI support 4. Create test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * feat: Add LIGHTER DEX integration (快速整合階段) ## 🚀 新增功能 - ✅ 添加 LIGHTER DEX 作為第四個支持的交易所 (Binance, Hyperliquid, Aster, LIGHTER) - ✅ 完整的數據庫配置支持(ExchangeConfig 新增 LighterWalletAddr, LighterPrivateKey 字段) - ✅ 交易所註冊與初始化(initDefaultData 註冊 "lighter") - ✅ TraderManager 集成(配置傳遞邏輯完成) - ✅ AutoTrader 支持(NewAutoTrader 添加 "lighter" case) ## 📝 實現細節 ### 後端整合 1. **數據庫層** (config/database.go): - ExchangeConfig 添加 LIGHTER 字段 - 創建表時添加 lighter_wallet_addr, lighter_private_key 欄位 - ALTER TABLE 語句用於向後兼容 - UpdateExchange/CreateExchange/GetExchanges 支持 LIGHTER - migrateExchangesTable 支持 LIGHTER 字段 2. **API 層** (api/server.go, api/utils.go): - UpdateExchangeConfigRequest 添加 LIGHTER 字段 - SanitizeExchangeConfigForLog 添加脫敏處理 3. **Trader 層** (trader/): - lighter_trader.go: 核心結構、認證、初始化 - lighter_account.go: 餘額、持倉、市場價格查詢 - lighter_orders.go: 訂單管理(創建、取消、查詢) - lighter_trading.go: 交易功能實現(開多/空、平倉、止損/盈) - 實現完整 Trader interface (13個方法) 4. **Manager 層** (manager/trader_manager.go): - addTraderFromDB 添加 LIGHTER 配置設置 - AutoTraderConfig 添加 LIGHTER 字段 ### 實現的功能(快速整合階段) ✅ 基礎交易功能 (OpenLong, OpenShort, CloseLong, CloseShort) ✅ 餘額查詢 (GetBalance, GetAccountBalance) ✅ 持倉查詢 (GetPositions, GetPosition) ✅ 訂單管理 (CreateOrder, CancelOrder, CancelAllOrders) ✅ 止損/止盈 (SetStopLoss, SetTakeProfit, CancelStopLossOrders) ✅ 市場數據 (GetMarketPrice) ✅ 格式化工具 (FormatQuantity) ## ⚠️ TODO(完整實現階段) - [ ] 完整認證令牌生成邏輯 (refreshAuthToken) - [ ] 完整交易簽名邏輯(參考 Python SDK) - [ ] 從 API 獲取幣種精度 - [ ] 區分止損/止盈訂單類型 - [ ] 前端 UI 支持 - [ ] 完整測試套件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * feat: 完整集成 LIGHTER DEX with SDK - 集成官方 lighter-go SDK (v0.0.0-20251104171447-78b9b55ebc48) - 集成 Poseidon2 Goldilocks 簽名庫 (poseidon_crypto v0.0.11) - 實現完整的 LighterTraderV2 使用官方 SDK - 實現 17 個 Trader 接口方法(賬戶、交易、訂單管理) - 支持雙密鑰系統(L1 錢包 + API Key) - V1/V2 自動切換機制(向後兼容) - 自動認證令牌管理(8小時有效期) - 添加完整集成文檔 LIGHTER_INTEGRATION.md 新增文件: - trader/lighter_trader_v2.go - V2 核心結構和初始化 - trader/lighter_trader_v2_account.go - 賬戶查詢方法 - trader/lighter_trader_v2_trading.go - 交易操作方法 - trader/lighter_trader_v2_orders.go - 訂單管理方法 - LIGHTER_INTEGRATION.md - 完整文檔 修改文件: - trader/auto_trader.go - 添加 LighterAPIKeyPrivateKey 配置 - config/database.go - 添加 API Key 字段支持 - go.mod, go.sum - 添加 SDK 依賴 🤖 Generated with Claude Code Co-Authored-By: tinkle-community <tinklefund@gmail.com> * feat(lighter): 實現完整 HTTP 調用與動態市場映射 ### 實現的功能 #### 1. submitOrder() - 真實訂單提交 - 使用 POST /api/v1/sendTx 提交已簽名訂單 - tx_type: 14 (CREATE_ORDER) - 價格保護機制 (price_protection) - 完整錯誤處理與響應解析 #### 2. GetActiveOrders() - 查詢活躍訂單 - GET /api/v1/accountActiveOrders - 使用認證令牌 (Authorization header) - 支持按市場索引過濾 #### 3. CancelOrder() - 真實取消訂單 - 使用 SDK 簽名 CancelOrderTxReq - POST /api/v1/sendTx with tx_type: 15 (CANCEL_ORDER) - 自動 nonce 管理 #### 4. getMarketIndex() - 動態市場映射 - 從 GET /api/v1/orderBooks 獲取市場列表 - 內存緩存 (marketIndexMap) 提高性能 - 回退到硬編碼映射(API 失敗時) - 線程安全 (sync.RWMutex) ### 技術實現 **數據結構**: - SendTxRequest/SendTxResponse - sendTx 請求響應 - MarketInfo - 市場信息緩存 **並發安全**: - marketMutex - 保護市場索引緩存 - 讀寫鎖優化性能 **錯誤處理**: - API 失敗回退機制 - 詳細日誌記錄 - HTTP 狀態碼驗證 ### 測試 ✅ 編譯通過 (CGO_ENABLED=1) ✅ 所有 Trader 接口方法實現完整 ✅ HTTP 調用格式符合 LIGHTER API 規範 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * feat(lighter): 數據庫遷移與前端類型支持 ### 數據庫變更 #### 新增欄位 - `exchanges.lighter_api_key_private_key` TEXT DEFAULT '' - 支持 LIGHTER V2 的 40 字節 API Key 私鑰 #### 遷移腳本 - 📄 `migrations/002_add_lighter_api_key.sql` - 包含完整的驗證和統計查詢 - 向後兼容現有配置(默認為空,使用 V1) #### Schema 更新 - `config/database.go`: - 更新 CREATE TABLE 語句 - 更新 exchanges_new 表結構 - 新增 ALTER TABLE 遷移命令 ### 前端類型更新 #### types.ts - 新增 `Exchange` 接口字段: - `lighterWalletAddr?: string` - L1 錢包地址 - `lighterPrivateKey?: string` - L1 私鑰 - `lighterApiKeyPrivateKey?: string` - API Key 私鑰(⭐新增) ### 技術細節 **數據庫兼容性**: - 使用 ALTER TABLE ADD COLUMN IF NOT EXISTS - 默認值為空字符串 - 不影響現有數據 **類型安全**: - TypeScript 可選字段 - 與後端 ExchangeConfig 結構對齊 ### 下一步 ⏳ **待完成**: 1. ExchangeConfigModal 組件更新 2. API 調用參數傳遞 3. V1/V2 狀態顯示 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * docs(lighter): 更新 LIGHTER_INTEGRATION.md 文檔狀態 * feat(lighter): 前端完整實現 - API Key 配置與 V1/V2 狀態 **英文**: - `lighterWalletAddress`, `lighterPrivateKey`, `lighterApiKeyPrivateKey` - `lighterWalletAddressDesc`, `lighterPrivateKeyDesc`, `lighterApiKeyPrivateKeyDesc` - `lighterApiKeyOptionalNote` - V1 模式提示 - `lighterV1Description`, `lighterV2Description` - 狀態說明 - `lighterPrivateKeyImported` - 導入成功提示 **中文(繁體)**: - 完整的中文翻譯對應 - 專業術語保留原文(L1、API Key、Poseidon2) **Exchange 接口**: - `lighterWalletAddr?: string` - `lighterPrivateKey?: string` - `lighterApiKeyPrivateKey?: string` **UpdateExchangeConfigRequest 接口**: - `lighter_wallet_addr?: string` - `lighter_private_key?: string` - `lighter_api_key_private_key?: string` **狀態管理**: - 添加 3 個 LIGHTER 狀態變量 - 更新 `secureInputTarget` 類型包含 'lighter' **表單字段**: - L1 錢包地址(必填,text input) - L1 私鑰(必填,password + 安全輸入) - API Key 私鑰(可選,password,40 字節) **V1/V2 狀態顯示**: - 動態背景顏色(V1: 橙色 #3F2E0F,V2: 綠色 #0F3F2E) - 圖標指示(V1: ⚠️,V2: ✅) - 狀態說明文字 **驗證邏輯**: - 必填字段:錢包地址 + L1 私鑰 - API Key 為可選字段 - 自動 V1/V2 檢測 **安全輸入**: - 支持通過 TwoStageKeyModal 安全導入私鑰 - 導入成功後顯示 toast 提示 **handleSaveExchange**: - 添加 3 個 LIGHTER 參數 - 更新交易所對象(新增/更新) - 構建 API 請求(snake_case 字段) **V1 模式(無 API Key)**: ``` ┌────────────────────────────────────────┐ │ ⚠️ LIGHTER V1 │ │ 基本模式 - 功能受限,僅用於測試框架 │ └────────────────────────────────────────┘ 背景: #3F2E0F (橙色調) 邊框: #F59E0B (橙色) ``` **V2 模式(有 API Key)**: ``` ┌────────────────────────────────────────┐ │ ✅ LIGHTER V2 │ │ 完整模式 - 支持 Poseidon2 簽名和真實交易 │ └────────────────────────────────────────┘ 背景: #0F3F2E (綠色調) 邊框: #10B981 (綠色) ``` 1. **類型安全** - 完整的 TypeScript 類型定義 - Props 接口正確對齊 - ✅ 無 LIGHTER 相關編譯錯誤 2. **用戶體驗** - 清晰的必填/可選字段區分 - 實時 V1/V2 狀態反饋 - 安全私鑰輸入支持 3. **向後兼容** - 不影響現有交易所配置 - 所有字段為可選(Optional) - API 請求格式統一 ✅ TypeScript 編譯通過(無 LIGHTER 錯誤) ✅ 類型定義完整且正確 ✅ 所有必需文件已更新 ✅ 與後端 API 格式對齊 Modified: - `web/src/i18n/translations.ts` - 中英文翻譯 - `web/src/types.ts` - 類型定義 - `web/src/components/traders/ExchangeConfigModal.tsx` - Modal 組件 - `web/src/hooks/useTraderActions.ts` - Actions hook 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * test(lighter): 添加 V1 測試套件與修復 SafeFloat64 缺失 - 新增 trader/helpers.go: 添加 SafeFloat64/SafeString/SafeInt 輔助函數 - 新增 trader/lighter_trader_test.go: LIGHTER V1 測試套件 - ✅ 測試通過 (7/10): - NewTrader 驗證 (無效私鑰, 有效私鑰格式) - FormatQuantity - GetExchangeType - InvalidQuantity 驗證 - InvalidLeverage 驗證 - HelperFunctions (SafeFloat64) - ⚠️ 待改進 (3/10): - GetBalance (需要調整 mock 響應格式) - GetPositions (需要調整 mock 響應格式) - GetMarketPrice (需要調整 mock 響應格式) - 修復 Bug: lighter_account.go 和 lighter_trader_v2_account.go 中未定義的 SafeFloat64 - 測試框架: httptest.Server mock LIGHTER API - 安全: 使用固定測試私鑰 (不含真實資金) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community <tinklefund@gmail.com> --------- Co-authored-by: the-dev-z <the-dev-z@users.noreply.github.com> Co-authored-by: tinkle-community <tinklefund@gmail.com>
1683 lines
55 KiB
Go
1683 lines
55 KiB
Go
package trader
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"nofx/decision"
|
||
"nofx/logger"
|
||
"nofx/market"
|
||
"nofx/mcp"
|
||
"nofx/pool"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// AutoTraderConfig 自动交易配置(简化版 - AI全权决策)
|
||
type AutoTraderConfig struct {
|
||
// Trader标识
|
||
ID string // Trader唯一标识(用于日志目录等)
|
||
Name string // Trader显示名称
|
||
AIModel string // AI模型: "qwen" 或 "deepseek"
|
||
|
||
// 交易平台选择
|
||
Exchange string // "binance", "hyperliquid", "aster" 或 "lighter"
|
||
|
||
// 币安API配置
|
||
BinanceAPIKey string
|
||
BinanceSecretKey string
|
||
|
||
// Hyperliquid配置
|
||
HyperliquidPrivateKey string
|
||
HyperliquidWalletAddr string
|
||
HyperliquidTestnet bool
|
||
|
||
// Aster配置
|
||
AsterUser string // Aster主钱包地址
|
||
AsterSigner string // Aster API钱包地址
|
||
AsterPrivateKey string // Aster API钱包私钥
|
||
|
||
// LIGHTER配置
|
||
LighterWalletAddr string // LIGHTER钱包地址(L1 wallet)
|
||
LighterPrivateKey string // LIGHTER L1私钥(用于识别账户)
|
||
LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易)
|
||
LighterTestnet bool // 是否使用testnet
|
||
|
||
CoinPoolAPIURL string
|
||
|
||
// AI配置
|
||
UseQwen bool
|
||
DeepSeekKey string
|
||
QwenKey string
|
||
|
||
// 自定义AI API配置
|
||
CustomAPIURL string
|
||
CustomAPIKey string
|
||
CustomModelName string
|
||
|
||
// 扫描配置
|
||
ScanInterval time.Duration // 扫描间隔(建议3分钟)
|
||
|
||
// 账户配置
|
||
InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置)
|
||
|
||
// 杠杆配置
|
||
BTCETHLeverage int // BTC和ETH的杠杆倍数
|
||
AltcoinLeverage int // 山寨币的杠杆倍数
|
||
|
||
// 风险控制(仅作为提示,AI可自主决定)
|
||
MaxDailyLoss float64 // 最大日亏损百分比(提示)
|
||
MaxDrawdown float64 // 最大回撤百分比(提示)
|
||
StopTradingTime time.Duration // 触发风控后暂停时长
|
||
|
||
// 仓位模式
|
||
IsCrossMargin bool // true=全仓模式, false=逐仓模式
|
||
|
||
// 币种配置
|
||
DefaultCoins []string // 默认币种列表(从数据库获取)
|
||
TradingCoins []string // 实际交易币种列表
|
||
|
||
// 系统提示词模板
|
||
SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive")
|
||
}
|
||
|
||
// AutoTrader 自动交易器
|
||
type AutoTrader struct {
|
||
id string // Trader唯一标识
|
||
name string // Trader显示名称
|
||
aiModel string // AI模型名称
|
||
exchange string // 交易平台名称
|
||
config AutoTraderConfig
|
||
trader Trader // 使用Trader接口(支持多平台)
|
||
mcpClient mcp.AIClient
|
||
decisionLogger logger.IDecisionLogger // 决策日志记录器
|
||
initialBalance float64
|
||
dailyPnL float64
|
||
customPrompt string // 自定义交易策略prompt
|
||
overrideBasePrompt bool // 是否覆盖基础prompt
|
||
systemPromptTemplate string // 系统提示词模板名称
|
||
defaultCoins []string // 默认币种列表(从数据库获取)
|
||
tradingCoins []string // 实际交易币种列表
|
||
lastResetTime time.Time
|
||
stopUntil time.Time
|
||
isRunning bool
|
||
startTime time.Time // 系统启动时间
|
||
callCount int // AI调用次数
|
||
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
|
||
stopMonitorCh chan struct{} // 用于停止监控goroutine
|
||
monitorWg sync.WaitGroup // 用于等待监控goroutine结束
|
||
peakPnLCache map[string]float64 // 最高收益缓存 (symbol -> 峰值盈亏百分比)
|
||
peakPnLCacheMutex sync.RWMutex // 缓存读写锁
|
||
lastBalanceSyncTime time.Time // 上次余额同步时间
|
||
database interface{} // 数据库引用(用于自动更新余额)
|
||
userID string // 用户ID
|
||
}
|
||
|
||
// NewAutoTrader 创建自动交易器
|
||
func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) (*AutoTrader, error) {
|
||
// 设置默认值
|
||
if config.ID == "" {
|
||
config.ID = "default_trader"
|
||
}
|
||
if config.Name == "" {
|
||
config.Name = "Default Trader"
|
||
}
|
||
if config.AIModel == "" {
|
||
if config.UseQwen {
|
||
config.AIModel = "qwen"
|
||
} else {
|
||
config.AIModel = "deepseek"
|
||
}
|
||
}
|
||
|
||
mcpClient := mcp.New()
|
||
|
||
// 初始化AI
|
||
if config.AIModel == "custom" {
|
||
// 使用自定义API
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
log.Printf("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else if config.UseQwen || config.AIModel == "qwen" {
|
||
// 使用Qwen (支持自定义URL和Model)
|
||
mcpClient = mcp.NewQwenClient()
|
||
mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName)
|
||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
||
log.Printf("🤖 [%s] 使用阿里云Qwen AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else {
|
||
log.Printf("🤖 [%s] 使用阿里云Qwen AI", config.Name)
|
||
}
|
||
} else {
|
||
// 默认使用DeepSeek (支持自定义URL和Model)
|
||
mcpClient = mcp.NewDeepSeekClient()
|
||
mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName)
|
||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
||
log.Printf("🤖 [%s] 使用DeepSeek AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else {
|
||
log.Printf("🤖 [%s] 使用DeepSeek AI", config.Name)
|
||
}
|
||
}
|
||
|
||
// 初始化币种池API
|
||
if config.CoinPoolAPIURL != "" {
|
||
pool.SetCoinPoolAPI(config.CoinPoolAPIURL)
|
||
}
|
||
|
||
// 设置默认交易平台
|
||
if config.Exchange == "" {
|
||
config.Exchange = "binance"
|
||
}
|
||
|
||
// 根据配置创建对应的交易器
|
||
var trader Trader
|
||
var err error
|
||
|
||
// 记录仓位模式(通用)
|
||
marginModeStr := "全仓"
|
||
if !config.IsCrossMargin {
|
||
marginModeStr = "逐仓"
|
||
}
|
||
log.Printf("📊 [%s] 仓位模式: %s", config.Name, marginModeStr)
|
||
|
||
switch config.Exchange {
|
||
case "binance":
|
||
log.Printf("🏦 [%s] 使用币安合约交易", config.Name)
|
||
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||
case "hyperliquid":
|
||
log.Printf("🏦 [%s] 使用Hyperliquid交易", config.Name)
|
||
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化Hyperliquid交易器失败: %w", err)
|
||
}
|
||
case "aster":
|
||
log.Printf("🏦 [%s] 使用Aster交易", config.Name)
|
||
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化Aster交易器失败: %w", err)
|
||
}
|
||
case "lighter":
|
||
log.Printf("🏦 [%s] 使用LIGHTER交易", config.Name)
|
||
|
||
// 優先使用 V2(需要 API Key)
|
||
if config.LighterAPIKeyPrivateKey != "" {
|
||
log.Printf("✓ 使用 LIGHTER SDK (V2) - 完整簽名支持")
|
||
trader, err = NewLighterTraderV2(
|
||
config.LighterPrivateKey,
|
||
config.LighterWalletAddr,
|
||
config.LighterAPIKeyPrivateKey,
|
||
config.LighterTestnet,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化LIGHTER交易器(V2)失败: %w", err)
|
||
}
|
||
} else {
|
||
// 降級使用 V1(基本HTTP實現)
|
||
log.Printf("⚠️ 使用 LIGHTER 基本實現 (V1) - 功能受限,請配置 API Key")
|
||
trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化LIGHTER交易器(V1)失败: %w", err)
|
||
}
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("不支持的交易平台: %s", config.Exchange)
|
||
}
|
||
|
||
// 验证初始金额配置
|
||
if config.InitialBalance <= 0 {
|
||
return nil, fmt.Errorf("初始金额必须大于0,请在配置中设置InitialBalance")
|
||
}
|
||
|
||
// 初始化决策日志记录器(使用trader ID创建独立目录)
|
||
logDir := fmt.Sprintf("decision_logs/%s", config.ID)
|
||
decisionLogger := logger.NewDecisionLogger(logDir)
|
||
|
||
// 设置默认系统提示词模板
|
||
systemPromptTemplate := config.SystemPromptTemplate
|
||
if systemPromptTemplate == "" {
|
||
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
|
||
systemPromptTemplate = "adaptive"
|
||
}
|
||
|
||
return &AutoTrader{
|
||
id: config.ID,
|
||
name: config.Name,
|
||
aiModel: config.AIModel,
|
||
exchange: config.Exchange,
|
||
config: config,
|
||
trader: trader,
|
||
mcpClient: mcpClient,
|
||
decisionLogger: decisionLogger,
|
||
initialBalance: config.InitialBalance,
|
||
systemPromptTemplate: systemPromptTemplate,
|
||
defaultCoins: config.DefaultCoins,
|
||
tradingCoins: config.TradingCoins,
|
||
lastResetTime: time.Now(),
|
||
startTime: time.Now(),
|
||
callCount: 0,
|
||
isRunning: false,
|
||
positionFirstSeenTime: make(map[string]int64),
|
||
stopMonitorCh: make(chan struct{}),
|
||
monitorWg: sync.WaitGroup{},
|
||
peakPnLCache: make(map[string]float64),
|
||
peakPnLCacheMutex: sync.RWMutex{},
|
||
lastBalanceSyncTime: time.Now(), // 初始化为当前时间
|
||
database: database,
|
||
userID: userID,
|
||
}, nil
|
||
}
|
||
|
||
// Run 运行自动交易主循环
|
||
func (at *AutoTrader) Run() error {
|
||
at.isRunning = true
|
||
at.stopMonitorCh = make(chan struct{})
|
||
at.startTime = time.Now()
|
||
|
||
log.Println("🚀 AI驱动自动交易系统启动")
|
||
log.Printf("💰 初始余额: %.2f USDT", at.initialBalance)
|
||
log.Printf("⚙️ 扫描间隔: %v", at.config.ScanInterval)
|
||
log.Println("🤖 AI将全权决定杠杆、仓位大小、止损止盈等参数")
|
||
at.monitorWg.Add(1)
|
||
defer at.monitorWg.Done()
|
||
|
||
// 启动回撤监控
|
||
at.startDrawdownMonitor()
|
||
|
||
ticker := time.NewTicker(at.config.ScanInterval)
|
||
defer ticker.Stop()
|
||
|
||
// 首次立即执行
|
||
if err := at.runCycle(); err != nil {
|
||
log.Printf("❌ 执行失败: %v", err)
|
||
}
|
||
|
||
for at.isRunning {
|
||
select {
|
||
case <-ticker.C:
|
||
if err := at.runCycle(); err != nil {
|
||
log.Printf("❌ 执行失败: %v", err)
|
||
}
|
||
case <-at.stopMonitorCh:
|
||
log.Printf("[%s] ⏹ 收到停止信号,退出自动交易主循环", at.name)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Stop 停止自动交易
|
||
func (at *AutoTrader) Stop() {
|
||
if !at.isRunning {
|
||
return
|
||
}
|
||
at.isRunning = false
|
||
close(at.stopMonitorCh) // 通知监控goroutine停止
|
||
at.monitorWg.Wait() // 等待监控goroutine结束
|
||
log.Println("⏹ 自动交易系统停止")
|
||
}
|
||
|
||
// runCycle 运行一个交易周期(使用AI全权决策)
|
||
func (at *AutoTrader) runCycle() error {
|
||
at.callCount++
|
||
|
||
log.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||
log.Printf("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||
log.Println(strings.Repeat("=", 70))
|
||
|
||
// 创建决策记录
|
||
record := &logger.DecisionRecord{
|
||
ExecutionLog: []string{},
|
||
Success: true,
|
||
}
|
||
|
||
// 1. 检查是否需要停止交易
|
||
if time.Now().Before(at.stopUntil) {
|
||
remaining := at.stopUntil.Sub(time.Now())
|
||
log.Printf("⏸ 风险控制:暂停交易中,剩余 %.0f 分钟", remaining.Minutes())
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("风险控制暂停中,剩余 %.0f 分钟", remaining.Minutes())
|
||
at.decisionLogger.LogDecision(record)
|
||
return nil
|
||
}
|
||
|
||
// 2. 重置日盈亏(每天重置)
|
||
if time.Since(at.lastResetTime) > 24*time.Hour {
|
||
at.dailyPnL = 0
|
||
at.lastResetTime = time.Now()
|
||
log.Println("📅 日盈亏已重置")
|
||
}
|
||
|
||
// 4. 收集交易上下文
|
||
ctx, err := at.buildTradingContext()
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("构建交易上下文失败: %v", err)
|
||
at.decisionLogger.LogDecision(record)
|
||
return fmt.Errorf("构建交易上下文失败: %w", err)
|
||
}
|
||
|
||
// 保存账户状态快照
|
||
record.AccountState = logger.AccountSnapshot{
|
||
TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
||
AvailableBalance: ctx.Account.AvailableBalance,
|
||
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
|
||
PositionCount: ctx.Account.PositionCount,
|
||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||
InitialBalance: at.initialBalance, // 记录当时的初始余额基准
|
||
}
|
||
|
||
// 保存持仓快照
|
||
for _, pos := range ctx.Positions {
|
||
record.Positions = append(record.Positions, logger.PositionSnapshot{
|
||
Symbol: pos.Symbol,
|
||
Side: pos.Side,
|
||
PositionAmt: pos.Quantity,
|
||
EntryPrice: pos.EntryPrice,
|
||
MarkPrice: pos.MarkPrice,
|
||
UnrealizedProfit: pos.UnrealizedPnL,
|
||
Leverage: float64(pos.Leverage),
|
||
LiquidationPrice: pos.LiquidationPrice,
|
||
})
|
||
}
|
||
|
||
log.Print(strings.Repeat("=", 70))
|
||
for _, coin := range ctx.CandidateCoins {
|
||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||
}
|
||
|
||
log.Printf("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d",
|
||
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
|
||
|
||
// 5. 调用AI获取完整决策
|
||
log.Printf("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate)
|
||
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate)
|
||
|
||
if decision != nil && decision.AIRequestDurationMs > 0 {
|
||
record.AIRequestDurationMs = decision.AIRequestDurationMs
|
||
log.Printf("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000)
|
||
record.ExecutionLog = append(record.ExecutionLog,
|
||
fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs))
|
||
}
|
||
|
||
// 即使有错误,也保存思维链、决策和输入prompt(用于debug)
|
||
if decision != nil {
|
||
record.SystemPrompt = decision.SystemPrompt // 保存系统提示词
|
||
record.InputPrompt = decision.UserPrompt
|
||
record.CoTTrace = decision.CoTTrace
|
||
if len(decision.Decisions) > 0 {
|
||
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ")
|
||
record.DecisionJSON = string(decisionJSON)
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err)
|
||
|
||
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
|
||
if decision != nil {
|
||
log.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||
log.Printf("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate)
|
||
log.Println(strings.Repeat("=", 70))
|
||
log.Println(decision.SystemPrompt)
|
||
log.Println(strings.Repeat("=", 70))
|
||
|
||
if decision.CoTTrace != "" {
|
||
log.Print("\n" + strings.Repeat("-", 70) + "\n")
|
||
log.Println("💭 AI思维链分析(错误情况):")
|
||
log.Println(strings.Repeat("-", 70))
|
||
log.Println(decision.CoTTrace)
|
||
log.Println(strings.Repeat("-", 70))
|
||
}
|
||
}
|
||
|
||
at.decisionLogger.LogDecision(record)
|
||
return fmt.Errorf("获取AI决策失败: %w", err)
|
||
}
|
||
|
||
// // 5. 打印系统提示词
|
||
// log.Printf("\n" + strings.Repeat("=", 70))
|
||
// log.Printf("📋 系统提示词 [模板: %s]", at.systemPromptTemplate)
|
||
// log.Println(strings.Repeat("=", 70))
|
||
// log.Println(decision.SystemPrompt)
|
||
// log.Printf(strings.Repeat("=", 70) + "\n")
|
||
|
||
// 6. 打印AI思维链
|
||
// log.Printf("\n" + strings.Repeat("-", 70))
|
||
// log.Println("💭 AI思维链分析:")
|
||
// log.Println(strings.Repeat("-", 70))
|
||
// log.Println(decision.CoTTrace)
|
||
// log.Printf(strings.Repeat("-", 70) + "\n")
|
||
|
||
// 7. 打印AI决策
|
||
// log.Printf("📋 AI决策列表 (%d 个):\n", len(decision.Decisions))
|
||
// for i, d := range decision.Decisions {
|
||
// log.Printf(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||
// if d.Action == "open_long" || d.Action == "open_short" {
|
||
// log.Printf(" 杠杆: %dx | 仓位: %.2f USDT | 止损: %.4f | 止盈: %.4f",
|
||
// d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit)
|
||
// }
|
||
// }
|
||
log.Println()
|
||
log.Print(strings.Repeat("-", 70))
|
||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||
log.Print(strings.Repeat("-", 70))
|
||
|
||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||
sortedDecisions := sortDecisionsByPriority(decision.Decisions)
|
||
|
||
log.Println("🔄 执行顺序(已优化): 先平仓→后开仓")
|
||
for i, d := range sortedDecisions {
|
||
log.Printf(" [%d] %s %s", i+1, d.Symbol, d.Action)
|
||
}
|
||
log.Println()
|
||
|
||
// 执行决策并记录结果
|
||
for _, d := range sortedDecisions {
|
||
actionRecord := logger.DecisionAction{
|
||
Action: d.Action,
|
||
Symbol: d.Symbol,
|
||
Quantity: 0,
|
||
Leverage: d.Leverage,
|
||
Price: 0,
|
||
Timestamp: time.Now(),
|
||
Success: false,
|
||
}
|
||
|
||
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
|
||
log.Printf("❌ 执行决策失败 (%s %s): %v", d.Symbol, d.Action, err)
|
||
actionRecord.Error = err.Error()
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s 失败: %v", d.Symbol, d.Action, err))
|
||
} else {
|
||
actionRecord.Success = true
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s 成功", d.Symbol, d.Action))
|
||
// 成功执行后短暂延迟
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
record.Decisions = append(record.Decisions, actionRecord)
|
||
}
|
||
|
||
// 9. 保存决策记录
|
||
if err := at.decisionLogger.LogDecision(record); err != nil {
|
||
log.Printf("⚠ 保存决策记录失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildTradingContext 构建交易上下文
|
||
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||
// 1. 获取账户信息
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
|
||
// 获取账户字段
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Total Equity = 钱包余额 + 未实现盈亏
|
||
totalEquity := totalWalletBalance + totalUnrealizedProfit
|
||
|
||
// 2. 获取持仓信息
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
var positionInfos []decision.PositionInfo
|
||
totalMarginUsed := 0.0
|
||
|
||
// 当前持仓的key集合(用于清理已平仓的记录)
|
||
currentPositionKeys := make(map[string]bool)
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // 空仓数量为负,转为正数
|
||
}
|
||
|
||
// 跳过已平仓的持仓(quantity = 0),防止"幽灵持仓"传递给AI
|
||
if quantity == 0 {
|
||
continue
|
||
}
|
||
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
// 计算占用保证金(估算)
|
||
leverage := 10 // 默认值,实际应该从持仓信息获取
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
|
||
// 计算盈亏百分比(基于保证金,考虑杠杆)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
// 跟踪持仓首次出现时间
|
||
posKey := symbol + "_" + side
|
||
currentPositionKeys[posKey] = true
|
||
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
|
||
// 新持仓,记录当前时间
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
}
|
||
updateTime := at.positionFirstSeenTime[posKey]
|
||
|
||
// 获取该持仓的历史最高收益率
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnlPct := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
positionInfos = append(positionInfos, decision.PositionInfo{
|
||
Symbol: symbol,
|
||
Side: side,
|
||
EntryPrice: entryPrice,
|
||
MarkPrice: markPrice,
|
||
Quantity: quantity,
|
||
Leverage: leverage,
|
||
UnrealizedPnL: unrealizedPnl,
|
||
UnrealizedPnLPct: pnlPct,
|
||
PeakPnLPct: peakPnlPct,
|
||
LiquidationPrice: liquidationPrice,
|
||
MarginUsed: marginUsed,
|
||
UpdateTime: updateTime,
|
||
})
|
||
}
|
||
|
||
// 清理已平仓的持仓记录
|
||
for key := range at.positionFirstSeenTime {
|
||
if !currentPositionKeys[key] {
|
||
delete(at.positionFirstSeenTime, key)
|
||
}
|
||
}
|
||
|
||
// 3. 获取交易员的候选币种池
|
||
candidateCoins, err := at.getCandidateCoins()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取候选币种失败: %w", err)
|
||
}
|
||
|
||
// 4. 计算总盈亏
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
// 5. 分析历史表现(最近100个周期,避免长期持仓的交易记录丢失)
|
||
// 假设每3分钟一个周期,100个周期 = 5小时,足够覆盖大部分交易
|
||
performance, err := at.decisionLogger.AnalyzePerformance(100)
|
||
if err != nil {
|
||
log.Printf("⚠️ 分析历史表现失败: %v", err)
|
||
// 不影响主流程,继续执行(但设置performance为nil以避免传递错误数据)
|
||
performance = nil
|
||
}
|
||
|
||
// 6. 构建上下文
|
||
ctx := &decision.Context{
|
||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||
CallCount: at.callCount,
|
||
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数
|
||
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数
|
||
Account: decision.AccountInfo{
|
||
TotalEquity: totalEquity,
|
||
AvailableBalance: availableBalance,
|
||
UnrealizedPnL: totalUnrealizedProfit,
|
||
TotalPnL: totalPnL,
|
||
TotalPnLPct: totalPnLPct,
|
||
MarginUsed: totalMarginUsed,
|
||
MarginUsedPct: marginUsedPct,
|
||
PositionCount: len(positionInfos),
|
||
},
|
||
Positions: positionInfos,
|
||
CandidateCoins: candidateCoins,
|
||
Performance: performance, // 添加历史表现分析
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|
||
|
||
// executeDecisionWithRecord 执行AI决策并记录详细信息
|
||
func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
switch decision.Action {
|
||
case "open_long":
|
||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
||
case "open_short":
|
||
return at.executeOpenShortWithRecord(decision, actionRecord)
|
||
case "close_long":
|
||
return at.executeCloseLongWithRecord(decision, actionRecord)
|
||
case "close_short":
|
||
return at.executeCloseShortWithRecord(decision, actionRecord)
|
||
case "update_stop_loss":
|
||
return at.executeUpdateStopLossWithRecord(decision, actionRecord)
|
||
case "update_take_profit":
|
||
return at.executeUpdateTakeProfitWithRecord(decision, actionRecord)
|
||
case "partial_close":
|
||
return at.executePartialCloseWithRecord(decision, actionRecord)
|
||
case "hold", "wait":
|
||
// 无需执行,仅记录
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("未知的action: %s", decision.Action)
|
||
}
|
||
}
|
||
|
||
// executeOpenLongWithRecord 执行开多仓并记录详细信息
|
||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 📈 开多仓: %s", decision.Symbol)
|
||
|
||
// ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限)
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||
return fmt.Errorf("❌ %s 已有多仓,拒绝开仓以防止仓位叠加超限。如需换仓,请先给出 close_long 决策", decision.Symbol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 计算数量
|
||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// 手续费估算(Taker费率 0.04%)
|
||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||
totalRequired := requiredMargin + estimatedFee
|
||
|
||
if totalRequired > availableBalance {
|
||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||
}
|
||
|
||
// 设置仓位模式
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||
// 继续执行,不影响交易
|
||
}
|
||
|
||
// 开仓
|
||
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
log.Printf(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity)
|
||
|
||
// 记录开仓时间
|
||
posKey := decision.Symbol + "_long"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// 设置止损止盈
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
|
||
log.Printf(" ⚠ 设置止损失败: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil {
|
||
log.Printf(" ⚠ 设置止盈失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeOpenShortWithRecord 执行开空仓并记录详细信息
|
||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 📉 开空仓: %s", decision.Symbol)
|
||
|
||
// ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限)
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||
return fmt.Errorf("❌ %s 已有空仓,拒绝开仓以防止仓位叠加超限。如需换仓,请先给出 close_short 决策", decision.Symbol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 计算数量
|
||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// 手续费估算(Taker费率 0.04%)
|
||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||
totalRequired := requiredMargin + estimatedFee
|
||
|
||
if totalRequired > availableBalance {
|
||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||
}
|
||
|
||
// 设置仓位模式
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||
// 继续执行,不影响交易
|
||
}
|
||
|
||
// 开仓
|
||
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
log.Printf(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity)
|
||
|
||
// 记录开仓时间
|
||
posKey := decision.Symbol + "_short"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// 设置止损止盈
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
|
||
log.Printf(" ⚠ 设置止损失败: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil {
|
||
log.Printf(" ⚠ 设置止盈失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeCloseLongWithRecord 执行平多仓并记录详细信息
|
||
func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 🔄 平多仓: %s", decision.Symbol)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 平仓
|
||
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
log.Printf(" ✓ 平仓成功")
|
||
return nil
|
||
}
|
||
|
||
// executeCloseShortWithRecord 执行平空仓并记录详细信息
|
||
func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 🔄 平空仓: %s", decision.Symbol)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 平仓
|
||
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
log.Printf(" ✓ 平仓成功")
|
||
return nil
|
||
}
|
||
|
||
// executeUpdateStopLossWithRecord 执行调整止损并记录详细信息
|
||
func (at *AutoTrader) executeUpdateStopLossWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 🎯 调整止损: %s → %.2f", decision.Symbol, decision.NewStopLoss)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 获取当前持仓
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
// 查找目标持仓
|
||
var targetPosition map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
posAmt, _ := pos["positionAmt"].(float64)
|
||
if symbol == decision.Symbol && posAmt != 0 {
|
||
targetPosition = pos
|
||
break
|
||
}
|
||
}
|
||
|
||
if targetPosition == nil {
|
||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||
}
|
||
|
||
// 获取持仓方向和数量
|
||
side, _ := targetPosition["side"].(string)
|
||
positionSide := strings.ToUpper(side)
|
||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||
|
||
// 验证新止损价格合理性
|
||
if positionSide == "LONG" && decision.NewStopLoss >= marketData.CurrentPrice {
|
||
return fmt.Errorf("多单止损必须低于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
|
||
}
|
||
if positionSide == "SHORT" && decision.NewStopLoss <= marketData.CurrentPrice {
|
||
return fmt.Errorf("空单止损必须高于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
|
||
}
|
||
|
||
// ⚠️ 防御性检查:检测是否存在双向持仓(不应该出现,但提供保护)
|
||
var hasOppositePosition bool
|
||
oppositeSide := ""
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
posSide, _ := pos["side"].(string)
|
||
posAmt, _ := pos["positionAmt"].(float64)
|
||
if symbol == decision.Symbol && posAmt != 0 && strings.ToUpper(posSide) != positionSide {
|
||
hasOppositePosition = true
|
||
oppositeSide = strings.ToUpper(posSide)
|
||
break
|
||
}
|
||
}
|
||
|
||
if hasOppositePosition {
|
||
log.Printf(" 🚨 警告:检测到 %s 存在双向持仓(%s + %s),这违反了策略规则",
|
||
decision.Symbol, positionSide, oppositeSide)
|
||
log.Printf(" 🚨 取消止损单将影响两个方向的订单,请检查是否为用户手动操作导致")
|
||
log.Printf(" 🚨 建议:手动平掉其中一个方向的持仓,或检查系统是否有BUG")
|
||
}
|
||
|
||
// 取消旧的止损单(只删除止损单,不影响止盈单)
|
||
// 注意:如果存在双向持仓,这会删除两个方向的止损单
|
||
if err := at.trader.CancelStopLossOrders(decision.Symbol); err != nil {
|
||
log.Printf(" ⚠ 取消旧止损单失败: %v", err)
|
||
// 不中断执行,继续设置新止损
|
||
}
|
||
|
||
// 调用交易所 API 修改止损
|
||
quantity := math.Abs(positionAmt)
|
||
err = at.trader.SetStopLoss(decision.Symbol, positionSide, quantity, decision.NewStopLoss)
|
||
if err != nil {
|
||
return fmt.Errorf("修改止损失败: %w", err)
|
||
}
|
||
|
||
log.Printf(" ✓ 止损已调整: %.2f (当前价格: %.2f)", decision.NewStopLoss, marketData.CurrentPrice)
|
||
return nil
|
||
}
|
||
|
||
// executeUpdateTakeProfitWithRecord 执行调整止盈并记录详细信息
|
||
func (at *AutoTrader) executeUpdateTakeProfitWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 🎯 调整止盈: %s → %.2f", decision.Symbol, decision.NewTakeProfit)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 获取当前持仓
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
// 查找目标持仓
|
||
var targetPosition map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
posAmt, _ := pos["positionAmt"].(float64)
|
||
if symbol == decision.Symbol && posAmt != 0 {
|
||
targetPosition = pos
|
||
break
|
||
}
|
||
}
|
||
|
||
if targetPosition == nil {
|
||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||
}
|
||
|
||
// 获取持仓方向和数量
|
||
side, _ := targetPosition["side"].(string)
|
||
positionSide := strings.ToUpper(side)
|
||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||
|
||
// 验证新止盈价格合理性
|
||
if positionSide == "LONG" && decision.NewTakeProfit <= marketData.CurrentPrice {
|
||
return fmt.Errorf("多单止盈必须高于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
|
||
}
|
||
if positionSide == "SHORT" && decision.NewTakeProfit >= marketData.CurrentPrice {
|
||
return fmt.Errorf("空单止盈必须低于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
|
||
}
|
||
|
||
// ⚠️ 防御性检查:检测是否存在双向持仓(不应该出现,但提供保护)
|
||
var hasOppositePosition bool
|
||
oppositeSide := ""
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
posSide, _ := pos["side"].(string)
|
||
posAmt, _ := pos["positionAmt"].(float64)
|
||
if symbol == decision.Symbol && posAmt != 0 && strings.ToUpper(posSide) != positionSide {
|
||
hasOppositePosition = true
|
||
oppositeSide = strings.ToUpper(posSide)
|
||
break
|
||
}
|
||
}
|
||
|
||
if hasOppositePosition {
|
||
log.Printf(" 🚨 警告:检测到 %s 存在双向持仓(%s + %s),这违反了策略规则",
|
||
decision.Symbol, positionSide, oppositeSide)
|
||
log.Printf(" 🚨 取消止盈单将影响两个方向的订单,请检查是否为用户手动操作导致")
|
||
log.Printf(" 🚨 建议:手动平掉其中一个方向的持仓,或检查系统是否有BUG")
|
||
}
|
||
|
||
// 取消旧的止盈单(只删除止盈单,不影响止损单)
|
||
// 注意:如果存在双向持仓,这会删除两个方向的止盈单
|
||
if err := at.trader.CancelTakeProfitOrders(decision.Symbol); err != nil {
|
||
log.Printf(" ⚠ 取消旧止盈单失败: %v", err)
|
||
// 不中断执行,继续设置新止盈
|
||
}
|
||
|
||
// 调用交易所 API 修改止盈
|
||
quantity := math.Abs(positionAmt)
|
||
err = at.trader.SetTakeProfit(decision.Symbol, positionSide, quantity, decision.NewTakeProfit)
|
||
if err != nil {
|
||
return fmt.Errorf("修改止盈失败: %w", err)
|
||
}
|
||
|
||
log.Printf(" ✓ 止盈已调整: %.2f (当前价格: %.2f)", decision.NewTakeProfit, marketData.CurrentPrice)
|
||
return nil
|
||
}
|
||
|
||
// executePartialCloseWithRecord 执行部分平仓并记录详细信息
|
||
func (at *AutoTrader) executePartialCloseWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||
log.Printf(" 📊 部分平仓: %s %.1f%%", decision.Symbol, decision.ClosePercentage)
|
||
|
||
// 验证百分比范围
|
||
if decision.ClosePercentage <= 0 || decision.ClosePercentage > 100 {
|
||
return fmt.Errorf("平仓百分比必须在 0-100 之间,当前: %.1f", decision.ClosePercentage)
|
||
}
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 获取当前持仓
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
// 查找目标持仓
|
||
var targetPosition map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
posAmt, _ := pos["positionAmt"].(float64)
|
||
if symbol == decision.Symbol && posAmt != 0 {
|
||
targetPosition = pos
|
||
break
|
||
}
|
||
}
|
||
|
||
if targetPosition == nil {
|
||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||
}
|
||
|
||
// 获取持仓方向和数量
|
||
side, _ := targetPosition["side"].(string)
|
||
positionSide := strings.ToUpper(side)
|
||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||
|
||
// 计算平仓数量
|
||
totalQuantity := math.Abs(positionAmt)
|
||
closeQuantity := totalQuantity * (decision.ClosePercentage / 100.0)
|
||
actionRecord.Quantity = closeQuantity
|
||
|
||
// ✅ Layer 2: 最小仓位检查(防止产生小额剩余)
|
||
markPrice, ok := targetPosition["markPrice"].(float64)
|
||
if !ok || markPrice <= 0 {
|
||
return fmt.Errorf("无法解析当前价格,无法执行最小仓位检查")
|
||
}
|
||
|
||
currentPositionValue := totalQuantity * markPrice
|
||
remainingQuantity := totalQuantity - closeQuantity
|
||
remainingValue := remainingQuantity * markPrice
|
||
|
||
const MIN_POSITION_VALUE = 10.0 // 最小持仓价值 10 USDT(對齊交易所底线,小仓位建议直接全平)
|
||
|
||
if remainingValue > 0 && remainingValue <= MIN_POSITION_VALUE {
|
||
log.Printf("⚠️ 检测到 partial_close 后剩余仓位 %.2f USDT < %.0f USDT",
|
||
remainingValue, MIN_POSITION_VALUE)
|
||
log.Printf(" → 当前仓位价值: %.2f USDT, 平仓 %.1f%%, 剩余: %.2f USDT",
|
||
currentPositionValue, decision.ClosePercentage, remainingValue)
|
||
log.Printf(" → 自动修正为全部平仓,避免产生无法平仓的小额剩余")
|
||
|
||
// 🔄 自动修正为全部平仓
|
||
if positionSide == "LONG" {
|
||
decision.Action = "close_long"
|
||
log.Printf(" ✓ 已修正为: close_long")
|
||
return at.executeCloseLongWithRecord(decision, actionRecord)
|
||
} else {
|
||
decision.Action = "close_short"
|
||
log.Printf(" ✓ 已修正为: close_short")
|
||
return at.executeCloseShortWithRecord(decision, actionRecord)
|
||
}
|
||
}
|
||
|
||
// 执行平仓
|
||
var order map[string]interface{}
|
||
if positionSide == "LONG" {
|
||
order, err = at.trader.CloseLong(decision.Symbol, closeQuantity)
|
||
} else {
|
||
order, err = at.trader.CloseShort(decision.Symbol, closeQuantity)
|
||
}
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("部分平仓失败: %w", err)
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
log.Printf(" ✓ 部分平仓成功: 平仓 %.4f (%.1f%%), 剩余 %.4f",
|
||
closeQuantity, decision.ClosePercentage, remainingQuantity)
|
||
|
||
// ✅ Step 4: 恢复止盈止损(防止剩余仓位裸奔)
|
||
// 重要:币安等交易所在部分平仓后会自动取消原有的 TP/SL 订单(因为数量不匹配)
|
||
// 如果 AI 提供了新的止损止盈价格,则为剩余仓位重新设置保护
|
||
if decision.NewStopLoss > 0 {
|
||
log.Printf(" → 为剩余仓位 %.4f 恢复止损单: %.2f", remainingQuantity, decision.NewStopLoss)
|
||
err = at.trader.SetStopLoss(decision.Symbol, positionSide, remainingQuantity, decision.NewStopLoss)
|
||
if err != nil {
|
||
log.Printf(" ⚠️ 恢复止损失败: %v(不影响平仓结果)", err)
|
||
}
|
||
}
|
||
|
||
if decision.NewTakeProfit > 0 {
|
||
log.Printf(" → 为剩余仓位 %.4f 恢复止盈单: %.2f", remainingQuantity, decision.NewTakeProfit)
|
||
err = at.trader.SetTakeProfit(decision.Symbol, positionSide, remainingQuantity, decision.NewTakeProfit)
|
||
if err != nil {
|
||
log.Printf(" ⚠️ 恢复止盈失败: %v(不影响平仓结果)", err)
|
||
}
|
||
}
|
||
|
||
// 如果 AI 没有提供新的止盈止损,记录警告
|
||
if decision.NewStopLoss <= 0 && decision.NewTakeProfit <= 0 {
|
||
log.Printf(" ⚠️⚠️⚠️ 警告: 部分平仓后AI未提供新的止盈止损价格")
|
||
log.Printf(" → 剩余仓位 %.4f (价值 %.2f USDT) 目前没有止盈止损保护", remainingQuantity, remainingValue)
|
||
log.Printf(" → 建议: 在 partial_close 决策中包含 new_stop_loss 和 new_take_profit 字段")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetID 获取trader ID
|
||
func (at *AutoTrader) GetID() string {
|
||
return at.id
|
||
}
|
||
|
||
// GetName 获取trader名称
|
||
func (at *AutoTrader) GetName() string {
|
||
return at.name
|
||
}
|
||
|
||
// GetAIModel 获取AI模型
|
||
func (at *AutoTrader) GetAIModel() string {
|
||
return at.aiModel
|
||
}
|
||
|
||
// GetExchange 获取交易所
|
||
func (at *AutoTrader) GetExchange() string {
|
||
return at.exchange
|
||
}
|
||
|
||
// SetCustomPrompt 设置自定义交易策略prompt
|
||
func (at *AutoTrader) SetCustomPrompt(prompt string) {
|
||
at.customPrompt = prompt
|
||
}
|
||
|
||
// SetOverrideBasePrompt 设置是否覆盖基础prompt
|
||
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
|
||
at.overrideBasePrompt = override
|
||
}
|
||
|
||
// SetSystemPromptTemplate 设置系统提示词模板
|
||
func (at *AutoTrader) SetSystemPromptTemplate(templateName string) {
|
||
at.systemPromptTemplate = templateName
|
||
}
|
||
|
||
// GetSystemPromptTemplate 获取当前系统提示词模板名称
|
||
func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||
return at.systemPromptTemplate
|
||
}
|
||
|
||
// GetDecisionLogger 获取决策日志记录器
|
||
func (at *AutoTrader) GetDecisionLogger() logger.IDecisionLogger {
|
||
return at.decisionLogger
|
||
}
|
||
|
||
// GetStatus 获取系统状态(用于API)
|
||
func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||
aiProvider := "DeepSeek"
|
||
if at.config.UseQwen {
|
||
aiProvider = "Qwen"
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"trader_id": at.id,
|
||
"trader_name": at.name,
|
||
"ai_model": at.aiModel,
|
||
"exchange": at.exchange,
|
||
"is_running": at.isRunning,
|
||
"start_time": at.startTime.Format(time.RFC3339),
|
||
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
||
"call_count": at.callCount,
|
||
"initial_balance": at.initialBalance,
|
||
"scan_interval": at.config.ScanInterval.String(),
|
||
"stop_until": at.stopUntil.Format(time.RFC3339),
|
||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||
"ai_provider": aiProvider,
|
||
}
|
||
}
|
||
|
||
// GetAccountInfo 获取账户信息(用于API)
|
||
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取余额失败: %w", err)
|
||
}
|
||
|
||
// 获取账户字段
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Total Equity = 钱包余额 + 未实现盈亏
|
||
totalEquity := totalWalletBalance + totalUnrealizedProfit
|
||
|
||
// 获取持仓计算总保证金
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
totalMarginUsed := 0.0
|
||
totalUnrealizedPnLCalculated := 0.0
|
||
for _, pos := range positions {
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
totalUnrealizedPnLCalculated += unrealizedPnl
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
}
|
||
|
||
// 验证未实现盈亏的一致性(API值 vs 从持仓计算)
|
||
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
||
if diff > 0.1 { // 允许0.01 USDT的误差
|
||
log.Printf("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f",
|
||
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
||
}
|
||
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
} else {
|
||
log.Printf("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance)
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
// 核心字段
|
||
"total_equity": totalEquity, // 账户净值 = wallet + unrealized
|
||
"wallet_balance": totalWalletBalance, // 钱包余额(不含未实现盈亏)
|
||
"unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(交易所API官方值)
|
||
"available_balance": availableBalance, // 可用余额
|
||
|
||
// 盈亏统计
|
||
"total_pnl": totalPnL, // 总盈亏 = equity - initial
|
||
"total_pnl_pct": totalPnLPct, // 总盈亏百分比
|
||
"initial_balance": at.initialBalance, // 初始余额
|
||
"daily_pnl": at.dailyPnL, // 日盈亏
|
||
|
||
// 持仓信息
|
||
"position_count": len(positions), // 持仓数量
|
||
"margin_used": totalMarginUsed, // 保证金占用
|
||
"margin_used_pct": marginUsedPct, // 保证金使用率
|
||
}, nil
|
||
}
|
||
|
||
// GetPositions 获取持仓列表(用于API)
|
||
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
var result []map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
// 计算占用保证金
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
|
||
// 计算盈亏百分比(基于保证金)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
result = append(result, map[string]interface{}{
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"entry_price": entryPrice,
|
||
"mark_price": markPrice,
|
||
"quantity": quantity,
|
||
"leverage": leverage,
|
||
"unrealized_pnl": unrealizedPnl,
|
||
"unrealized_pnl_pct": pnlPct,
|
||
"liquidation_price": liquidationPrice,
|
||
"margin_used": marginUsed,
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// calculatePnLPercentage 计算盈亏百分比(基于保证金,自动考虑杠杆)
|
||
// 收益率 = 未实现盈亏 / 保证金 × 100%
|
||
func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
||
if marginUsed > 0 {
|
||
return (unrealizedPnl / marginUsed) * 100
|
||
}
|
||
return 0.0
|
||
}
|
||
|
||
// sortDecisionsByPriority 对决策排序:先平仓,再开仓,最后hold/wait
|
||
// 这样可以避免换仓时仓位叠加超限
|
||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||
if len(decisions) <= 1 {
|
||
return decisions
|
||
}
|
||
|
||
// 定义优先级
|
||
getActionPriority := func(action string) int {
|
||
switch action {
|
||
case "close_long", "close_short", "partial_close":
|
||
return 1 // 最高优先级:先平仓(包括部分平仓)
|
||
case "update_stop_loss", "update_take_profit":
|
||
return 2 // 调整持仓止盈止损
|
||
case "open_long", "open_short":
|
||
return 3 // 次优先级:后开仓
|
||
case "hold", "wait":
|
||
return 4 // 最低优先级:观望
|
||
default:
|
||
return 999 // 未知动作放最后
|
||
}
|
||
}
|
||
|
||
// 复制决策列表
|
||
sorted := make([]decision.Decision, len(decisions))
|
||
copy(sorted, decisions)
|
||
|
||
// 按优先级排序
|
||
for i := 0; i < len(sorted)-1; i++ {
|
||
for j := i + 1; j < len(sorted); j++ {
|
||
if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) {
|
||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
return sorted
|
||
}
|
||
|
||
// getCandidateCoins 获取交易员的候选币种列表
|
||
func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) {
|
||
if len(at.tradingCoins) == 0 {
|
||
// 使用数据库配置的默认币种列表
|
||
var candidateCoins []decision.CandidateCoin
|
||
|
||
if len(at.defaultCoins) > 0 {
|
||
// 使用数据库中配置的默认币种
|
||
for _, coin := range at.defaultCoins {
|
||
symbol := normalizeSymbol(coin)
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: []string{"default"}, // 标记为数据库默认币种
|
||
})
|
||
}
|
||
log.Printf("📋 [%s] 使用数据库默认币种: %d个币种 %v",
|
||
at.name, len(candidateCoins), at.defaultCoins)
|
||
return candidateCoins, nil
|
||
} else {
|
||
// 如果数据库中没有配置默认币种,则使用AI500+OI Top作为fallback
|
||
const ai500Limit = 20 // AI500取前20个评分最高的币种
|
||
|
||
mergedPool, err := pool.GetMergedCoinPool(ai500Limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取合并币种池失败: %w", err)
|
||
}
|
||
|
||
// 构建候选币种列表(包含来源信息)
|
||
for _, symbol := range mergedPool.AllSymbols {
|
||
sources := mergedPool.SymbolSources[symbol]
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: sources, // "ai500" 和/或 "oi_top"
|
||
})
|
||
}
|
||
|
||
log.Printf("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种",
|
||
at.name, ai500Limit, len(candidateCoins))
|
||
return candidateCoins, nil
|
||
}
|
||
} else {
|
||
// 使用自定义币种列表
|
||
var candidateCoins []decision.CandidateCoin
|
||
for _, coin := range at.tradingCoins {
|
||
// 确保币种格式正确(转为大写USDT交易对)
|
||
symbol := normalizeSymbol(coin)
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: []string{"custom"}, // 标记为自定义来源
|
||
})
|
||
}
|
||
|
||
log.Printf("📋 [%s] 使用自定义币种: %d个币种 %v",
|
||
at.name, len(candidateCoins), at.tradingCoins)
|
||
return candidateCoins, nil
|
||
}
|
||
}
|
||
|
||
// normalizeSymbol 标准化币种符号(确保以USDT结尾)
|
||
func normalizeSymbol(symbol string) string {
|
||
// 转为大写
|
||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||
|
||
// 确保以USDT结尾
|
||
if !strings.HasSuffix(symbol, "USDT") {
|
||
symbol = symbol + "USDT"
|
||
}
|
||
|
||
return symbol
|
||
}
|
||
|
||
// 启动回撤监控
|
||
func (at *AutoTrader) startDrawdownMonitor() {
|
||
at.monitorWg.Add(1)
|
||
go func() {
|
||
defer at.monitorWg.Done()
|
||
|
||
ticker := time.NewTicker(1 * time.Minute) // 每分钟检查一次
|
||
defer ticker.Stop()
|
||
|
||
log.Println("📊 启动持仓回撤监控(每分钟检查一次)")
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
at.checkPositionDrawdown()
|
||
case <-at.stopMonitorCh:
|
||
log.Println("⏹ 停止持仓回撤监控")
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// 检查持仓回撤情况
|
||
func (at *AutoTrader) checkPositionDrawdown() {
|
||
// 获取当前持仓
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
log.Printf("❌ 回撤监控:获取持仓失败: %v", err)
|
||
return
|
||
}
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // 空仓数量为负,转为正数
|
||
}
|
||
|
||
// 计算当前盈亏百分比
|
||
leverage := 10 // 默认值
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
var currentPnLPct float64
|
||
if side == "long" {
|
||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
||
} else {
|
||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
||
}
|
||
|
||
// 构造持仓唯一标识(区分多空)
|
||
posKey := symbol + "_" + side
|
||
|
||
// 获取该持仓的历史最高收益
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnLPct, exists := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
if !exists {
|
||
// 如果没有历史最高记录,使用当前盈亏作为初始值
|
||
peakPnLPct = currentPnLPct
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
} else {
|
||
// 更新峰值缓存
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
}
|
||
|
||
// 计算回撤(从最高点下跌的幅度)
|
||
var drawdownPct float64
|
||
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
|
||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||
}
|
||
|
||
// 检查平仓条件:收益大于5%且回撤超过40%
|
||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
||
log.Printf("🚨 触发回撤平仓条件: %s %s | 当前收益: %.2f%% | 最高收益: %.2f%% | 回撤: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
|
||
// 执行平仓
|
||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||
log.Printf("❌ 回撤平仓失败 (%s %s): %v", symbol, side, err)
|
||
} else {
|
||
log.Printf("✅ 回撤平仓成功: %s %s", symbol, side)
|
||
// 平仓后清理该持仓的缓存
|
||
at.ClearPeakPnLCache(symbol, side)
|
||
}
|
||
} else if currentPnLPct > 5.0 {
|
||
// 记录接近平仓条件的情况(用于调试)
|
||
log.Printf("📊 回撤监控: %s %s | 收益: %.2f%% | 最高: %.2f%% | 回撤: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 紧急平仓函数
|
||
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
|
||
switch side {
|
||
case "long":
|
||
order, err := at.trader.CloseLong(symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
log.Printf("✅ 紧急平多仓成功,订单ID: %v", order["orderId"])
|
||
case "short":
|
||
order, err := at.trader.CloseShort(symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
log.Printf("✅ 紧急平空仓成功,订单ID: %v", order["orderId"])
|
||
default:
|
||
return fmt.Errorf("未知的持仓方向: %s", side)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetPeakPnLCache 获取最高收益缓存
|
||
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
|
||
at.peakPnLCacheMutex.RLock()
|
||
defer at.peakPnLCacheMutex.RUnlock()
|
||
|
||
// 返回缓存的副本
|
||
cache := make(map[string]float64)
|
||
for k, v := range at.peakPnLCache {
|
||
cache[k] = v
|
||
}
|
||
return cache
|
||
}
|
||
|
||
// UpdatePeakPnL 更新最高收益缓存
|
||
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
if peak, exists := at.peakPnLCache[posKey]; exists {
|
||
// 更新峰值(如果是多头,取较大值;如果是空头,currentPnLPct为负,也要比较)
|
||
if currentPnLPct > peak {
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
} else {
|
||
// 首次记录
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
}
|
||
|
||
// ClearPeakPnLCache 清除指定持仓的峰值缓存
|
||
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
delete(at.peakPnLCache, posKey)
|
||
}
|