diff --git a/api/server.go b/api/server.go index 9bab6f08..ee10b335 100644 --- a/api/server.go +++ b/api/server.go @@ -454,16 +454,18 @@ type UpdateModelConfigRequest struct { type UpdateExchangeConfigRequest struct { Exchanges map[string]struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - SecretKey string `json:"secret_key"` - Testnet bool `json:"testnet"` - HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` - AsterUser string `json:"aster_user"` - AsterSigner string `json:"aster_signer"` - AsterPrivateKey string `json:"aster_private_key"` - LighterWalletAddr string `json:"lighter_wallet_addr"` - LighterPrivateKey string `json:"lighter_private_key"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Passphrase string `json:"passphrase"` // OKX专用 + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` + AsterUser string `json:"aster_user"` + AsterSigner string `json:"aster_signer"` + AsterPrivateKey string `json:"aster_private_key"` + LighterWalletAddr string `json:"lighter_wallet_addr"` + LighterPrivateKey string `json:"lighter_private_key"` + LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"` } `json:"exchanges"` } @@ -843,8 +845,46 @@ func (s *Server) handleStartTrader(c *gin.Context) { trader, err := s.traderManager.GetTrader(traderID) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) - return + // 交易员不在内存中,尝试从数据库加载 + logger.Infof("🔄 交易员 %s 不在内存中,尝试加载...", traderID) + if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil { + logger.Infof("❌ 加载用户交易员失败: %v", loadErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载交易员失败: " + loadErr.Error()}) + return + } + // 再次尝试获取 + trader, err = s.traderManager.GetTrader(traderID) + if err != nil { + // 检查详细原因 + fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID) + if fullCfg != nil && fullCfg.Trader != nil { + // 检查策略 + if fullCfg.Strategy == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员未配置策略,请先在策略工作室创建策略并关联到交易员"}) + return + } + // 检查AI模型 + if fullCfg.AIModel == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员的AI模型不存在,请检查AI模型配置"}) + return + } + if !fullCfg.AIModel.Enabled { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员的AI模型未启用,请先启用AI模型"}) + return + } + // 检查交易所 + if fullCfg.Exchange == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员的交易所不存在,请检查交易所配置"}) + return + } + if !fullCfg.Exchange.Enabled { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员的交易所未启用,请先启用交易所"}) + return + } + } + c.JSON(http.StatusNotFound, gin.H{"error": "交易员加载失败,请检查AI模型、交易所和策略配置"}) + return + } } // 检查交易员是否已经在运行 @@ -1249,7 +1289,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 更新每个交易所的配置 for exchangeID, exchangeData := range req.Exchanges { - err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey) + err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) return diff --git a/img.png b/img.png new file mode 100644 index 00000000..232ce693 Binary files /dev/null and b/img.png differ diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 4dea8161..dc05cca8 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -443,9 +443,10 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string } // 使用现有的方法加载交易员 + logger.Infof("📦 正在加载交易员 %s (AI模型: %s, 交易所: %s, 策略ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID, traderCfg.StrategyID) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st) if err != nil { - logger.Infof("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) + logger.Infof("❌ 加载交易员 %s 失败: %v", traderCfg.Name, err) } } @@ -630,6 +631,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg case "bybit": traderConfig.BybitAPIKey = exchangeCfg.APIKey traderConfig.BybitSecretKey = exchangeCfg.SecretKey + case "okx": + traderConfig.OKXAPIKey = exchangeCfg.APIKey + traderConfig.OKXSecretKey = exchangeCfg.SecretKey + traderConfig.OKXPassphrase = exchangeCfg.Passphrase case "hyperliquid": traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr diff --git a/store/exchange.go b/store/exchange.go index 73b668e0..e9981485 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -24,6 +24,7 @@ type Exchange struct { Enabled bool `json:"enabled"` APIKey string `json:"apiKey"` SecretKey string `json:"secretKey"` + Passphrase string `json:"passphrase"` // OKX专用 Testnet bool `json:"testnet"` HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` AsterUser string `json:"asterUser"` @@ -46,6 +47,7 @@ func (s *ExchangeStore) initTables() error { enabled BOOLEAN DEFAULT 0, api_key TEXT DEFAULT '', secret_key TEXT DEFAULT '', + passphrase TEXT DEFAULT '', testnet BOOLEAN DEFAULT 0, hyperliquid_wallet_addr TEXT DEFAULT '', aster_user TEXT DEFAULT '', @@ -63,6 +65,9 @@ func (s *ExchangeStore) initTables() error { return err } + // 迁移:添加 passphrase 列(如果不存在) + s.db.Exec(`ALTER TABLE exchanges ADD COLUMN passphrase TEXT DEFAULT ''`) + // 触发器 _, err = s.db.Exec(` CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at @@ -80,6 +85,7 @@ func (s *ExchangeStore) initDefaultData() error { }{ {"binance", "Binance Futures", "binance"}, {"bybit", "Bybit Futures", "bybit"}, + {"okx", "OKX Futures", "okx"}, {"hyperliquid", "Hyperliquid", "hyperliquid"}, {"aster", "Aster DEX", "aster"}, {"lighter", "LIGHTER DEX", "lighter"}, @@ -111,10 +117,41 @@ func (s *ExchangeStore) decrypt(encrypted string) string { return encrypted } +// EnsureUserExchanges 确保用户有所有支持的交易所记录 +func (s *ExchangeStore) EnsureUserExchanges(userID string) error { + exchanges := []struct { + id, name, typ string + }{ + {"binance", "Binance Futures", "binance"}, + {"bybit", "Bybit Futures", "bybit"}, + {"okx", "OKX Futures", "okx"}, + {"hyperliquid", "Hyperliquid", "hyperliquid"}, + {"aster", "Aster DEX", "aster"}, + {"lighter", "LIGHTER DEX", "lighter"}, + } + + for _, exchange := range exchanges { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled) + VALUES (?, ?, ?, ?, 0) + `, exchange.id, userID, exchange.name, exchange.typ) + if err != nil { + return fmt.Errorf("确保用户交易所失败: %w", err) + } + } + return nil +} + // List 获取用户的交易所列表 func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { + // 确保用户有所有支持的交易所记录 + if err := s.EnsureUserExchanges(userID); err != nil { + logger.Debugf("⚠️ 确保用户交易所记录失败: %v", err) + } + rows, err := s.db.Query(` - SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet, + SELECT id, user_id, name, type, enabled, api_key, secret_key, + COALESCE(passphrase, '') as passphrase, testnet, COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, COALESCE(aster_user, '') as aster_user, COALESCE(aster_signer, '') as aster_signer, @@ -136,7 +173,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { var createdAt, updatedAt string err := rows.Scan( &e.ID, &e.UserID, &e.Name, &e.Type, - &e.Enabled, &e.APIKey, &e.SecretKey, &e.Testnet, + &e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet, &e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey, &e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, &createdAt, &updatedAt, @@ -148,6 +185,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { e.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) e.APIKey = s.decrypt(e.APIKey) e.SecretKey = s.decrypt(e.SecretKey) + e.Passphrase = s.decrypt(e.Passphrase) e.AsterPrivateKey = s.decrypt(e.AsterPrivateKey) e.LighterPrivateKey = s.decrypt(e.LighterPrivateKey) e.LighterAPIKeyPrivateKey = s.decrypt(e.LighterAPIKeyPrivateKey) @@ -157,8 +195,8 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { } // Update 更新交易所配置 -func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, - hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error { +func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool, + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) error { logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) @@ -181,6 +219,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe setClauses = append(setClauses, "secret_key = ?") args = append(args, s.encrypt(secretKey)) } + if passphrase != "" { + setClauses = append(setClauses, "passphrase = ?") + args = append(args, s.encrypt(passphrase)) + } if asterPrivateKey != "" { setClauses = append(setClauses, "aster_private_key = ?") args = append(args, s.encrypt(asterPrivateKey)) @@ -189,6 +231,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe setClauses = append(setClauses, "lighter_private_key = ?") args = append(args, s.encrypt(lighterPrivateKey)) } + if lighterApiKeyPrivateKey != "" { + setClauses = append(setClauses, "lighter_api_key_private_key = ?") + args = append(args, s.encrypt(lighterApiKeyPrivateKey)) + } args = append(args, id, userID) query := fmt.Sprintf(`UPDATE exchanges SET %s WHERE id = ? AND user_id = ?`, strings.Join(setClauses, ", ")) @@ -200,31 +246,33 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { - // 创建新记录 + // 创建新记录,type 使用交易所 ID 以便后续正确识别 var name, typ string switch id { case "binance": - name, typ = "Binance Futures", "cex" + name, typ = "Binance Futures", "binance" case "bybit": - name, typ = "Bybit Futures", "cex" + name, typ = "Bybit Futures", "bybit" + case "okx": + name, typ = "OKX Futures", "okx" case "hyperliquid": - name, typ = "Hyperliquid", "dex" + name, typ = "Hyperliquid", "hyperliquid" case "aster": - name, typ = "Aster DEX", "dex" + name, typ = "Aster DEX", "aster" case "lighter": - name, typ = "LIGHTER DEX", "dex" + name, typ = "LIGHTER DEX", "lighter" default: - name, typ = id+" Exchange", "cex" + name, typ = id+" Exchange", id } _, err = s.db.Exec(` - INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, passphrase, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, - lighter_wallet_addr, lighter_private_key, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet, + lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet, hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey), - lighterWalletAddr, s.encrypt(lighterPrivateKey)) + lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey)) return err } return nil diff --git a/trader/auto_trader.go b/trader/auto_trader.go index d8df9424..4906c986 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -22,7 +22,7 @@ type AutoTraderConfig struct { AIModel string // AI模型: "qwen" 或 "deepseek" // 交易平台选择 - Exchange string // "binance", "bybit", "hyperliquid", "aster" 或 "lighter" + Exchange string // "binance", "bybit", "okx", "hyperliquid", "aster" 或 "lighter" // 币安API配置 BinanceAPIKey string @@ -32,6 +32,11 @@ type AutoTraderConfig struct { BybitAPIKey string BybitSecretKey string + // OKX API配置 + OKXAPIKey string + OKXSecretKey string + OKXPassphrase string + // Hyperliquid配置 HyperliquidPrivateKey string HyperliquidWalletAddr string @@ -174,6 +179,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au case "bybit": logger.Infof("🏦 [%s] 使用Bybit合约交易", config.Name) trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) + case "okx": + logger.Infof("🏦 [%s] 使用OKX合约交易", config.Name) + trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase) case "hyperliquid": logger.Infof("🏦 [%s] 使用Hyperliquid交易", config.Name) trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) @@ -213,9 +221,28 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au return nil, fmt.Errorf("不支持的交易平台: %s", config.Exchange) } - // 验证初始金额配置 + // 验证初始金额配置,如果为0则自动从交易所获取 if config.InitialBalance <= 0 { - return nil, fmt.Errorf("初始金额必须大于0,请在配置中设置InitialBalance") + logger.Infof("📊 [%s] 初始金额未设置,尝试从交易所获取当前余额...", config.Name) + account, err := trader.GetBalance() + if err != nil { + return nil, fmt.Errorf("初始金额未设置且无法从交易所获取余额: %w", err) + } + // 尝试多种余额字段名(不同交易所返回格式不同) + balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} + var foundBalance float64 + for _, key := range balanceKeys { + if balance, ok := account[key].(float64); ok && balance > 0 { + foundBalance = balance + break + } + } + if foundBalance > 0 { + config.InitialBalance = foundBalance + logger.Infof("✓ [%s] 自动获取初始金额: %.2f USDT", config.Name, foundBalance) + } else { + return nil, fmt.Errorf("初始金额必须大于0,请在配置中设置InitialBalance或确保交易所账户有余额") + } } // 获取最后的周期编号(用于恢复) diff --git a/trader/okx_trader.go b/trader/okx_trader.go new file mode 100644 index 00000000..057d201b --- /dev/null +++ b/trader/okx_trader.go @@ -0,0 +1,1123 @@ +package trader + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "nofx/logger" + "strconv" + "strings" + "sync" + "time" +) + +// OKX API endpoints +const ( + okxBaseURL = "https://www.okx.com" + okxAccountPath = "/api/v5/account/balance" + okxPositionPath = "/api/v5/account/positions" + okxOrderPath = "/api/v5/trade/order" + okxLeveragePath = "/api/v5/account/set-leverage" + okxTickerPath = "/api/v5/market/ticker" + okxInstrumentsPath = "/api/v5/public/instruments" + okxCancelOrderPath = "/api/v5/trade/cancel-order" + okxPendingOrdersPath = "/api/v5/trade/orders-pending" + okxAlgoOrderPath = "/api/v5/trade/order-algo" + okxCancelAlgoPath = "/api/v5/trade/cancel-algos" + okxAlgoPendingPath = "/api/v5/trade/orders-algo-pending" + okxPositionModePath = "/api/v5/account/set-position-mode" +) + +// OKXTrader OKX合约交易器 +type OKXTrader struct { + apiKey string + secretKey string + passphrase string + + // HTTP 客户端(禁用代理) + httpClient *http.Client + + // 余额缓存 + cachedBalance map[string]interface{} + balanceCacheTime time.Time + balanceCacheMutex sync.RWMutex + + // 持仓缓存 + cachedPositions []map[string]interface{} + positionsCacheTime time.Time + positionsCacheMutex sync.RWMutex + + // 合约信息缓存 + instrumentsCache map[string]*OKXInstrument + instrumentsCacheTime time.Time + instrumentsCacheMutex sync.RWMutex + + // 缓存有效期 + cacheDuration time.Duration +} + +// OKXInstrument OKX合约信息 +type OKXInstrument struct { + InstID string // 合约ID + CtVal float64 // 合约面值 + CtMult float64 // 合约乘数 + LotSz float64 // 最小下单数量 + MinSz float64 // 最小下单数量 + TickSz float64 // 最小价格变动 + CtType string // 合约类型 +} + +// OKXResponse OKX API响应 +type OKXResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// getOkxOrderID 生成OKX订单ID +func genOkxClOrdID() string { + timestamp := time.Now().UnixNano() % 10000000000000 + randomBytes := make([]byte, 4) + rand.Read(randomBytes) + randomHex := hex.EncodeToString(randomBytes) + // OKX clOrdId 最长32字符 + orderID := fmt.Sprintf("%s%d%s", okxTag, timestamp, randomHex) + if len(orderID) > 32 { + orderID = orderID[:32] + } + return orderID +} + +// NewOKXTrader 创建OKX交易器 +func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader { + // 创建禁用代理的 HTTP 客户端 + transport := &http.Transport{ + Proxy: nil, // 显式禁用代理 + } + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + } + + trader := &OKXTrader{ + apiKey: apiKey, + secretKey: secretKey, + passphrase: passphrase, + httpClient: httpClient, + cacheDuration: 15 * time.Second, + instrumentsCache: make(map[string]*OKXInstrument), + } + + // 设置双向持仓模式 + if err := trader.setPositionMode(); err != nil { + logger.Infof("⚠️ 设置OKX持仓模式失败: %v (如果已是双向模式则忽略)", err) + } + + return trader +} + +// setPositionMode 设置双向持仓模式 +func (t *OKXTrader) setPositionMode() error { + body := map[string]string{ + "posMode": "long_short_mode", // 双向持仓 + } + + _, err := t.doRequest("POST", okxPositionModePath, body) + if err != nil { + // 如果已经是双向模式,忽略错误 + if strings.Contains(err.Error(), "already") || strings.Contains(err.Error(), "Position mode is not modified") { + logger.Infof(" ✓ OKX账户已是双向持仓模式") + return nil + } + return err + } + + logger.Infof(" ✓ OKX账户已切换为双向持仓模式") + return nil +} + +// sign 生成OKX API签名 +func (t *OKXTrader) sign(timestamp, method, requestPath, body string) string { + preHash := timestamp + method + requestPath + body + h := hmac.New(sha256.New, []byte(t.secretKey)) + h.Write([]byte(preHash)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// doRequest 执行HTTP请求 +func (t *OKXTrader) doRequest(method, path string, body interface{}) ([]byte, error) { + var bodyBytes []byte + var err error + + if body != nil { + bodyBytes, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求体失败: %w", err) + } + } + + timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + signature := t.sign(timestamp, method, path, string(bodyBytes)) + + req, err := http.NewRequest(method, okxBaseURL+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("OK-ACCESS-KEY", t.apiKey) + req.Header.Set("OK-ACCESS-SIGN", signature) + req.Header.Set("OK-ACCESS-TIMESTAMP", timestamp) + req.Header.Set("OK-ACCESS-PASSPHRASE", t.passphrase) + req.Header.Set("Content-Type", "application/json") + // 设置请求头 + req.Header.Set("x-simulated-trading", "0") + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + var okxResp OKXResponse + if err := json.Unmarshal(respBody, &okxResp); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + if okxResp.Code != "0" { + return nil, fmt.Errorf("OKX API错误: code=%s, msg=%s", okxResp.Code, okxResp.Msg) + } + + return okxResp.Data, nil +} + +// convertSymbol 将通用符号转换为OKX格式 +// 如 BTCUSDT -> BTC-USDT-SWAP +func (t *OKXTrader) convertSymbol(symbol string) string { + // 移除USDT后缀并构建OKX格式 + base := strings.TrimSuffix(symbol, "USDT") + return fmt.Sprintf("%s-USDT-SWAP", base) +} + +// convertSymbolBack 将OKX格式转换回通用符号 +// 如 BTC-USDT-SWAP -> BTCUSDT +func (t *OKXTrader) convertSymbolBack(instId string) string { + parts := strings.Split(instId, "-") + if len(parts) >= 2 { + return parts[0] + parts[1] + } + return instId +} + +// GetBalance 获取账户余额 +func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { + // 检查缓存 + t.balanceCacheMutex.RLock() + if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { + t.balanceCacheMutex.RUnlock() + logger.Infof("✓ 使用缓存的OKX账户余额") + return t.cachedBalance, nil + } + t.balanceCacheMutex.RUnlock() + + logger.Infof("🔄 正在调用OKX API获取账户余额...") + data, err := t.doRequest("GET", okxAccountPath, nil) + if err != nil { + return nil, fmt.Errorf("获取账户余额失败: %w", err) + } + + var balances []struct { + TotalEq string `json:"totalEq"` + AdjEq string `json:"adjEq"` + IsoEq string `json:"isoEq"` + OrdFroz string `json:"ordFroz"` + Details []struct { + Ccy string `json:"ccy"` + Eq string `json:"eq"` + CashBal string `json:"cashBal"` + AvailBal string `json:"availBal"` + UPL string `json:"upl"` + } `json:"details"` + } + + if err := json.Unmarshal(data, &balances); err != nil { + return nil, fmt.Errorf("解析余额数据失败: %w", err) + } + + if len(balances) == 0 { + return nil, fmt.Errorf("未获取到余额数据") + } + + balance := balances[0] + + // 查找USDT余额 + var usdtAvail, usdtUPL float64 + for _, detail := range balance.Details { + if detail.Ccy == "USDT" { + usdtAvail, _ = strconv.ParseFloat(detail.AvailBal, 64) + usdtUPL, _ = strconv.ParseFloat(detail.UPL, 64) + break + } + } + + totalEq, _ := strconv.ParseFloat(balance.TotalEq, 64) + + result := map[string]interface{}{ + "totalWalletBalance": totalEq, + "availableBalance": usdtAvail, + "totalUnrealizedProfit": usdtUPL, + } + + logger.Infof("✓ OKX余额: 总权益=%.2f, 可用=%.2f, 未实现盈亏=%.2f", totalEq, usdtAvail, usdtUPL) + + // 更新缓存 + t.balanceCacheMutex.Lock() + t.cachedBalance = result + t.balanceCacheTime = time.Now() + t.balanceCacheMutex.Unlock() + + return result, nil +} + +// GetPositions 获取所有持仓 +func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) { + // 检查缓存 + t.positionsCacheMutex.RLock() + if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { + t.positionsCacheMutex.RUnlock() + logger.Infof("✓ 使用缓存的OKX持仓信息") + return t.cachedPositions, nil + } + t.positionsCacheMutex.RUnlock() + + logger.Infof("🔄 正在调用OKX API获取持仓信息...") + data, err := t.doRequest("GET", okxPositionPath+"?instType=SWAP", nil) + if err != nil { + return nil, fmt.Errorf("获取持仓失败: %w", err) + } + + var positions []struct { + InstId string `json:"instId"` + PosSide string `json:"posSide"` + Pos string `json:"pos"` + AvgPx string `json:"avgPx"` + MarkPx string `json:"markPx"` + Upl string `json:"upl"` + Lever string `json:"lever"` + LiqPx string `json:"liqPx"` + Margin string `json:"margin"` + } + + if err := json.Unmarshal(data, &positions); err != nil { + return nil, fmt.Errorf("解析持仓数据失败: %w", err) + } + + var result []map[string]interface{} + for _, pos := range positions { + posAmt, _ := strconv.ParseFloat(pos.Pos, 64) + if posAmt == 0 { + continue + } + + entryPrice, _ := strconv.ParseFloat(pos.AvgPx, 64) + markPrice, _ := strconv.ParseFloat(pos.MarkPx, 64) + upl, _ := strconv.ParseFloat(pos.Upl, 64) + leverage, _ := strconv.ParseFloat(pos.Lever, 64) + liqPrice, _ := strconv.ParseFloat(pos.LiqPx, 64) + + // 转换symbol格式 + symbol := t.convertSymbolBack(pos.InstId) + + // 确定方向 + side := "long" + if pos.PosSide == "short" || posAmt < 0 { + side = "short" + posAmt = -posAmt // 取绝对值 + } + + posMap := map[string]interface{}{ + "symbol": symbol, + "positionAmt": posAmt, + "entryPrice": entryPrice, + "markPrice": markPrice, + "unRealizedProfit": upl, + "leverage": leverage, + "liquidationPrice": liqPrice, + "side": side, + } + result = append(result, posMap) + } + + // 更新缓存 + t.positionsCacheMutex.Lock() + t.cachedPositions = result + t.positionsCacheTime = time.Now() + t.positionsCacheMutex.Unlock() + + return result, nil +} + +// getInstrument 获取合约信息 +func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) { + instId := t.convertSymbol(symbol) + + // 检查缓存 + t.instrumentsCacheMutex.RLock() + if inst, ok := t.instrumentsCache[instId]; ok && time.Since(t.instrumentsCacheTime) < 5*time.Minute { + t.instrumentsCacheMutex.RUnlock() + return inst, nil + } + t.instrumentsCacheMutex.RUnlock() + + // 获取合约信息 + path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxInstrumentsPath, instId) + data, err := t.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var instruments []struct { + InstId string `json:"instId"` + CtVal string `json:"ctVal"` + CtMult string `json:"ctMult"` + LotSz string `json:"lotSz"` + MinSz string `json:"minSz"` + TickSz string `json:"tickSz"` + CtType string `json:"ctType"` + } + + if err := json.Unmarshal(data, &instruments); err != nil { + return nil, err + } + + if len(instruments) == 0 { + return nil, fmt.Errorf("未找到合约信息: %s", instId) + } + + inst := instruments[0] + ctVal, _ := strconv.ParseFloat(inst.CtVal, 64) + ctMult, _ := strconv.ParseFloat(inst.CtMult, 64) + lotSz, _ := strconv.ParseFloat(inst.LotSz, 64) + minSz, _ := strconv.ParseFloat(inst.MinSz, 64) + tickSz, _ := strconv.ParseFloat(inst.TickSz, 64) + + instrument := &OKXInstrument{ + InstID: inst.InstId, + CtVal: ctVal, + CtMult: ctMult, + LotSz: lotSz, + MinSz: minSz, + TickSz: tickSz, + CtType: inst.CtType, + } + + // 更新缓存 + t.instrumentsCacheMutex.Lock() + t.instrumentsCache[instId] = instrument + t.instrumentsCacheTime = time.Now() + t.instrumentsCacheMutex.Unlock() + + return instrument, nil +} + +// SetMarginMode 设置仓位模式 +func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { + instId := t.convertSymbol(symbol) + + mgnMode := "isolated" + if isCrossMargin { + mgnMode = "cross" + } + + body := map[string]interface{}{ + "instId": instId, + "mgnMode": mgnMode, + } + + _, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body) + if err != nil { + // 如果已经是目标模式,忽略错误 + if strings.Contains(err.Error(), "already") { + logger.Infof(" ✓ %s 仓位模式已是 %s", symbol, mgnMode) + return nil + } + // 有持仓无法更改 + if strings.Contains(err.Error(), "position") { + logger.Infof(" ⚠️ %s 有持仓,无法更改仓位模式", symbol) + return nil + } + return err + } + + logger.Infof(" ✓ %s 仓位模式已设置为 %s", symbol, mgnMode) + return nil +} + +// SetLeverage 设置杠杆 +func (t *OKXTrader) SetLeverage(symbol string, leverage int) error { + instId := t.convertSymbol(symbol) + + // 设置多头和空头的杠杆 + for _, posSide := range []string{"long", "short"} { + body := map[string]interface{}{ + "instId": instId, + "lever": strconv.Itoa(leverage), + "mgnMode": "cross", + "posSide": posSide, + } + + _, err := t.doRequest("POST", okxLeveragePath, body) + if err != nil { + // 如果已经是目标杠杆,忽略 + if strings.Contains(err.Error(), "same") { + continue + } + logger.Infof(" ⚠️ 设置 %s %s 杠杆失败: %v", symbol, posSide, err) + } + } + + logger.Infof(" ✓ %s 杠杆已设置为 %dx", symbol, leverage) + return nil +} + +// OpenLong 开多仓 +func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // 取消旧订单 + t.CancelAllOrders(symbol) + + // 设置杠杆 + if err := t.SetLeverage(symbol, leverage); err != nil { + logger.Infof(" ⚠️ 设置杠杆失败: %v", err) + } + + instId := t.convertSymbol(symbol) + + // 获取合约信息并计算合约数量 + inst, err := t.getInstrument(symbol) + if err != nil { + return nil, fmt.Errorf("获取合约信息失败: %w", err) + } + + // OKX使用合约张数,需要根据合约面值转换 + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, fmt.Errorf("获取市价失败: %w", err) + } + + // 计算合约张数 = 数量 * 价格 / 合约面值 + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": "buy", + "posSide": "long", + "ordType": "market", + "sz": szStr, + "clOrdId": genOkxClOrdID(), + "tag": okxTag, + } + + data, err := t.doRequest("POST", okxOrderPath, body) + if err != nil { + return nil, fmt.Errorf("开多仓失败: %w", err) + } + + var orders []struct { + OrdId string `json:"ordId"` + ClOrdId string `json:"clOrdId"` + SCode string `json:"sCode"` + SMsg string `json:"sMsg"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return nil, fmt.Errorf("解析订单响应失败: %w", err) + } + + if len(orders) == 0 || orders[0].SCode != "0" { + msg := "未知错误" + if len(orders) > 0 { + msg = orders[0].SMsg + } + return nil, fmt.Errorf("开多仓失败: %s", msg) + } + + logger.Infof("✓ OKX开多仓成功: %s 张数: %s", symbol, szStr) + logger.Infof(" 订单ID: %s", orders[0].OrdId) + + return map[string]interface{}{ + "orderId": orders[0].OrdId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// OpenShort 开空仓 +func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // 取消旧订单 + t.CancelAllOrders(symbol) + + // 设置杠杆 + if err := t.SetLeverage(symbol, leverage); err != nil { + logger.Infof(" ⚠️ 设置杠杆失败: %v", err) + } + + instId := t.convertSymbol(symbol) + + // 获取合约信息并计算合约数量 + inst, err := t.getInstrument(symbol) + if err != nil { + return nil, fmt.Errorf("获取合约信息失败: %w", err) + } + + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, fmt.Errorf("获取市价失败: %w", err) + } + + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": "sell", + "posSide": "short", + "ordType": "market", + "sz": szStr, + "clOrdId": genOkxClOrdID(), + "tag": okxTag, + } + + data, err := t.doRequest("POST", okxOrderPath, body) + if err != nil { + return nil, fmt.Errorf("开空仓失败: %w", err) + } + + var orders []struct { + OrdId string `json:"ordId"` + ClOrdId string `json:"clOrdId"` + SCode string `json:"sCode"` + SMsg string `json:"sMsg"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return nil, fmt.Errorf("解析订单响应失败: %w", err) + } + + if len(orders) == 0 || orders[0].SCode != "0" { + msg := "未知错误" + if len(orders) > 0 { + msg = orders[0].SMsg + } + return nil, fmt.Errorf("开空仓失败: %s", msg) + } + + logger.Infof("✓ OKX开空仓成功: %s 张数: %s", symbol, szStr) + logger.Infof(" 订单ID: %s", orders[0].OrdId) + + return map[string]interface{}{ + "orderId": orders[0].OrdId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// CloseLong 平多仓 +func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + instId := t.convertSymbol(symbol) + + // 如果数量为0,获取当前持仓 + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + for _, pos := range positions { + if pos["symbol"] == symbol && pos["side"] == "long" { + quantity = pos["positionAmt"].(float64) + break + } + } + if quantity == 0 { + return nil, fmt.Errorf("没有找到 %s 的多仓", symbol) + } + } + + // 获取合约信息 + inst, err := t.getInstrument(symbol) + if err != nil { + return nil, fmt.Errorf("获取合约信息失败: %w", err) + } + + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, fmt.Errorf("获取市价失败: %w", err) + } + + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": "sell", + "posSide": "long", + "ordType": "market", + "sz": szStr, + "clOrdId": genOkxClOrdID(), + "tag": okxTag, + } + + data, err := t.doRequest("POST", okxOrderPath, body) + if err != nil { + return nil, fmt.Errorf("平多仓失败: %w", err) + } + + var orders []struct { + OrdId string `json:"ordId"` + SCode string `json:"sCode"` + SMsg string `json:"sMsg"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return nil, err + } + + if len(orders) == 0 || orders[0].SCode != "0" { + msg := "未知错误" + if len(orders) > 0 { + msg = orders[0].SMsg + } + return nil, fmt.Errorf("平多仓失败: %s", msg) + } + + logger.Infof("✓ OKX平多仓成功: %s", symbol) + + // 平仓后取消挂单 + t.CancelAllOrders(symbol) + + return map[string]interface{}{ + "orderId": orders[0].OrdId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// CloseShort 平空仓 +func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + instId := t.convertSymbol(symbol) + + // 如果数量为0,获取当前持仓 + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + for _, pos := range positions { + if pos["symbol"] == symbol && pos["side"] == "short" { + quantity = pos["positionAmt"].(float64) + break + } + } + if quantity == 0 { + return nil, fmt.Errorf("没有找到 %s 的空仓", symbol) + } + } + + // 获取合约信息 + inst, err := t.getInstrument(symbol) + if err != nil { + return nil, fmt.Errorf("获取合约信息失败: %w", err) + } + + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, fmt.Errorf("获取市价失败: %w", err) + } + + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": "buy", + "posSide": "short", + "ordType": "market", + "sz": szStr, + "clOrdId": genOkxClOrdID(), + "tag": okxTag, + } + + data, err := t.doRequest("POST", okxOrderPath, body) + if err != nil { + return nil, fmt.Errorf("平空仓失败: %w", err) + } + + var orders []struct { + OrdId string `json:"ordId"` + SCode string `json:"sCode"` + SMsg string `json:"sMsg"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return nil, err + } + + if len(orders) == 0 || orders[0].SCode != "0" { + msg := "未知错误" + if len(orders) > 0 { + msg = orders[0].SMsg + } + return nil, fmt.Errorf("平空仓失败: %s", msg) + } + + logger.Infof("✓ OKX平空仓成功: %s", symbol) + + // 平仓后取消挂单 + t.CancelAllOrders(symbol) + + return map[string]interface{}{ + "orderId": orders[0].OrdId, + "symbol": symbol, + "status": "FILLED", + }, nil +} + +// GetMarketPrice 获取市场价格 +func (t *OKXTrader) GetMarketPrice(symbol string) (float64, error) { + instId := t.convertSymbol(symbol) + path := fmt.Sprintf("%s?instId=%s", okxTickerPath, instId) + + data, err := t.doRequest("GET", path, nil) + if err != nil { + return 0, fmt.Errorf("获取价格失败: %w", err) + } + + var tickers []struct { + Last string `json:"last"` + } + + if err := json.Unmarshal(data, &tickers); err != nil { + return 0, err + } + + if len(tickers) == 0 { + return 0, fmt.Errorf("未获取到价格数据") + } + + price, err := strconv.ParseFloat(tickers[0].Last, 64) + if err != nil { + return 0, err + } + + return price, nil +} + +// SetStopLoss 设置止损单 +func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + instId := t.convertSymbol(symbol) + + // 获取合约信息 + inst, err := t.getInstrument(symbol) + if err != nil { + return fmt.Errorf("获取合约信息失败: %w", err) + } + + // 计算张数 + price, _ := t.GetMarketPrice(symbol) + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + // 确定方向 + side := "sell" + posSide := "long" + if strings.ToUpper(positionSide) == "SHORT" { + side = "buy" + posSide = "short" + } + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": side, + "posSide": posSide, + "ordType": "conditional", + "sz": szStr, + "slTriggerPx": fmt.Sprintf("%.8f", stopPrice), + "slOrdPx": "-1", // 市价 + "tag": okxTag, + } + + _, err = t.doRequest("POST", okxAlgoOrderPath, body) + if err != nil { + return fmt.Errorf("设置止损失败: %w", err) + } + + logger.Infof(" 止损价设置: %.4f", stopPrice) + return nil +} + +// SetTakeProfit 设置止盈单 +func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + instId := t.convertSymbol(symbol) + + // 获取合约信息 + inst, err := t.getInstrument(symbol) + if err != nil { + return fmt.Errorf("获取合约信息失败: %w", err) + } + + // 计算张数 + price, _ := t.GetMarketPrice(symbol) + sz := quantity * price / inst.CtVal + szStr := t.formatSize(sz, inst) + + // 确定方向 + side := "sell" + posSide := "long" + if strings.ToUpper(positionSide) == "SHORT" { + side = "buy" + posSide = "short" + } + + body := map[string]interface{}{ + "instId": instId, + "tdMode": "cross", + "side": side, + "posSide": posSide, + "ordType": "conditional", + "sz": szStr, + "tpTriggerPx": fmt.Sprintf("%.8f", takeProfitPrice), + "tpOrdPx": "-1", // 市价 + "tag": okxTag, + } + + _, err = t.doRequest("POST", okxAlgoOrderPath, body) + if err != nil { + return fmt.Errorf("设置止盈失败: %w", err) + } + + logger.Infof(" 止盈价设置: %.4f", takeProfitPrice) + return nil +} + +// CancelStopLossOrders 取消止损单 +func (t *OKXTrader) CancelStopLossOrders(symbol string) error { + return t.cancelAlgoOrders(symbol, "sl") +} + +// CancelTakeProfitOrders 取消止盈单 +func (t *OKXTrader) CancelTakeProfitOrders(symbol string) error { + return t.cancelAlgoOrders(symbol, "tp") +} + +// cancelAlgoOrders 取消策略订单 +func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error { + instId := t.convertSymbol(symbol) + + // 获取待成交的策略订单 + path := fmt.Sprintf("%s?instType=SWAP&instId=%s&ordType=conditional", okxAlgoPendingPath, instId) + data, err := t.doRequest("GET", path, nil) + if err != nil { + return err + } + + var orders []struct { + AlgoId string `json:"algoId"` + InstId string `json:"instId"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return err + } + + canceledCount := 0 + for _, order := range orders { + body := []map[string]interface{}{ + { + "algoId": order.AlgoId, + "instId": order.InstId, + }, + } + + _, err := t.doRequest("POST", okxCancelAlgoPath, body) + if err != nil { + logger.Infof(" ⚠️ 取消策略订单失败: %v", err) + continue + } + canceledCount++ + } + + if canceledCount > 0 { + logger.Infof(" ✓ 已取消 %s 的 %d 个策略订单", symbol, canceledCount) + } + + return nil +} + +// CancelAllOrders 取消所有挂单 +func (t *OKXTrader) CancelAllOrders(symbol string) error { + instId := t.convertSymbol(symbol) + + // 获取待成交订单 + path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId) + data, err := t.doRequest("GET", path, nil) + if err != nil { + return err + } + + var orders []struct { + OrdId string `json:"ordId"` + InstId string `json:"instId"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return err + } + + // 批量取消 + for _, order := range orders { + body := map[string]interface{}{ + "instId": order.InstId, + "ordId": order.OrdId, + } + t.doRequest("POST", okxCancelOrderPath, body) + } + + // 同时取消策略订单 + t.cancelAlgoOrders(symbol, "") + + if len(orders) > 0 { + logger.Infof(" ✓ 已取消 %s 的所有挂单", symbol) + } + + return nil +} + +// CancelStopOrders 取消止盈止损单 +func (t *OKXTrader) CancelStopOrders(symbol string) error { + return t.cancelAlgoOrders(symbol, "") +} + +// FormatQuantity 格式化数量 +func (t *OKXTrader) FormatQuantity(symbol string, quantity float64) (string, error) { + inst, err := t.getInstrument(symbol) + if err != nil { + return fmt.Sprintf("%.3f", quantity), nil + } + + // OKX使用张数 + price, _ := t.GetMarketPrice(symbol) + if price == 0 { + return fmt.Sprintf("%.0f", quantity), nil + } + + sz := quantity * price / inst.CtVal + return t.formatSize(sz, inst), nil +} + +// formatSize 格式化张数 +func (t *OKXTrader) formatSize(sz float64, inst *OKXInstrument) string { + // 根据lotSz确定精度 + if inst.LotSz >= 1 { + return fmt.Sprintf("%.0f", sz) + } + + // 计算小数位数 + lotSzStr := fmt.Sprintf("%f", inst.LotSz) + dotIndex := strings.Index(lotSzStr, ".") + if dotIndex == -1 { + return fmt.Sprintf("%.0f", sz) + } + + // 去除尾部0 + lotSzStr = strings.TrimRight(lotSzStr, "0") + precision := len(lotSzStr) - dotIndex - 1 + + format := fmt.Sprintf("%%.%df", precision) + return fmt.Sprintf(format, sz) +} + +// GetOrderStatus 获取订单状态 +func (t *OKXTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + instId := t.convertSymbol(symbol) + path := fmt.Sprintf("/api/v5/trade/order?instId=%s&ordId=%s", instId, orderID) + + data, err := t.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("获取订单状态失败: %w", err) + } + + var orders []struct { + OrdId string `json:"ordId"` + State string `json:"state"` + AvgPx string `json:"avgPx"` + AccFillSz string `json:"accFillSz"` + Fee string `json:"fee"` + Side string `json:"side"` + OrdType string `json:"ordType"` + CTime string `json:"cTime"` + UTime string `json:"uTime"` + } + + if err := json.Unmarshal(data, &orders); err != nil { + return nil, err + } + + if len(orders) == 0 { + return nil, fmt.Errorf("未找到订单") + } + + order := orders[0] + avgPrice, _ := strconv.ParseFloat(order.AvgPx, 64) + fillSz, _ := strconv.ParseFloat(order.AccFillSz, 64) + fee, _ := strconv.ParseFloat(order.Fee, 64) + cTime, _ := strconv.ParseInt(order.CTime, 10, 64) + uTime, _ := strconv.ParseInt(order.UTime, 10, 64) + + // 状态映射 + statusMap := map[string]string{ + "filled": "FILLED", + "live": "NEW", + "partially_filled": "PARTIALLY_FILLED", + "canceled": "CANCELED", + } + + status := statusMap[order.State] + if status == "" { + status = order.State + } + + return map[string]interface{}{ + "orderId": order.OrdId, + "symbol": symbol, + "status": status, + "avgPrice": avgPrice, + "executedQty": fillSz, + "side": order.Side, + "type": order.OrdType, + "time": cTime, + "updateTime": uTime, + "commission": -fee, // OKX返回的是负数 + }, nil +} + +// OKX 订单标签 +var okxTag = func() string { + b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==") + return string(b) +}() diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index e8e6c4a9..cf1c9ab5 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -601,11 +601,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { exchangeId: string, apiKey: string, secretKey?: string, + passphrase?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, - asterPrivateKey?: string + asterPrivateKey?: string, + lighterWalletAddr?: string, + lighterPrivateKey?: string, + lighterApiKeyPrivateKey?: string ) => { try { // 找到要配置的交易所(从supportedExchanges中) @@ -630,11 +634,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { ...e, apiKey, secretKey, + passphrase, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, + lighterWalletAddr, + lighterPrivateKey, + lighterApiKeyPrivateKey, enabled: true, } : e @@ -645,11 +653,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { ...exchangeToUpdate, apiKey, secretKey, + passphrase, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, + lighterWalletAddr, + lighterPrivateKey, + lighterApiKeyPrivateKey, enabled: true, } updatedExchanges = [...(allExchanges || []), newExchange] @@ -663,11 +675,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { enabled: exchange.enabled, api_key: exchange.apiKey || '', secret_key: exchange.secretKey || '', + passphrase: exchange.passphrase || '', testnet: exchange.testnet || false, hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', aster_user: exchange.asterUser || '', aster_signer: exchange.asterSigner || '', aster_private_key: exchange.asterPrivateKey || '', + lighter_wallet_addr: exchange.lighterWalletAddr || '', + lighter_private_key: exchange.lighterPrivateKey || '', + lighter_api_key_private_key: exchange.lighterApiKeyPrivateKey || '', }, ]) ), @@ -1758,11 +1774,15 @@ function ExchangeConfigModal({ exchangeId: string, apiKey: string, secretKey?: string, + passphrase?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, - asterPrivateKey?: string + asterPrivateKey?: string, + lighterWalletAddr?: string, + lighterPrivateKey?: string, + lighterApiKeyPrivateKey?: string ) => Promise onDelete: (exchangeId: string) => void onClose: () => void @@ -1796,9 +1816,14 @@ function ExchangeConfigModal({ // Hyperliquid 特定字段 const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('') + // LIGHTER 特定字段 + const [lighterWalletAddr, setLighterWalletAddr] = useState('') + const [lighterPrivateKey, setLighterPrivateKey] = useState('') + const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('') + // 安全输入状态 const [secureInputTarget, setSecureInputTarget] = useState< - null | 'hyperliquid' | 'aster' + null | 'hyperliquid' | 'aster' | 'lighter' >(null) // 获取当前编辑的交易所信息 @@ -1821,6 +1846,11 @@ function ExchangeConfigModal({ // Hyperliquid 字段 setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') + + // LIGHTER 字段 + setLighterWalletAddr(selectedExchange.lighterWalletAddr || '') + setLighterPrivateKey('') // Don't load existing private key for security + setLighterApiKeyPrivateKey('') // Don't load existing API key for security } }, [editingExchangeId, selectedExchange]) @@ -1926,15 +1956,19 @@ function ExchangeConfigModal({ if (!selectedExchangeId) return // 根据交易所类型验证不同字段 - if (selectedExchange?.id === 'binance') { + if (selectedExchange?.id === 'binance' || selectedExchange?.id === 'bybit') { if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) + } else if (selectedExchange?.id === 'okx') { + if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) } else if (selectedExchange?.id === 'hyperliquid') { - if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 + if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return await onSave( selectedExchangeId, apiKey.trim(), '', + '', testnet, hyperliquidWalletAddr.trim() ) @@ -1945,19 +1979,33 @@ function ExchangeConfigModal({ selectedExchangeId, '', '', + '', testnet, undefined, asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim() ) - } else if (selectedExchange?.id === 'okx') { - if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } else if (selectedExchange?.id === 'lighter') { + if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return + await onSave( + selectedExchangeId, + '', + '', + '', + testnet, + undefined, + undefined, + undefined, + undefined, + lighterWalletAddr.trim(), + lighterPrivateKey.trim(), + lighterApiKeyPrivateKey.trim() + ) } else { // 默认情况(其他CEX交易所) if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) } } @@ -2095,9 +2143,10 @@ function ExchangeConfigModal({ {selectedExchange && ( <> - {/* Binance/Bybit 和其他 CEX 交易所的字段 */} + {/* Binance/Bybit/OKX 和其他 CEX 交易所的字段 */} {(selectedExchange.id === 'binance' || selectedExchange.id === 'bybit' || + selectedExchange.id === 'okx' || selectedExchange.type === 'cex') && selectedExchange.id !== 'hyperliquid' && selectedExchange.id !== 'aster' && ( @@ -2553,6 +2602,104 @@ function ExchangeConfigModal({ )} + + {/* LIGHTER 交易所的字段 */} + {selectedExchange.id === 'lighter' && ( + <> +
+ + setLighterWalletAddr(e.target.value)} + placeholder={t('enterLighterWalletAddress', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ {t('lighterWalletAddressDesc', language)} +
+
+ +
+ + setLighterPrivateKey(e.target.value)} + placeholder={t('enterLighterPrivateKey', language)} + className="w-full px-3 py-2 rounded font-mono text-sm" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ {t('lighterPrivateKeyDesc', language)} +
+
+ +
+ + setLighterApiKeyPrivateKey(e.target.value)} + placeholder={t('enterLighterApiKeyPrivateKey', language)} + className="w-full px-3 py-2 rounded font-mono text-sm" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('lighterApiKeyPrivateKeyDesc', language)} +
+
+ +
+
+
+ {lighterApiKeyPrivateKey ? '✅ LIGHTER V2' : '⚠️ LIGHTER V1'} +
+
+
+ {lighterApiKeyPrivateKey + ? t('lighterV2Description', language) + : t('lighterV1Description', language) + } +
+
+ + )} )} @@ -2575,25 +2722,20 @@ function ExchangeConfigModal({ !selectedExchange || (selectedExchange.id === 'binance' && (!apiKey.trim() || !secretKey.trim())) || + (selectedExchange.id === 'bybit' && + (!apiKey.trim() || !secretKey.trim())) || (selectedExchange.id === 'okx' && (!apiKey.trim() || !secretKey.trim() || !passphrase.trim())) || (selectedExchange.id === 'hyperliquid' && - (!apiKey.trim() || !hyperliquidWalletAddr.trim())) || // 验证私钥和钱包地址 + (!apiKey.trim() || !hyperliquidWalletAddr.trim())) || (selectedExchange.id === 'aster' && (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim())) || - (selectedExchange.id === 'bybit' && - (!apiKey.trim() || !secretKey.trim())) || - (selectedExchange.type === 'cex' && - selectedExchange.id !== 'hyperliquid' && - selectedExchange.id !== 'aster' && - selectedExchange.id !== 'binance' && - selectedExchange.id !== 'bybit' && - selectedExchange.id !== 'okx' && - (!apiKey.trim() || !secretKey.trim())) + (selectedExchange.id === 'lighter' && + (!lighterWalletAddr.trim() || !lighterPrivateKey.trim())) } className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50" style={{ background: '#F0B90B', color: '#000' }} diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx index d1f6c745..eee47afc 100644 --- a/web/src/components/ExchangeIcons.tsx +++ b/web/src/components/ExchangeIcons.tsx @@ -80,6 +80,28 @@ const BybitIcon: React.FC = ({ ) +// OKX SVG 图标组件 +const OKXIcon: React.FC = ({ + width = 24, + height = 24, + className, +}) => ( + + + + + + + +) + // Aster SVG 图标组件 const AsterIcon: React.FC = ({ width = 24, @@ -168,11 +190,13 @@ export const getExchangeIcon = ( ? 'binance' : exchangeType.toLowerCase().includes('bybit') ? 'bybit' - : exchangeType.toLowerCase().includes('hyperliquid') - ? 'hyperliquid' - : exchangeType.toLowerCase().includes('aster') - ? 'aster' - : exchangeType.toLowerCase() + : exchangeType.toLowerCase().includes('okx') + ? 'okx' + : exchangeType.toLowerCase().includes('hyperliquid') + ? 'hyperliquid' + : exchangeType.toLowerCase().includes('aster') + ? 'aster' + : exchangeType.toLowerCase() const iconProps = { width: props.width || 24, @@ -185,6 +209,8 @@ export const getExchangeIcon = ( return case 'bybit': return + case 'okx': + return case 'hyperliquid': case 'dex': return diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index 0d507641..80f944c3 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -23,6 +23,7 @@ interface ExchangeConfigModalProps { exchangeId: string, apiKey: string, secretKey?: string, + passphrase?: string, // OKX专用 testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, @@ -222,13 +223,17 @@ export function ExchangeConfigModal({ // 根据交易所类型验证不同字段 if (selectedExchange?.id === 'binance') { if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) + } else if (selectedExchange?.id === 'okx') { + if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) } else if (selectedExchange?.id === 'hyperliquid') { if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 await onSave( selectedExchangeId, apiKey.trim(), '', + '', testnet, hyperliquidWalletAddr.trim() ) @@ -239,6 +244,7 @@ export function ExchangeConfigModal({ selectedExchangeId, '', '', + '', testnet, undefined, asterUser.trim(), @@ -251,6 +257,7 @@ export function ExchangeConfigModal({ selectedExchangeId, lighterPrivateKey.trim(), '', + '', testnet, lighterWalletAddr.trim(), undefined, @@ -260,13 +267,10 @@ export function ExchangeConfigModal({ lighterPrivateKey.trim(), lighterApiKeyPrivateKey.trim() ) - } else if (selectedExchange?.id === 'okx') { - if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) } else { // 默认情况(其他CEX交易所) if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) } } @@ -404,12 +408,10 @@ export function ExchangeConfigModal({ {selectedExchange && ( <> - {/* Binance/Bybit 和其他 CEX 交易所的字段 */} + {/* Binance/Bybit/OKX 的输入字段 */} {(selectedExchange.id === 'binance' || selectedExchange.id === 'bybit' || - selectedExchange.type === 'cex') && - selectedExchange.id !== 'hyperliquid' && - selectedExchange.id !== 'aster' && ( + selectedExchange.id === 'okx') && ( <> {/* 币安用户配置提示 (D1 方案) */} {selectedExchange.id === 'binance' && ( diff --git a/web/src/hooks/useTraderActions.ts b/web/src/hooks/useTraderActions.ts index 3aebbf05..a2f4e5db 100644 --- a/web/src/hooks/useTraderActions.ts +++ b/web/src/hooks/useTraderActions.ts @@ -445,6 +445,7 @@ export function useTraderActions({ ...e, apiKey: '', secretKey: '', + passphrase: '', // OKX专用 hyperliquidWalletAddr: '', asterUser: '', asterSigner: '', @@ -459,6 +460,7 @@ export function useTraderActions({ enabled: exchange.enabled, api_key: exchange.apiKey || '', secret_key: exchange.secretKey || '', + passphrase: exchange.passphrase || '', // OKX专用 testnet: exchange.testnet || false, hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', aster_user: exchange.asterUser || '', @@ -486,6 +488,7 @@ export function useTraderActions({ exchangeId: string, apiKey: string, secretKey?: string, + passphrase?: string, // OKX专用 testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, @@ -518,6 +521,7 @@ export function useTraderActions({ ...e, apiKey, secretKey, + passphrase, // OKX专用 testnet, hyperliquidWalletAddr, asterUser, @@ -536,6 +540,7 @@ export function useTraderActions({ ...exchangeToUpdate, apiKey, secretKey, + passphrase, // OKX专用 testnet, hyperliquidWalletAddr, asterUser, @@ -557,6 +562,7 @@ export function useTraderActions({ enabled: exchange.enabled, api_key: exchange.apiKey || '', secret_key: exchange.secretKey || '', + passphrase: exchange.passphrase || '', // OKX专用 testnet: exchange.testnet || false, hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', aster_user: exchange.asterUser || '', diff --git a/web/src/types.ts b/web/src/types.ts index 7fc8c3ad..e6aae677 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -114,6 +114,7 @@ export interface Exchange { enabled: boolean apiKey?: string secretKey?: string + passphrase?: string // OKX 特定字段 testnet?: boolean // Hyperliquid 特定字段 hyperliquidWalletAddr?: string @@ -163,6 +164,7 @@ export interface UpdateExchangeConfigRequest { enabled: boolean api_key: string secret_key: string + passphrase?: string testnet?: boolean // Hyperliquid 特定字段 hyperliquid_wallet_addr?: string