fix(stats): aggregate partial closes into single trade for accurate statistics

## Problem
Multiple partial_close actions on the same position were being counted as separate trades, inflating TotalTrades count and distorting win rate/profit factor statistics.

**Example of bug:**
- Open 1 BTC @ $100,000
- Partial close 30% @ $101,000 → Counted as trade #1 
- Partial close 50% @ $102,000 → Counted as trade #2 
- Close remaining 20% @ $103,000 → Counted as trade #3 
- **Result:** 3 trades instead of 1 

## Solution

### 1. Added tracking fields to openPositions map
- `remainingQuantity`: Tracks remaining position size
- `accumulatedPnL`: Accumulates PnL from all partial closes
- `partialCloseCount`: Counts number of partial close operations
- `partialCloseVolume`: Total volume closed partially

### 2. Modified partial_close handling logic
- Each partial_close:
  - Accumulates PnL into `accumulatedPnL`
  - Reduces `remainingQuantity`
  - **Does NOT increment TotalTrades++**
  - Keeps position in openPositions map

- Only when `remainingQuantity <= 0.0001`:
  - Records ONE TradeOutcome with aggregated PnL
  - Increments TotalTrades++ once
  - Removes from openPositions map

### 3. Updated full close handling
- If position had prior partial closes:
  - Adds `accumulatedPnL` to final close PnL
  - Reports total PnL in TradeOutcome

### 4. Fixed GetStatistics()
- Removed `partial_close` from TotalClosePositions count
- Only `close_long/close_short/auto_close` count as close operations

## Impact
-  Statistics now accurate: multiple partial closes = 1 trade
-  Win rate calculated correctly
-  Profit factor reflects true performance
-  Backward compatible: handles positions without tracking fields

## Testing
-  Compiles successfully
- ⚠️ Requires validation with live partial_close scenarios

## Code Changes
```
logger/decision_logger.go:
- Lines 420-430: Add tracking fields to openPositions
- Lines 441-534: Implement partial_close aggregation logic
- Lines 536-593: Update full close to include accumulated PnL
- Lines 246-250: Fix GetStatistics() to exclude partial_close
```
This commit is contained in:
ZhouYongyou
2025-11-04 18:58:20 +08:00
parent 5649cb7496
commit 08f99c5a90

View File

