diff --git a/api/server.go b/api/server.go index ee10b335..eca75a90 100644 --- a/api/server.go +++ b/api/server.go @@ -135,6 +135,7 @@ func (s *Server) setupRoutes() { protected.POST("/traders/:id/stop", s.handleStopTrader) protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) + protected.POST("/traders/:id/close-position", s.handleClosePosition) // AI模型配置 protected.GET("/models", s.handleGetModelConfigs) @@ -1098,6 +1099,122 @@ func (s *Server) handleSyncBalance(c *gin.Context) { }) } +// handleClosePosition 一键平仓 +func (s *Server) handleClosePosition(c *gin.Context) { + userID := c.GetString("user_id") + traderID := c.Param("id") + + var req struct { + Symbol string `json:"symbol" binding:"required"` + Side string `json:"side" binding:"required"` // "LONG" or "SHORT" + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: symbol和side必填"}) + return + } + + logger.Infof("🔻 用户 %s 请求平仓: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side) + + // 从数据库获取交易员配置(包含交易所信息) + fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) + return + } + + traderConfig := fullConfig.Trader + exchangeCfg := fullConfig.Exchange + + if exchangeCfg == nil || !exchangeCfg.Enabled { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"}) + return + } + + // 创建临时 trader 执行平仓 + var tempTrader trader.Trader + var createErr error + + switch traderConfig.ExchangeID { + case "binance": + tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) + case "hyperliquid": + tempTrader, createErr = trader.NewHyperliquidTrader( + exchangeCfg.APIKey, + exchangeCfg.HyperliquidWalletAddr, + exchangeCfg.Testnet, + ) + case "aster": + tempTrader, createErr = trader.NewAsterTrader( + exchangeCfg.AsterUser, + exchangeCfg.AsterSigner, + exchangeCfg.AsterPrivateKey, + ) + case "bybit": + tempTrader = trader.NewBybitTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + ) + case "okx": + tempTrader = trader.NewOKXTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) + case "lighter": + if exchangeCfg.LighterAPIKeyPrivateKey != "" { + tempTrader, createErr = trader.NewLighterTraderV2( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.LighterAPIKeyPrivateKey, + exchangeCfg.Testnet, + ) + } else { + tempTrader, createErr = trader.NewLighterTrader( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.Testnet, + ) + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"}) + return + } + + if createErr != nil { + logger.Infof("⚠️ 创建临时 trader 失败: %v", createErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)}) + return + } + + // 执行平仓操作 + var result map[string]interface{} + var closeErr error + + if req.Side == "LONG" { + result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 表示全部平仓 + } else if req.Side == "SHORT" { + result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 表示全部平仓 + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "side必须是LONG或SHORT"}) + return + } + + if closeErr != nil { + logger.Infof("❌ 平仓失败: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("平仓失败: %v", closeErr)}) + return + } + + logger.Infof("✅ 平仓成功: symbol=%s, side=%s, result=%v", req.Symbol, req.Side, result) + c.JSON(http.StatusOK, gin.H{ + "message": "平仓成功", + "symbol": req.Symbol, + "side": req.Side, + "result": result, + }) +} + // handleGetModelConfigs 获取AI模型配置 func (s *Server) handleGetModelConfigs(c *gin.Context) { userID := c.GetString("user_id") diff --git a/img.png b/img.png deleted file mode 100644 index 232ce693..00000000 Binary files a/img.png and /dev/null differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 00000000..14f3112a Binary files /dev/null and b/img_1.png differ diff --git a/store/trader.go b/store/trader.go index 485e9bb2..329ba7d2 100644 --- a/store/trader.go +++ b/store/trader.go @@ -227,7 +227,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, t.created_at, t.updated_at, a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at, - e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, + e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet, COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''), COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''), COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at @@ -244,7 +244,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, &aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt, &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, - &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, &exchange.HyperliquidWalletAddr, + &exchange.APIKey, &exchange.SecretKey, &exchange.Passphrase, &exchange.Testnet, &exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey, &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, &exchangeCreatedAt, &exchangeUpdatedAt, @@ -264,6 +264,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, aiModel.APIKey = s.decrypt(aiModel.APIKey) exchange.APIKey = s.decrypt(exchange.APIKey) exchange.SecretKey = s.decrypt(exchange.SecretKey) + exchange.Passphrase = s.decrypt(exchange.Passphrase) exchange.AsterPrivateKey = s.decrypt(exchange.AsterPrivateKey) exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey) exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey) diff --git a/trader/okx_trader.go b/trader/okx_trader.go index 057d201b..0ab9028b 100644 --- a/trader/okx_trader.go +++ b/trader/okx_trader.go @@ -195,7 +195,9 @@ func (t *OKXTrader) doRequest(method, path string, body interface{}) ([]byte, er return nil, fmt.Errorf("解析响应失败: %w", err) } - if okxResp.Code != "0" { + // code=1 表示部分成功,需要检查 data 里的具体结果 + // code=2 表示全部失败 + if okxResp.Code != "0" && okxResp.Code != "1" { return nil, fmt.Errorf("OKX API错误: code=%s, msg=%s", okxResp.Code, okxResp.Msg) } @@ -639,7 +641,7 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { instId := t.convertSymbol(symbol) - // 如果数量为0,获取当前持仓 + // 如果数量为0,获取当前持仓(positionAmt 就是张数) if quantity == 0 { positions, err := t.GetPositions() if err != nil { @@ -647,7 +649,7 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) + quantity = pos["positionAmt"].(float64) // 这已经是张数 break } } @@ -656,19 +658,17 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter } } - // 获取合约信息 + // 获取合约信息用于格式化张数 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) - } + // quantity 已经是张数,直接格式化 + szStr := t.formatSize(quantity, inst) - sz := quantity * price / inst.CtVal - szStr := t.formatSize(sz, inst) + logger.Infof("🔻 OKX平多仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s", + symbol, instId, quantity, szStr) body := map[string]interface{}{ "instId": instId, @@ -720,7 +720,7 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { instId := t.convertSymbol(symbol) - // 如果数量为0,获取当前持仓 + // 如果数量为0,获取当前持仓(positionAmt 就是张数) if quantity == 0 { positions, err := t.GetPositions() if err != nil { @@ -728,7 +728,7 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte } for _, pos := range positions { if pos["symbol"] == symbol && pos["side"] == "short" { - quantity = pos["positionAmt"].(float64) + quantity = pos["positionAmt"].(float64) // 这已经是张数 break } } @@ -737,19 +737,17 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte } } - // 获取合约信息 + // 获取合约信息用于格式化张数 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) - } + // quantity 已经是张数,直接格式化 + szStr := t.formatSize(quantity, inst) - sz := quantity * price / inst.CtVal - szStr := t.formatSize(sz, inst) + logger.Infof("🔻 OKX平空仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s", + symbol, instId, quantity, szStr) body := map[string]interface{}{ "instId": instId, @@ -762,6 +760,8 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte "tag": okxTag, } + logger.Infof("🔻 OKX平空仓请求体: %+v", body) + data, err := t.doRequest("POST", okxOrderPath, body) if err != nil { return nil, fmt.Errorf("平空仓失败: %w", err) @@ -780,12 +780,13 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte if len(orders) == 0 || orders[0].SCode != "0" { msg := "未知错误" if len(orders) > 0 { - msg = orders[0].SMsg + msg = fmt.Sprintf("sCode=%s, sMsg=%s", orders[0].SCode, orders[0].SMsg) } + logger.Infof("❌ OKX平空仓失败: %s, 响应: %s", msg, string(data)) return nil, fmt.Errorf("平空仓失败: %s", msg) } - logger.Infof("✓ OKX平空仓成功: %s", symbol) + logger.Infof("✓ OKX平空仓成功: %s, ordId=%s", symbol, orders[0].OrdId) // 平仓后取消挂单 t.CancelAllOrders(symbol) diff --git a/web/src/App.tsx b/web/src/App.tsx index 74a2c172..aee15153 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import { t, type Language } from './i18n/translations' import { useSystemConfig } from './hooks/useSystemConfig' import { DecisionCard } from './components/DecisionCard' import { BacktestPage } from './components/BacktestPage' +import { LogOut, Loader2 } from 'lucide-react' import type { SystemStatus, AccountInfo, @@ -524,6 +525,31 @@ function TraderDetailsPage({ lastUpdate: string language: Language }) { + const [closingPosition, setClosingPosition] = useState(null) + + // 平仓操作 + const handleClosePosition = async (symbol: string, side: string) => { + if (!selectedTraderId) return + + const confirmMsg = language === 'zh' + ? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?` + : `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?` + + if (!confirm(confirmMsg)) return + + setClosingPosition(symbol) + try { + await api.closePosition(selectedTraderId, symbol, side) + const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully' + alert(successMsg) + window.location.reload() + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position') + alert(errorMsg) + } finally { + setClosingPosition(null) + } + } // If API failed with error, show empty state (likely backend not running) if (tradersError) { return ( @@ -836,6 +862,9 @@ function TraderDetailsPage({ {t('side', language)} + + {language === 'zh' ? '操作' : 'Action'} + {t('entryPrice', language)} @@ -889,6 +918,27 @@ function TraderDetailsPage({ )} + + + { + const result = await httpClient.post<{ message: string }>( + `${API_BASE}/traders/${traderId}/close-position`, + { symbol, side } + ) + if (!result.success) throw new Error('平仓失败') + return result.data! + }, + async updateTraderPrompt( traderId: string, customPrompt: string diff --git a/web/src/pages/TraderDashboard.tsx b/web/src/pages/TraderDashboard.tsx index f2a458cf..131574a9 100644 --- a/web/src/pages/TraderDashboard.tsx +++ b/web/src/pages/TraderDashboard.tsx @@ -18,6 +18,8 @@ import { Check, X, XCircle, + LogOut, + Loader2, } from 'lucide-react' import { stripLeadingIcons } from '../lib/text' import type { @@ -63,6 +65,9 @@ export default function TraderDashboard() { const [selectedChartSymbol, setSelectedChartSymbol] = useState() const [chartUpdateKey, setChartUpdateKey] = useState(0) + // 平仓操作状态 + const [closingPosition, setClosingPosition] = useState(null) // symbol being closed + // 点击持仓币种时调用 const handlePositionSymbolClick = (symbol: string) => { setSelectedChartSymbol(symbol) @@ -75,6 +80,31 @@ export default function TraderDashboard() { localStorage.setItem('decisionLimit', newLimit.toString()) } + // 平仓操作 + const handleClosePosition = async (symbol: string, side: string) => { + if (!selectedTraderId) return + + const confirmMsg = language === 'zh' + ? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?` + : `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?` + + if (!confirm(confirmMsg)) return + + setClosingPosition(symbol) + try { + await api.closePosition(selectedTraderId, symbol, side) + const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully' + alert(successMsg) + // 刷新持仓数据 + window.location.reload() + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position') + alert(errorMsg) + } finally { + setClosingPosition(null) + } + } + // 获取trader列表(仅在用户登录时) const { data: traders, error: tradersError } = useSWR( user && token ? 'traders' : null, @@ -474,6 +504,9 @@ export default function TraderDashboard() { {t('side', language)} + + {language === 'zh' ? '操作' : 'Action'} + {t('entryPrice', language)} @@ -544,6 +577,27 @@ export default function TraderDashboard() { )} + + +