mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
fix(stats): fixed the PNL calculation (#963)
This commit is contained in:
194
api/server.go
194
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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
299
docs/pnl.md
Normal 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 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,避免手动输入错误
|
||||
@@ -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 持仓快照
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
87
manager/trader_manager_test.go
Normal file
87
manager/trader_manager_test.go
Normal 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 应该返回错误")
|
||||
}
|
||||
}
|
||||
@@ -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), // 持仓数量
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 扫描决策间隔 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user