@@ -50,9 +50,9 @@ type PositionSnapshot struct {
// DecisionAction 决策动作
type DecisionAction struct {
Action string `json:"action"` // open_long, open_short, close_long, close_short
Action string `json:"action"` // open_long, open_short, close_long, close_short, update_stop_loss, update_take_profit, partial_close
Symbol string `json:"symbol"` // 币种
Quantity float64 `json:"quantity"` // 数量
Quantity float64 `json:"quantity"` // 数量(部分平仓时使用)
Leverage int `json:"leverage"` // 杠杆(开仓时)
Price float64 `json:"price"` // 执行价格
OrderID int64 `json:"order_id"` // 订单ID
@@ -243,8 +243,11 @@ func (l *DecisionLogger) GetStatistics() (*Statistics, error) {
switch action.Action {
case "open_long", "open_short":
stats.TotalOpenPositions++
case "close_long", "close_short":
case "close_long", "close_short", "auto_close_long", "auto_close_short":
stats.TotalClosePositions++
// 🔧 BUG FIXpartial_close 不計入 TotalClosePositions避免重複計數
// case "partial_close": // 不計數,因為只有完全平倉才算一次
// update_stop_loss 和 update_take_profit 不計入統計
}
}
}
@@ -348,11 +351,22 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
symbol := action.Symbol
side := ""
if action.Action == "open_long" || action.Action == "close_long" {
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" || action.Action == "auto_close_long" {
side = "long"
} else if action.Action == "open_short" || action.Action == "close_short" {
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" {
side = "short"
}
// partial_close 需要根據持倉判斷方向
if action.Action == "partial_close" && side == "" {
for key, pos := range openPositions {
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
side = posSymbol
break
}
}
}
posKey := symbol + "_" + side
switch action.Action {
@@ -365,9 +379,10 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
"quantity": action.Quantity,
"leverage": action.Leverage,
}
case "close_long", "close_short":
case "close_long", "close_short", "auto_close_long", "auto_close_short":
// 移除已平仓记录
delete(openPositions, posKey)
// partial_close 不處理,保留持倉記錄
}
}
}
@@ -382,25 +397,41 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
symbol := action.Symbol
side := ""
if action.Action == "open_long" || action.Action == "close_long" {
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" || action.Action == "auto_close_long" {
side = "long"
} else if action.Action == "open_short" || action.Action == "close_short" {
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" {
side = "short"
}
// partial_close 需要根據持倉判斷方向
if action.Action == "partial_close" {
// 從 openPositions 中查找持倉方向
for key, pos := range openPositions {
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
side = posSymbol
break
}
}
}
posKey := symbol + "_" + side // 使用symbol_side作为key区分多空持仓
switch action.Action {
case "open_long", "open_short":
// 更新开仓记录(可能已经在预填充时记录过了)
openPositions[posKey] = map[string]interface{}{
"side": side,
"openPrice": action.Price,
"openTime": action.Timestamp,
"quantity": action.Quantity,
"leverage": action.Leverage,
"side": side,
"openPrice": action.Price,
"openTime": action.Timestamp,
"quantity": action.Quantity,
"leverage": action.Leverage,
"remainingQuantity": action.Quantity, // 🔧 BUG FIX追蹤剩餘數量
"accumulatedPnL": 0.0, // 🔧 BUG FIX累積部分平倉盈虧
"partialCloseCount": 0, // 🔧 BUG FIX部分平倉次數
"partialCloseVolume": 0.0, // 🔧 BUG FIX部分平倉總量
}
case "close_long", "close_short":
case "close_long", "close_short", "partial_close", "auto_close_long", "auto_close_short":
// 查找对应的开仓记录(可能来自预填充或当前窗口)
if openPos, exists := openPositions[posKey]; exists {
openPrice := openPos["openPrice"].(float64)
@@ -409,71 +440,159 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
quantity := openPos["quantity"].(float64)
leverage := openPos["leverage"].(int)
// 计算实际盈亏USDT
// 合约交易 PnL 计算quantity × 价格差
// 注意:杠杆不影响绝对盈亏,只影响保证金需求
// 🔧 BUG FIX取得追蹤字段若不存在則初始化
remainingQty, _ := openPos["remainingQuantity"].(float64)
if remainingQty == 0 {
remainingQty = quantity // 兼容舊數據(沒有 remainingQuantity 字段)
}
accumulatedPnL, _ := openPos["accumulatedPnL"].(float64)
partialCloseCount, _ := openPos["partialCloseCount"].(int)
partialCloseVolume, _ := openPos["partialCloseVolume"].(float64)
// 对于 partial_close使用实际平仓数量否则使用剩余仓位数量
actualQuantity := remainingQty
if action.Action == "partial_close" {
actualQuantity = action.Quantity
}
// 计算本次平仓的盈亏USDT
var pnl float64
if side == "long" {
pnl = quantity * (action.Price - openPrice)
pnl = actualQuantity * (action.Price - openPrice)
} else {
pnl = quantity * (openPrice - action.Price)
pnl = actualQuantity * (openPrice - action.Price)
}
// 计算盈亏百分比(相对保证金)
positionValue := quantity * openPrice
marginUsed := positionValue / float64(leverage)
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (pnl / marginUsed) * 100
}
// 🔧 BUG FIX處理 partial_close 聚合邏輯
if action.Action == "partial_close" {
// 累積盈虧和數量
accumulatedPnL += pnl
remainingQty -= actualQuantity
partialCloseCount++
partialCloseVolume += actualQuantity
// 记录交易结果
outcome := TradeOutcome{
Symbol: symbol,
Side: side,
Quantity: quantity,
Leverage: leverage,
OpenPrice: openPrice,
ClosePrice: action.Price,
PositionValue: positionValue,
MarginUsed: marginUsed,
PnL: pnl,
PnLPct: pnlPct,
Duration: action.Timestamp.Sub(openTime).String(),
OpenTime: openTime,
CloseTime: action.Timestamp,
}
// 更新 openPositions保留持倉記錄但更新追蹤數據
openPos["remainingQuantity"] = remainingQty
openPos["accumulatedPnL"] = accumulatedPnL
openPos["partialCloseCount"] = partialCloseCount
openPos["partialCloseVolume"] = partialCloseVolume
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
analysis.TotalTrades++
// 判斷是否已完全平倉
if remainingQty <= 0.0001 { // 使用小閾值避免浮點誤差
// ✅ 完全平倉:記錄為一筆完整交易
positionValue := quantity * openPrice
marginUsed := positionValue / float64(leverage)
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (accumulatedPnL / marginUsed) * 100
}
// 分类交易盈利、亏损、持平避免将pnl=0算入亏损
if pnl > 0 {
analysis.WinningTrades++
analysis.AvgWin += pnl
} else if pnl < 0 {
analysis.LosingTrades++
analysis.AvgLoss += pnl
}
// pnl == 0 的交易不计入盈利也不计入亏损,但计入总交易数
outcome := TradeOutcome{
Symbol: symbol,
Side: side,
Quantity: quantity, // 使用原始總量
Leverage: leverage,
OpenPrice: openPrice,
ClosePrice: action.Price, // 最後一次平倉價格
PositionValue: positionValue,
MarginUsed: marginUsed,
PnL: accumulatedPnL, // 🔧 使用累積盈虧
PnLPct: pnlPct,
Duration: action.Timestamp.Sub(openTime).String(),
OpenTime: openTime,
CloseTime: action.Timestamp,
}
// 更新币种统计
if _, exists := analysis.SymbolStats[symbol]; !exists {
analysis.SymbolStats[symbol] = &SymbolPerformance{
Symbol: symbol,
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
analysis.TotalTrades++ // 🔧 只在完全平倉時計數
// 分类交易
if accumulatedPnL > 0 {
analysis.WinningTrades++
analysis.AvgWin += accumulatedPnL
} else if accumulatedPnL < 0 {
analysis.LosingTrades++
analysis.AvgLoss += accumulatedPnL
}
// 更新币种统计
if _, exists := analysis.SymbolStats[symbol]; !exists {
analysis.SymbolStats[symbol] = &SymbolPerformance{
Symbol: symbol,
}
}
stats := analysis.SymbolStats[symbol]
stats.TotalTrades++
stats.TotalPnL += accumulatedPnL
if accumulatedPnL > 0 {
stats.WinningTrades++
} else if accumulatedPnL < 0 {
stats.LosingTrades++
}
// 刪除持倉記錄
delete(openPositions, posKey)
}
}
stats := analysis.SymbolStats[symbol]
stats.TotalTrades++
stats.TotalPnL += pnl
if pnl > 0 {
stats.WinningTrades++
} else if pnl < 0 {
stats.LosingTrades++
}
// ⚠️ 否則不做任何操作(等待後續 partial_close 或 full close
// 移除已平仓记录
delete(openPositions, posKey)
} else {
// 🔧 完全平倉close_long/close_short/auto_close
// 如果之前有部分平倉,需要加上累積的 PnL
totalPnL := accumulatedPnL + pnl
positionValue := quantity * openPrice
marginUsed := positionValue / float64(leverage)
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (totalPnL / marginUsed) * 100
}
outcome := TradeOutcome{
Symbol: symbol,
Side: side,
Quantity: quantity, // 使用原始總量
Leverage: leverage,
OpenPrice: openPrice,
ClosePrice: action.Price,
PositionValue: positionValue,
MarginUsed: marginUsed,
PnL: totalPnL, // 🔧 包含之前部分平倉的 PnL
PnLPct: pnlPct,
Duration: action.Timestamp.Sub(openTime).String(),
OpenTime: openTime,
CloseTime: action.Timestamp,
}
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
analysis.TotalTrades++
// 分类交易
if totalPnL > 0 {
analysis.WinningTrades++
analysis.AvgWin += totalPnL
} else if totalPnL < 0 {
analysis.LosingTrades++
analysis.AvgLoss += totalPnL
}
// 更新币种统计
if _, exists := analysis.SymbolStats[symbol]; !exists {
analysis.SymbolStats[symbol] = &SymbolPerformance{
Symbol: symbol,
}
}
stats := analysis.SymbolStats[symbol]
stats.TotalTrades++
stats.TotalPnL += totalPnL
if totalPnL > 0 {
stats.WinningTrades++
} else if totalPnL < 0 {
stats.LosingTrades++
}
// 刪除持倉記錄
delete(openPositions, posKey)
}
}
}
}