feat: add one-click close position for all exchanges

- Add handleClosePosition API endpoint in server.go
- Add closePosition API function in frontend
- Add close position button to positions table in App.tsx and TraderDashboard.tsx
- Fix GetFullConfig to include passphrase field for OKX
- Fix OKX CloseLong/CloseShort to use position quantity directly (already in contracts)
This commit is contained in:
tinkle-community
2025-12-06 19:16:37 +08:00
parent 5e5be347ad
commit a77c54dbef
8 changed files with 255 additions and 23 deletions
+117
View File
@@ -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")
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

+3 -2
View File
@@ -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)
+22 -21
View File
@@ -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)
+50
View File
@@ -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<string | null>(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({
<th className="pb-3 font-semibold text-gray-400">
{t('side', language)}
</th>
<th className="pb-3 font-semibold text-gray-400">
{language === 'zh' ? '操作' : 'Action'}
</th>
<th className="pb-3 font-semibold text-gray-400">
{t('entryPrice', language)}
</th>
@@ -889,6 +918,27 @@ function TraderDetailsPage({
)}
</span>
</td>
<td className="py-3">
<button
type="button"
onClick={() => handleClosePosition(pos.symbol, pos.side.toUpperCase())}
disabled={closingPosition === pos.symbol}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
title={language === 'zh' ? '平仓' : 'Close Position'}
>
{closingPosition === pos.symbol ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogOut className="w-3 h-3" />
)}
{language === 'zh' ? '平仓' : 'Close'}
</button>
</td>
<td
className="py-3 font-mono"
style={{ color: '#EAECEF' }}
+9
View File
@@ -102,6 +102,15 @@ export const api = {
if (!result.success) throw new Error('停止交易员失败')
},
async closePosition(traderId: string, symbol: string, side: string): Promise<{ message: string }> {
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
+54
View File
@@ -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<string | undefined>()
const [chartUpdateKey, setChartUpdateKey] = useState(0)
// 平仓操作状态
const [closingPosition, setClosingPosition] = useState<string | null>(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<TraderInfo[]>(
user && token ? 'traders' : null,
@@ -474,6 +504,9 @@ export default function TraderDashboard() {
<th className="pb-3 font-semibold text-gray-400">
{t('side', language)}
</th>
<th className="pb-3 font-semibold text-gray-400">
{language === 'zh' ? '操作' : 'Action'}
</th>
<th className="pb-3 font-semibold text-gray-400">
{t('entryPrice', language)}
</th>
@@ -544,6 +577,27 @@ export default function TraderDashboard() {
)}
</span>
</td>
<td className="py-3">
<button
type="button"
onClick={() => handleClosePosition(pos.symbol, pos.side.toUpperCase())}
disabled={closingPosition === pos.symbol}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
title={language === 'zh' ? '平仓' : 'Close Position'}
>
{closingPosition === pos.symbol ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogOut className="w-3 h-3" />
)}
{language === 'zh' ? '平仓' : 'Close'}
</button>
</td>
<td
className="py-3 font-mono"
style={{ color: '#EAECEF' }}