From a574717f7b4b0acfec69aba4c464724bceec92cb Mon Sep 17 00:00:00 2001 From: Diego <45224689+tangmengqiu@users.noreply.github.com> Date: Thu, 13 Nov 2025 01:27:13 -0500 Subject: [PATCH] fix(stats): fixed the PNL calculation (#963) --- api/server.go | 194 +++++---------- config/database.go | 7 +- decision/engine.go | 1 + docs/pnl.md | 299 +++++++++++++++++++++++ logger/decision_logger.go | 1 + manager/trader_manager.go | 12 + manager/trader_manager_test.go | 87 +++++++ trader/auto_trader.go | 128 ++-------- web/src/components/ComparisonChart.tsx | 9 +- web/src/components/TraderConfigModal.tsx | 110 +++++---- web/src/types.ts | 5 +- 11 files changed, 549 insertions(+), 304 deletions(-) create mode 100644 docs/pnl.md create mode 100644 manager/trader_manager_test.go diff --git a/api/server.go b/api/server.go index 70b6e3f5..3aadcc24 100644 --- a/api/server.go +++ b/api/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "math" "net" "net/http" "nofx/auth" @@ -132,7 +133,6 @@ func (s *Server) setupRoutes() { protected.POST("/traders/:id/start", s.handleStartTrader) protected.POST("/traders/:id/stop", s.handleStopTrader) protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) - protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) // AI模型配置 protected.GET("/models", s.handleGetModelConfigs) @@ -589,16 +589,36 @@ func (s *Server) handleCreateTrader(c *gin.Context) { if balanceErr != nil { log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr) } else { - // 提取可用余额 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - // 有些交易所可能只返回 balance 字段 - actualBalance = totalBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + // 🔧 计算Total Equity = Wallet Balance + Unrealized Profit + // 这是账户的真实净值,用作Initial Balance的基准 + var totalWalletBalance float64 + var totalUnrealizedProfit float64 + + // 提取钱包余额 + if wb, ok := balanceInfo["totalWalletBalance"].(float64); ok { + totalWalletBalance = wb + } else if wb, ok := balanceInfo["wallet_balance"].(float64); ok { + totalWalletBalance = wb + } else if wb, ok := balanceInfo["balance"].(float64); ok { + totalWalletBalance = wb + } + + // 提取未实现盈亏 + if up, ok := balanceInfo["totalUnrealizedProfit"].(float64); ok { + totalUnrealizedProfit = up + } else if up, ok := balanceInfo["unrealized_profit"].(float64); ok { + totalUnrealizedProfit = up + } + + // 计算总净值 + totalEquity := totalWalletBalance + totalUnrealizedProfit + + if totalEquity > 0 { + actualBalance = totalEquity + log.Printf("✅ 查询到交易所实际净值: %.2f USDT (钱包: %.2f + 未实现: %.2f, 用户输入: %.2f)", + actualBalance, totalWalletBalance, totalUnrealizedProfit, req.InitialBalance) } else { - log.Printf("⚠️ 无法从余额信息中提取可用余额,使用用户输入的初始资金") + log.Printf("⚠️ 无法从余额信息中计算净值,使用用户输入的初始资金") } } } @@ -752,6 +772,21 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { return } + // 如果请求中包含initial_balance且与现有值不同,单独更新它 + // UpdateTrader不会更新initial_balance,需要使用专门的方法 + if req.InitialBalance > 0 && math.Abs(req.InitialBalance-existingTrader.InitialBalance) > 0.1 { + err = s.database.UpdateTraderInitialBalance(userID, traderID, req.InitialBalance) + if err != nil { + log.Printf("⚠️ 更新初始余额失败: %v", err) + // 不返回错误,因为主要配置已更新成功 + } else { + log.Printf("✓ 初始余额已更新: %.2f -> %.2f", existingTrader.InitialBalance, req.InitialBalance) + } + } + + // 🔄 从内存中移除旧的trader实例,以便重新加载最新配置 + s.traderManager.RemoveTrader(traderID) + // 重新加载交易员到内存 err = s.traderManager.LoadTraderByID(s.database, userID, traderID) if err != nil { @@ -913,113 +948,6 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"}) } -// handleSyncBalance 同步交易所余额到initial_balance(选项B:手动同步 + 选项C:智能检测) -func (s *Server) handleSyncBalance(c *gin.Context) { - userID := c.GetString("user_id") - traderID := c.Param("id") - - log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID) - - // 从数据库获取交易员配置(包含交易所信息) - traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) - return - } - - 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, - ) - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"}) - return - } - - if createErr != nil { - log.Printf("⚠️ 创建临时 trader 失败: %v", createErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)}) - return - } - - // 查询实际余额 - balanceInfo, balanceErr := tempTrader.GetBalance() - if balanceErr != nil { - log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)}) - return - } - - // 提取可用余额 - var actualBalance float64 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - actualBalance = totalBalance - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取可用余额"}) - return - } - - oldBalance := traderConfig.InitialBalance - - // ✅ 选项C:智能检测余额变化 - changePercent := ((actualBalance - oldBalance) / oldBalance) * 100 - changeType := "增加" - if changePercent < 0 { - changeType = "减少" - } - - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)", - actualBalance, oldBalance, changePercent) - - // 更新数据库中的 initial_balance - err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance) - if err != nil { - log.Printf("❌ 更新initial_balance失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"}) - return - } - - // 重新加载交易员到内存 - err = s.traderManager.LoadTraderByID(s.database, userID, traderID) - if err != nil { - log.Printf("⚠️ 重新加载交易员到内存失败: %v", err) - } - - log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent) - - c.JSON(http.StatusOK, gin.H{ - "message": "余额同步成功", - "old_balance": oldBalance, - "new_balance": actualBalance, - "change_percent": changePercent, - "change_type": changeType, - }) -} - // handleGetModelConfigs 获取AI模型配置 func (s *Server) handleGetModelConfigs(c *gin.Context) { userID := c.GetString("user_id") @@ -1563,22 +1491,16 @@ func (s *Server) handleEquityHistory(c *gin.Context) { CycleNumber int `json:"cycle_number"` } - // 从AutoTrader获取初始余额(用于计算盈亏百分比) - initialBalance := 0.0 + // 从AutoTrader获取当前初始余额(用作旧数据的fallback) + base := 0.0 if status := trader.GetStatus(); status != nil { if ib, ok := status["initial_balance"].(float64); ok && ib > 0 { - initialBalance = ib + base = ib } } - // 如果无法从status获取,且有历史记录,则从第一条记录获取 - if initialBalance == 0 && len(records) > 0 { - // 第一条记录的equity作为初始余额 - initialBalance = records[0].AccountState.TotalBalance - } - // 如果还是无法获取,返回错误 - if initialBalance == 0 { + if base == 0 { c.JSON(http.StatusInternalServerError, gin.H{ "error": "无法获取初始余额", }) @@ -1588,14 +1510,24 @@ func (s *Server) handleEquityHistory(c *gin.Context) { var history []EquityPoint for _, record := range records { // TotalBalance字段实际存储的是TotalEquity - totalEquity := record.AccountState.TotalBalance + // totalEquity := record.AccountState.TotalBalance // TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额) - totalPnL := record.AccountState.TotalUnrealizedProfit + // totalPnL := record.AccountState.TotalUnrealizedProfit + walletBalance := record.AccountState.TotalBalance + unrealizedPnL := record.AccountState.TotalUnrealizedProfit + totalEquity := walletBalance + unrealizedPnL + // 🔄 使用历史记录中保存的initial_balance(如果有) + // 这样可以保持历史PNL%的准确性,即使用户后来更新了initial_balance + if record.AccountState.InitialBalance > 0 { + base = record.AccountState.InitialBalance + } + + totalPnL := totalEquity - base // 计算盈亏百分比 totalPnLPct := 0.0 - if initialBalance > 0 { - totalPnLPct = (totalPnL / initialBalance) * 100 + if base > 0 { + totalPnLPct = (totalPnL / base) * 100 } history = append(history, EquityPoint{ diff --git a/config/database.go b/config/database.go index 8d3e4ff8..b275720c 100644 --- a/config/database.go +++ b/config/database.go @@ -955,12 +955,12 @@ func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error { func (d *Database) UpdateTrader(trader *TraderRecord) error { _, err := d.db.Exec(` UPDATE traders SET - name = ?, ai_model_id = ?, exchange_id = ?, initial_balance = ?, + name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?, btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?, custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?, is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? - `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, + `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes, trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin, trader.ID, trader.UserID) @@ -973,7 +973,8 @@ func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt stri return err } -// UpdateTraderInitialBalance 更新交易员初始余额(用于自动同步交易所实际余额) +// UpdateTraderInitialBalance 更新交易员初始余额(仅支持手动更新) +// ⚠️ 注意:系统不会自动调用此方法,仅供用户在充值/提现后手动同步使用 func (d *Database) UpdateTraderInitialBalance(userID, id string, newBalance float64) error { _, err := d.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID) return err diff --git a/decision/engine.go b/decision/engine.go index bef863df..c8aa4f51 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -48,6 +48,7 @@ type PositionInfo struct { type AccountInfo struct { TotalEquity float64 `json:"total_equity"` // 账户净值 AvailableBalance float64 `json:"available_balance"` // 可用余额 + UnrealizedPnL float64 `json:"unrealized_pnl"` // 未实现盈亏 TotalPnL float64 `json:"total_pnl"` // 总盈亏 TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比 MarginUsed float64 `json:"margin_used"` // 已用保证金 diff --git a/docs/pnl.md b/docs/pnl.md new file mode 100644 index 00000000..8f1c57ca --- /dev/null +++ b/docs/pnl.md @@ -0,0 +1,299 @@ +# PNL计算重构方案 - 最终设计 + +## 📋 核心问题与答案 + +### 1. **Initial Balance(初始余额)** + +**定义:** 创建trader时的账户净值(Total Equity),作为所有PNL计算的基准 + +**设置时机:** +- ✅ **创建trader时自动获取** - 从交易所API获取当前的Total Equity +- ✅ **允许用户手动更新** - 充值/提现后可通过前端主动同步 + +**存储位置:** +- 数据库:`traders.initial_balance` 字段 + +**计算公式:** +``` +Initial Balance = Total Wallet Balance + Total Unrealized Profit + = 当前账户净值(创建时快照) +``` + +--- + +### 2. **Equity(账户净值)** + +**定义:** 账户的实时总价值 + +**计算公式:** +``` +Total Equity = Total Wallet Balance + Total Unrealized Profit +``` + +**数据来源:** 实时从交易所API获取 + +**说明:** +- `Total Wallet Balance`: 账户中的实际USDT余额(包括已实现盈亏) +- `Total Unrealized Profit`: 所有持仓的未实现盈亏总和 +- Equity会随着市场价格波动和持仓变化实时变化 + +--- + +### 3. **PNL(盈亏)** + +#### 3.1 Total PNL(总盈亏) + +**计算公式:** +``` +Total PNL = Current Equity - Initial Balance +Total PNL % = (Total PNL / Initial Balance) × 100% +``` + +**示例:** +``` +Initial Balance: 10,000 USDT (创建时) +Current Equity: 11,500 USDT (实时) +----------------------------------- +Total PNL: +1,500 USDT +Total PNL %: +15% +``` + +#### 3.2 Unrealized PNL(未实现盈亏) + +**定义:** 当前所有持仓的未实现盈亏总和 + +**来源:** 直接从交易所API获取 `totalUnrealizedProfit` + +#### 3.3 单个持仓的PNL% + +**计算公式:** +``` +Position PNL % = (Unrealized PnL / Margin Used) × 100% +``` + +其中:`Margin Used = Position Value / Leverage` + +--- + +## 🎯 最终实现方案 + +### 核心原则 + +| 原则 | 说明 | +|-----|------| +| ❌ **禁用自动同步** | 系统**不会**自动修改Initial Balance | +| ✅ **创建时自动获取** | 创建trader时从交易所获取真实equity | +| ✅ **允许手动更新** | 用户可通过前端主动同步(充值/提现后) | +| 🔒 **常规更新保护** | UpdateTrader方法**不允许**修改Initial Balance | + +--- + +## 🔧 实现细节 + +### 1. 创建Trader时自动获取Initial Balance + +**文件:** `api/server.go:handleCreateTrader()` + +**逻辑:** +```go +// 查询交易所余额 +balanceInfo, _ := tempTrader.GetBalance() + +// 提取钱包余额和未实现盈亏 +totalWalletBalance := balanceInfo["totalWalletBalance"].(float64) +totalUnrealizedProfit := balanceInfo["totalUnrealizedProfit"].(float64) + +// 计算Total Equity作为Initial Balance +initialEquity := totalWalletBalance + totalUnrealizedProfit + +// 存入数据库 +trader := &config.TraderRecord{ + InitialBalance: initialEquity, // 自动设置 + // ... 其他字段 +} +``` + +--- + +### 2. 禁用自动同步机制 + +**修改:** `trader/auto_trader.go:autoSyncBalanceIfNeeded()` + +**操作:** +- 函数重命名为 `autoSyncBalanceIfNeeded_DEPRECATED()` +- 在 `runCycle()` 中注释掉调用 + +**效果:** 系统运行过程中**不会**自动修改Initial Balance + +--- + +### 3. 保护UpdateTrader方法 + +**文件:** `config/database.go:UpdateTrader()` + +**修改:** 从SQL UPDATE语句中移除 `initial_balance` 字段 + +**效果:** 常规的配置更新操作**无法**修改Initial Balance + +--- + +### 4. 提供手动更新API + +**端点:** `POST /traders/:id` + +**实现:** `api/server.go:handleUpdateTrader()` + +**用途:** update trader, 包括Initial Balance基准值 + +**请求体:** +```json +{ + "initial_balance": 10000.0 +} +``` + +**流程:** +``` +1. 用户输入新的initial_balance值 +2. 更新数据库的initial_balance字段 +3. 重新加载trader到内存 +4. 返回更新前后的对比信息 +``` + +**特点:** +- ✅ 用户可以输入**任意值**,不限于交易所当前余额 +- ✅ 适用于充值/提现后重置基准 +- ✅ 也可用于手动校正或调整统计基准 + +--- + +## 📊 数据流设计 + +``` +┌─────────────────────────────────────────┐ +│ 1. 创建Trader │ +│ - 用户配置AI模型、交易所 │ +│ - 系统自动获取当前equity │ +│ → initial_balance = Total Equity │ +└──────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 2. 运行期间 │ +│ - 系统不会自动修改initial_balance │ +│ - 实时计算: │ +│ current_equity = API获取 │ +│ total_pnl = current - initial │ +└──────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 3. 充值/提现后 │ +│ - 用户点击"更新初始余额"按钮 │ +│ - 更新initial_balance │ +│ - PNL计算重新基于新的基准 │ +└─────────────────────────────────────────┘ +``` + +--- + +## 📝 字段定义总结 + +| 字段 | 定义 | 计算方式 | 存储位置 | 更新频率 | +|-----|------|---------|---------|---------| +| **Initial Balance** | 基准余额 | 创建/手动同步时获取equity | DB: traders.initial_balance | 创建时+手动 | +| **Current Equity** | 当前净值 | wallet + unrealized | 不存储(实时计算) | 实时 | +| **Total PNL** | 总盈亏 | current_equity - initial_balance | 不存储(实时计算) | 实时 | +| **Total PNL %** | 盈亏百分比 | (total_pnl / initial_balance) × 100 | 不存储(实时计算) | 实时 | + +--- + +## 🎮 用户操作场景 + +### 场景1:创建新的Trader +``` +用户操作:填写基本配置(不需要输入余额) +系统行为:自动从交易所获取当前equity,设置为initial_balance +结果:initial_balance = 当前账户净值 +``` + +### 场景2:正常交易运行 +``` +用户操作:无 +系统行为:实时计算PNL,不修改initial_balance +结果:PNL = 当前equity - initial_balance +``` + +### 场景3:充值后重新校准 +``` +用户操作:充值 → 输入新的Initial Balance(如:10000 + 5000 = 15000) +系统行为:更新initial_balance为15000 +结果:PNL统计基于新的基准15000计算 +``` + +### 场景4:提现后重新校准 +``` +用户操作:提现 → 输入新的Initial Balance(如:10000 - 2000 = 8000) +系统行为:更新initial_balance为8000 +结果:PNL统计基于新的基准8000计算 +``` + +### 场景5:手动调整统计基准 +``` +用户操作:想重新开始统计PNL → 输入当前账户净值作为新基准 +系统行为:更新initial_balance为用户输入的值 +结果:PNL统计重置,从新基准开始计算 +``` + +--- + +## ✅ 优势分析 + +1. **稳定性**:PNL基准不会自动变化,统计更可靠 +2. **灵活性**:用户可以在需要时主动校准 +3. **准确性**:Initial Balance基于真实equity,不是手动输入 +4. **可控性**:充值/提现后,用户可以重置PNL统计 + +--- + +## 🚀 前端需要做的改动 + +### 1. 创建Trader页面 +- ✅ 移除"初始资金"输入框 +- ✅ 添加说明:系统将自动获取您的账户净值 + +### 2. Trader详情页面 +- ✅ 添加"更新初始余额"按钮/表单 +- ✅ 弹窗/输入框:让用户输入新的Initial Balance值 +- ✅ 提示文案: + ``` + 当前初始余额: 10,000 USDT + 请输入新的初始余额(用于重新校准PNL统计) + ``` + + +### 4. 用户体验建议 +- 💡 可以在输入框旁边显示当前账户净值作为参考 +- 💡 充值/提现后,提示用户是否需要更新Initial Balance +- 💡 显示更新前后的对比信息,让用户确认 + +--- + +## 📖 关键代码位置 + +| 功能 | 文件 | 行号/函数 | +|-----|------|----------| +| 创建时自动获取equity | api/server.go | handleCreateTrader:540-625 | +| 禁用自动同步 | trader/auto_trader.go | autoSyncBalanceIfNeeded_DEPRECATED:291 | +| 保护UpdateTrader | config/database.go | UpdateTrader:954-969 | +| 手动同步API | api/server.go | handleSyncBalance:937-1050 | +| 手动同步数据库方法 | config/database.go | UpdateTraderInitialBalance:977-982 | + +--- + +## 🎯 总结 + +这个设计平衡了**稳定性**和**灵活性**: +- Initial Balance不会被系统自动修改,确保PNL统计的一致性 +- 用户拥有主动权,可以在充值/提现后重新校准 +- 创建时自动获取真实equity,避免手动输入错误 diff --git a/logger/decision_logger.go b/logger/decision_logger.go index 81ae52ef..a15377f2 100644 --- a/logger/decision_logger.go +++ b/logger/decision_logger.go @@ -36,6 +36,7 @@ type AccountSnapshot struct { TotalUnrealizedProfit float64 `json:"total_unrealized_profit"` PositionCount int `json:"position_count"` MarginUsedPct float64 `json:"margin_used_pct"` + InitialBalance float64 `json:"initial_balance"` // 记录当时的初始余额基准 } // PositionSnapshot 持仓快照 diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 2f0fb4fd..f331f3e4 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -1089,3 +1089,15 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode log.Printf("✓ Trader '%s' (%s + %s) 已为用户加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) return nil } + +// RemoveTrader 从内存中移除指定的trader(不影响数据库) +// 用于更新trader配置时强制重新加载 +func (tm *TraderManager) RemoveTrader(traderID string) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if _, exists := tm.traders[traderID]; exists { + delete(tm.traders, traderID) + log.Printf("✓ Trader %s 已从内存中移除", traderID) + } +} diff --git a/manager/trader_manager_test.go b/manager/trader_manager_test.go new file mode 100644 index 00000000..c5344d95 --- /dev/null +++ b/manager/trader_manager_test.go @@ -0,0 +1,87 @@ +package manager + +import ( + "testing" +) + +// TestRemoveTrader 测试从内存中移除trader +func TestRemoveTrader(t *testing.T) { + tm := NewTraderManager() + + // 创建一个模拟的 trader 并添加到 map + traderID := "test-trader-123" + tm.traders[traderID] = nil // 使用 nil 作为占位符,实际测试中只需验证删除逻辑 + + // 验证 trader 存在 + if _, exists := tm.traders[traderID]; !exists { + t.Fatal("trader 应该存在于 map 中") + } + + // 调用 RemoveTrader + tm.RemoveTrader(traderID) + + // 验证 trader 已被移除 + if _, exists := tm.traders[traderID]; exists { + t.Error("trader 应该已从 map 中移除") + } +} + +// TestRemoveTrader_NonExistent 测试移除不存在的trader不会报错 +func TestRemoveTrader_NonExistent(t *testing.T) { + tm := NewTraderManager() + + // 尝试移除不存在的 trader,不应该 panic + defer func() { + if r := recover(); r != nil { + t.Errorf("移除不存在的 trader 不应该 panic: %v", r) + } + }() + + tm.RemoveTrader("non-existent-trader") +} + +// TestRemoveTrader_Concurrent 测试并发移除trader的安全性 +func TestRemoveTrader_Concurrent(t *testing.T) { + tm := NewTraderManager() + traderID := "test-trader-concurrent" + + // 添加 trader + tm.traders[traderID] = nil + + // 并发调用 RemoveTrader + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { + tm.RemoveTrader(traderID) + done <- true + }() + } + + // 等待所有 goroutine 完成 + for i := 0; i < 10; i++ { + <-done + } + + // 验证 trader 已被移除 + if _, exists := tm.traders[traderID]; exists { + t.Error("trader 应该已从 map 中移除") + } +} + +// TestGetTrader_AfterRemove 测试移除后获取trader返回错误 +func TestGetTrader_AfterRemove(t *testing.T) { + tm := NewTraderManager() + traderID := "test-trader-get" + + // 添加 trader + tm.traders[traderID] = nil + + // 移除 trader + tm.RemoveTrader(traderID) + + // 尝试获取已移除的 trader + _, err := tm.GetTrader(traderID) + if err == nil { + t.Error("获取已移除的 trader 应该返回错误") + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 72b10fcb..7362e786 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -286,101 +286,6 @@ func (at *AutoTrader) Stop() { log.Println("⏹ 自动交易系统停止") } -// autoSyncBalanceIfNeeded 自动同步余额(每10分钟检查一次,变化>5%才更新) -func (at *AutoTrader) autoSyncBalanceIfNeeded() { - // 距离上次同步不足10分钟,跳过 - if time.Since(at.lastBalanceSyncTime) < 10*time.Minute { - return - } - - log.Printf("🔄 [%s] 开始自动检查余额变化...", at.name) - - // 查询实际余额 - balanceInfo, err := at.trader.GetBalance() - if err != nil { - log.Printf("⚠️ [%s] 查询余额失败: %v", at.name, err) - at.lastBalanceSyncTime = time.Now() // 即使失败也更新时间,避免频繁重试 - return - } - - // 提取可用余额 - var actualBalance float64 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - actualBalance = totalBalance - } else { - log.Printf("⚠️ [%s] 无法提取可用余额", at.name) - at.lastBalanceSyncTime = time.Now() - return - } - - oldBalance := at.initialBalance - - // 防止除以零:如果初始余额无效,直接更新为实际余额 - if oldBalance <= 0 { - log.Printf("⚠️ [%s] 初始余额无效 (%.2f),直接更新为实际余额 %.2f USDT", at.name, oldBalance, actualBalance) - at.initialBalance = actualBalance - if at.database != nil { - type DatabaseUpdater interface { - UpdateTraderInitialBalance(userID, id string, newBalance float64) error - } - if db, ok := at.database.(DatabaseUpdater); ok { - if err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance); err != nil { - log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err) - } else { - log.Printf("✅ [%s] 已自动同步余额到数据库", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name) - } - at.lastBalanceSyncTime = time.Now() - return - } - - changePercent := ((actualBalance - oldBalance) / oldBalance) * 100 - - // 变化超过5%才更新 - if math.Abs(changePercent) > 5.0 { - log.Printf("🔔 [%s] 检测到余额大幅变化: %.2f → %.2f USDT (%.2f%%)", - at.name, oldBalance, actualBalance, changePercent) - - // 更新内存中的 initialBalance - at.initialBalance = actualBalance - - // 更新数据库(需要类型断言) - if at.database != nil { - // 这里需要根据实际的数据库类型进行类型断言 - // 由于使用了 interface{},我们需要在 TraderManager 层面处理更新 - // 或者在这里进行类型检查 - type DatabaseUpdater interface { - UpdateTraderInitialBalance(userID, id string, newBalance float64) error - } - if db, ok := at.database.(DatabaseUpdater); ok { - err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance) - if err != nil { - log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err) - } else { - log.Printf("✅ [%s] 已自动同步余额到数据库", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name) - } - } else { - log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name) - } - } else { - log.Printf("✓ [%s] 余额变化不大 (%.2f%%),无需更新", at.name, changePercent) - } - - at.lastBalanceSyncTime = time.Now() -} - // runCycle 运行一个交易周期(使用AI全权决策) func (at *AutoTrader) runCycle() error { at.callCount++ @@ -412,9 +317,6 @@ func (at *AutoTrader) runCycle() error { log.Println("📅 日盈亏已重置") } - // 3. 自动同步余额(每10分钟检查一次,充值/提现后自动更新) - at.autoSyncBalanceIfNeeded() - // 4. 收集交易上下文 ctx, err := at.buildTradingContext() if err != nil { @@ -426,11 +328,12 @@ func (at *AutoTrader) runCycle() error { // 保存账户状态快照 record.AccountState = logger.AccountSnapshot{ - TotalBalance: ctx.Account.TotalEquity, + TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, AvailableBalance: ctx.Account.AvailableBalance, - TotalUnrealizedProfit: ctx.Account.TotalPnL, + TotalUnrealizedProfit: ctx.Account.UnrealizedPnL, PositionCount: ctx.Account.PositionCount, MarginUsedPct: ctx.Account.MarginUsedPct, + InitialBalance: at.initialBalance, // 记录当时的初始余额基准 } // 保存持仓快照 @@ -714,6 +617,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { Account: decision.AccountInfo{ TotalEquity: totalEquity, AvailableBalance: availableBalance, + UnrealizedPnL: totalUnrealizedProfit, TotalPnL: totalPnL, TotalPnLPct: totalPnLPct, MarginUsed: totalMarginUsed, @@ -1361,7 +1265,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { } totalMarginUsed := 0.0 - totalUnrealizedPnL := 0.0 + totalUnrealizedPnLCalculated := 0.0 for _, pos := range positions { markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) @@ -1369,7 +1273,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { quantity = -quantity } unrealizedPnl := pos["unRealizedProfit"].(float64) - totalUnrealizedPnL += unrealizedPnl + totalUnrealizedPnLCalculated += unrealizedPnl leverage := 10 if lev, ok := pos["leverage"].(float64); ok { @@ -1379,10 +1283,19 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { totalMarginUsed += marginUsed } + // 验证未实现盈亏的一致性(API值 vs 从持仓计算) + diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated) + if diff > 0.1 { // 允许0.01 USDT的误差 + log.Printf("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f", + totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff) + } + totalPnL := totalEquity - at.initialBalance totalPnLPct := 0.0 if at.initialBalance > 0 { totalPnLPct = (totalPnL / at.initialBalance) * 100 + } else { + log.Printf("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance) } marginUsedPct := 0.0 @@ -1394,15 +1307,14 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { // 核心字段 "total_equity": totalEquity, // 账户净值 = wallet + unrealized "wallet_balance": totalWalletBalance, // 钱包余额(不含未实现盈亏) - "unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(从API) + "unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(交易所API官方值) "available_balance": availableBalance, // 可用余额 // 盈亏统计 - "total_pnl": totalPnL, // 总盈亏 = equity - initial - "total_pnl_pct": totalPnLPct, // 总盈亏百分比 - "total_unrealized_pnl": totalUnrealizedPnL, // 未实现盈亏(从持仓计算) - "initial_balance": at.initialBalance, // 初始余额 - "daily_pnl": at.dailyPnL, // 日盈亏 + "total_pnl": totalPnL, // 总盈亏 = equity - initial + "total_pnl_pct": totalPnLPct, // 总盈亏百分比 + "initial_balance": at.initialBalance, // 初始余额 + "daily_pnl": at.dailyPnL, // 日盈亏 // 持仓信息 "position_count": len(positions), // 持仓数量 diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx index dc81c9cf..554c9b50 100644 --- a/web/src/components/ComparisonChart.tsx +++ b/web/src/components/ComparisonChart.tsx @@ -100,14 +100,9 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { }) } - // 计算盈亏百分比:从total_pnl和balance计算 - // 假设初始余额 = balance - total_pnl - const initialBalance = point.balance - point.total_pnl - const pnlPct = - initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0 - + // 直接使用后端返回的盈亏百分比,不要在前端重新计算 timestampMap.get(ts)!.traders.set(trader.trader_id, { - pnl_pct: pnlPct, + pnl_pct: point.total_pnl_pct || 0, equity: point.total_equity, }) }) diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index f0655213..30e104a6 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -25,7 +25,7 @@ interface TraderConfigData { is_cross_margin: boolean use_coin_pool: boolean use_oi_top: boolean - initial_balance: number + initial_balance?: number // 可选:创建时不需要,编辑时使用 scan_interval_minutes: number } @@ -62,7 +62,6 @@ export function TraderConfigModal({ is_cross_margin: true, use_coin_pool: false, use_oi_top: false, - initial_balance: 1000, scan_interval_minutes: 3, }) const [isSaving, setIsSaving] = useState(false) @@ -247,9 +246,14 @@ export function TraderConfigModal({ is_cross_margin: formData.is_cross_margin, use_coin_pool: formData.use_coin_pool, use_oi_top: formData.use_oi_top, - initial_balance: formData.initial_balance, scan_interval_minutes: formData.scan_interval_minutes, } + + // 只在编辑模式时包含initial_balance(用于手动更新) + if (isEditMode && formData.initial_balance !== undefined) { + saveData.initial_balance = formData.initial_balance + } + await toast.promise(onSave(saveData), { loading: '正在保存…', success: '保存成功', @@ -404,15 +408,12 @@ export function TraderConfigModal({ -
-
- - {isEditMode && ( + {isEditMode && ( +
+
+ +
+ + handleInputChange( + 'initial_balance', + Number(e.target.value) + ) + } + onBlur={(e) => { + // Force minimum value on blur + const value = Number(e.target.value) + if (value < 100) { + handleInputChange('initial_balance', 100) + } + }} + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" + min="100" + step="0.01" + /> +

+ 用于手动更新初始余额基准(例如充值/提现后) +

+ {balanceFetchError && ( +

+ {balanceFetchError} +

)}
- - handleInputChange( - 'initial_balance', - Number(e.target.value) - ) - } - onBlur={(e) => { - // Force minimum value on blur - const value = Number(e.target.value) - if (value < 100) { - handleInputChange('initial_balance', 100) - } - }} - className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - min="100" - step="0.01" - /> - {!isEditMode && ( -

+ )} + {!isEditMode && ( +

+ +
- - - + + + - 请输入您交易所账户的当前实际余额。如果输入不准确,P&L统计将会错误。 -

- )} - {isEditMode && ( -

- 点击"获取当前余额"按钮可自动获取您交易所账户的当前净值 -

- )} - {balanceFetchError && ( -

- {balanceFetchError} -

- )} -
+ + 系统将自动获取您的账户净值作为初始余额 + +
+
+ )}
{/* 第二行:AI 扫描决策间隔 */} diff --git a/web/src/types.ts b/web/src/types.ts index f63be1b6..8bf1e6bf 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -16,11 +16,10 @@ export interface SystemStatus { export interface AccountInfo { total_equity: number wallet_balance: number - unrealized_profit: number + unrealized_profit: number // 未实现盈亏(交易所API官方值) available_balance: number total_pnl: number total_pnl_pct: number - total_unrealized_pnl: number initial_balance: number daily_pnl: number position_count: number @@ -127,7 +126,7 @@ export interface CreateTraderRequest { name: string ai_model_id: string exchange_id: string - initial_balance: number + initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新 scan_interval_minutes?: number btc_eth_leverage?: number altcoin_leverage?: number