fix(stats): fixed the PNL calculation (#963)

This commit is contained in:
Diego
2025-11-13 01:27:13 -05:00
committed by tangmengqiu
parent 970cfaadf3
commit fc8a4d3d63
11 changed files with 549 additions and 304 deletions

View File

@@ -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{

View File

@@ -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

View File

@@ -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"` // 已用保证金

299
docs/pnl.md Normal file
View File

@@ -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 Balance10000 + 5000 = 15000
系统行为更新initial_balance为15000
结果PNL统计基于新的基准15000计算
```
### 场景4提现后重新校准
```
用户操作:提现 → 输入新的Initial Balance10000 - 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避免手动输入错误

View File

@@ -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 持仓快照

View File

@@ -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)
}
}

View File

@@ -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 应该返回错误")
}
}

View File

@@ -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), // 持仓数量

View File

@@ -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,
})
})

View File

@@ -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({
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
{!isEditMode && (
<span className="text-[#F0B90B] ml-1">*</span>
)}
</label>
{isEditMode && (
{isEditMode && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
</label>
<button
type="button"
onClick={handleFetchCurrentBalance}
@@ -421,33 +422,46 @@ export function TraderConfigModal({
>
{isFetchingBalance ? '获取中...' : '获取当前余额'}
</button>
</div>
<input
type="number"
value={formData.initial_balance || 0}
onChange={(e) =>
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"
/>
<p className="text-xs text-[#848E9C] mt-1">
/
</p>
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">
{balanceFetchError}
</p>
)}
</div>
<input
type="number"
value={formData.initial_balance}
onChange={(e) =>
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 && (
<p className="text-xs text-[#F0B90B] mt-1 flex items-center gap-1">
)}
{!isEditMode && (
<div>
<label className="text-sm text-[#EAECEF] mb-2 block">
</label>
<div className="w-full px-3 py-2 bg-[#1E2329] border border-[#2B3139] rounded text-[#848E9C] flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
className="w-4 h-4 text-[#F0B90B]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -455,24 +469,16 @@ export function TraderConfigModal({
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
P&L统计将会错误
</p>
)}
{isEditMode && (
<p className="text-xs text-[#848E9C] mt-1">
"获取当前余额"
</p>
)}
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">
{balanceFetchError}
</p>
)}
</div>
<span className="text-sm">
</span>
</div>
</div>
)}
</div>
{/* 第二行AI 扫描决策间隔 */}

View File

@@ -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