mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 06:46:59 +08:00
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:
117
api/server.go
117
api/server.go
@@ -135,6 +135,7 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
||||||
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
||||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||||
|
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||||
|
|
||||||
// AI模型配置
|
// AI模型配置
|
||||||
protected.GET("/models", s.handleGetModelConfigs)
|
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模型配置
|
// handleGetModelConfigs 获取AI模型配置
|
||||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
|||||||
t.created_at, t.updated_at,
|
t.created_at, t.updated_at,
|
||||||
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key,
|
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,
|
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.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.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
|
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.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
|
||||||
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
|
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
|
||||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
&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.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
|
||||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
|
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
|
||||||
&exchangeCreatedAt, &exchangeUpdatedAt,
|
&exchangeCreatedAt, &exchangeUpdatedAt,
|
||||||
@@ -264,6 +264,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
|||||||
aiModel.APIKey = s.decrypt(aiModel.APIKey)
|
aiModel.APIKey = s.decrypt(aiModel.APIKey)
|
||||||
exchange.APIKey = s.decrypt(exchange.APIKey)
|
exchange.APIKey = s.decrypt(exchange.APIKey)
|
||||||
exchange.SecretKey = s.decrypt(exchange.SecretKey)
|
exchange.SecretKey = s.decrypt(exchange.SecretKey)
|
||||||
|
exchange.Passphrase = s.decrypt(exchange.Passphrase)
|
||||||
exchange.AsterPrivateKey = s.decrypt(exchange.AsterPrivateKey)
|
exchange.AsterPrivateKey = s.decrypt(exchange.AsterPrivateKey)
|
||||||
exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey)
|
exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey)
|
||||||
exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey)
|
exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey)
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ func (t *OKXTrader) doRequest(method, path string, body interface{}) ([]byte, er
|
|||||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
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)
|
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) {
|
func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||||
instId := t.convertSymbol(symbol)
|
instId := t.convertSymbol(symbol)
|
||||||
|
|
||||||
// 如果数量为0,获取当前持仓
|
// 如果数量为0,获取当前持仓(positionAmt 就是张数)
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
positions, err := t.GetPositions()
|
positions, err := t.GetPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -647,7 +649,7 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter
|
|||||||
}
|
}
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
if pos["symbol"] == symbol && pos["side"] == "long" {
|
if pos["symbol"] == symbol && pos["side"] == "long" {
|
||||||
quantity = pos["positionAmt"].(float64)
|
quantity = pos["positionAmt"].(float64) // 这已经是张数
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,19 +658,17 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取合约信息
|
// 获取合约信息用于格式化张数
|
||||||
inst, err := t.getInstrument(symbol)
|
inst, err := t.getInstrument(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
price, err := t.GetMarketPrice(symbol)
|
// quantity 已经是张数,直接格式化
|
||||||
if err != nil {
|
szStr := t.formatSize(quantity, inst)
|
||||||
return nil, fmt.Errorf("获取市价失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sz := quantity * price / inst.CtVal
|
logger.Infof("🔻 OKX平多仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s",
|
||||||
szStr := t.formatSize(sz, inst)
|
symbol, instId, quantity, szStr)
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"instId": instId,
|
"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) {
|
func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||||
instId := t.convertSymbol(symbol)
|
instId := t.convertSymbol(symbol)
|
||||||
|
|
||||||
// 如果数量为0,获取当前持仓
|
// 如果数量为0,获取当前持仓(positionAmt 就是张数)
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
positions, err := t.GetPositions()
|
positions, err := t.GetPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -728,7 +728,7 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
|
|||||||
}
|
}
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
if pos["symbol"] == symbol && pos["side"] == "short" {
|
if pos["symbol"] == symbol && pos["side"] == "short" {
|
||||||
quantity = pos["positionAmt"].(float64)
|
quantity = pos["positionAmt"].(float64) // 这已经是张数
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,19 +737,17 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取合约信息
|
// 获取合约信息用于格式化张数
|
||||||
inst, err := t.getInstrument(symbol)
|
inst, err := t.getInstrument(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
return nil, fmt.Errorf("获取合约信息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
price, err := t.GetMarketPrice(symbol)
|
// quantity 已经是张数,直接格式化
|
||||||
if err != nil {
|
szStr := t.formatSize(quantity, inst)
|
||||||
return nil, fmt.Errorf("获取市价失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sz := quantity * price / inst.CtVal
|
logger.Infof("🔻 OKX平空仓参数: symbol=%s, instId=%s, quantity(张数)=%f, szStr=%s",
|
||||||
szStr := t.formatSize(sz, inst)
|
symbol, instId, quantity, szStr)
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"instId": instId,
|
"instId": instId,
|
||||||
@@ -762,6 +760,8 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
|
|||||||
"tag": okxTag,
|
"tag": okxTag,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("🔻 OKX平空仓请求体: %+v", body)
|
||||||
|
|
||||||
data, err := t.doRequest("POST", okxOrderPath, body)
|
data, err := t.doRequest("POST", okxOrderPath, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("平空仓失败: %w", err)
|
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" {
|
if len(orders) == 0 || orders[0].SCode != "0" {
|
||||||
msg := "未知错误"
|
msg := "未知错误"
|
||||||
if len(orders) > 0 {
|
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)
|
return nil, fmt.Errorf("平空仓失败: %s", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✓ OKX平空仓成功: %s", symbol)
|
logger.Infof("✓ OKX平空仓成功: %s, ordId=%s", symbol, orders[0].OrdId)
|
||||||
|
|
||||||
// 平仓后取消挂单
|
// 平仓后取消挂单
|
||||||
t.CancelAllOrders(symbol)
|
t.CancelAllOrders(symbol)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { t, type Language } from './i18n/translations'
|
|||||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||||
import { DecisionCard } from './components/DecisionCard'
|
import { DecisionCard } from './components/DecisionCard'
|
||||||
import { BacktestPage } from './components/BacktestPage'
|
import { BacktestPage } from './components/BacktestPage'
|
||||||
|
import { LogOut, Loader2 } from 'lucide-react'
|
||||||
import type {
|
import type {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
@@ -524,6 +525,31 @@ function TraderDetailsPage({
|
|||||||
lastUpdate: string
|
lastUpdate: string
|
||||||
language: Language
|
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 API failed with error, show empty state (likely backend not running)
|
||||||
if (tradersError) {
|
if (tradersError) {
|
||||||
return (
|
return (
|
||||||
@@ -836,6 +862,9 @@ function TraderDetailsPage({
|
|||||||
<th className="pb-3 font-semibold text-gray-400">
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
{t('side', language)}
|
{t('side', language)}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{language === 'zh' ? '操作' : 'Action'}
|
||||||
|
</th>
|
||||||
<th className="pb-3 font-semibold text-gray-400">
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
{t('entryPrice', language)}
|
{t('entryPrice', language)}
|
||||||
</th>
|
</th>
|
||||||
@@ -889,6 +918,27 @@ function TraderDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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
|
<td
|
||||||
className="py-3 font-mono"
|
className="py-3 font-mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ export const api = {
|
|||||||
if (!result.success) throw new Error('停止交易员失败')
|
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(
|
async updateTraderPrompt(
|
||||||
traderId: string,
|
traderId: string,
|
||||||
customPrompt: string
|
customPrompt: string
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
LogOut,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { stripLeadingIcons } from '../lib/text'
|
import { stripLeadingIcons } from '../lib/text'
|
||||||
import type {
|
import type {
|
||||||
@@ -63,6 +65,9 @@ export default function TraderDashboard() {
|
|||||||
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>()
|
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>()
|
||||||
const [chartUpdateKey, setChartUpdateKey] = useState(0)
|
const [chartUpdateKey, setChartUpdateKey] = useState(0)
|
||||||
|
|
||||||
|
// 平仓操作状态
|
||||||
|
const [closingPosition, setClosingPosition] = useState<string | null>(null) // symbol being closed
|
||||||
|
|
||||||
// 点击持仓币种时调用
|
// 点击持仓币种时调用
|
||||||
const handlePositionSymbolClick = (symbol: string) => {
|
const handlePositionSymbolClick = (symbol: string) => {
|
||||||
setSelectedChartSymbol(symbol)
|
setSelectedChartSymbol(symbol)
|
||||||
@@ -75,6 +80,31 @@ export default function TraderDashboard() {
|
|||||||
localStorage.setItem('decisionLimit', newLimit.toString())
|
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列表(仅在用户登录时)
|
// 获取trader列表(仅在用户登录时)
|
||||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||||
user && token ? 'traders' : null,
|
user && token ? 'traders' : null,
|
||||||
@@ -474,6 +504,9 @@ export default function TraderDashboard() {
|
|||||||
<th className="pb-3 font-semibold text-gray-400">
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
{t('side', language)}
|
{t('side', language)}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{language === 'zh' ? '操作' : 'Action'}
|
||||||
|
</th>
|
||||||
<th className="pb-3 font-semibold text-gray-400">
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
{t('entryPrice', language)}
|
{t('entryPrice', language)}
|
||||||
</th>
|
</th>
|
||||||
@@ -544,6 +577,27 @@ export default function TraderDashboard() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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
|
<td
|
||||||
className="py-3 font-mono"
|
className="py-3 font-mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user