From 5c9b396e5ae8e129f0ffa4d7a3e5588e3597d17e Mon Sep 17 00:00:00 2001 From: Xeron Date: Sun, 2 Nov 2025 12:35:37 +0800 Subject: [PATCH] fix: detect and record stop-loss/take-profit auto-close trades Fixes #200 Previously, positions closed via stop-loss or take-profit were not recorded in decision logs, causing inaccurate performance metrics (win rate, profit factor, etc.) displayed in the frontend. This commit implements a position snapshot mechanism to detect auto-closed positions by comparing positions between trading cycles. Changes: - Add PositionSnapshot struct to track position state - Add detectAutoClosedPositions() to detect disappeared positions - Update position snapshots at cycle end (after AI execution) - Support auto_close_long/auto_close_short action types in logger - Ensure accurate performance analysis including auto-closed trades Key fix: - Snapshots are updated AFTER AI execution to avoid false positives - AI manual closes won't be mistakenly detected as auto-closes --- logger/decision_logger.go | 14 ++-- trader/auto_trader.go | 145 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 14 deletions(-) diff --git a/logger/decision_logger.go b/logger/decision_logger.go index efa5ab74..0779e450 100644 --- a/logger/decision_logger.go +++ b/logger/decision_logger.go @@ -243,7 +243,7 @@ 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++ } } @@ -348,9 +348,9 @@ 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 == "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" } posKey := symbol + "_" + side @@ -365,7 +365,7 @@ 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) } @@ -382,9 +382,9 @@ 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 == "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" } posKey := symbol + "_" + side // 使用symbol_side作为key,区分多空持仓 @@ -400,7 +400,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna "leverage": action.Leverage, } - case "close_long", "close_short": + case "close_long", "close_short", "auto_close_long", "auto_close_short": // 查找对应的开仓记录(可能来自预填充或当前窗口) if openPos, exists := openPositions[posKey]; exists { openPrice := openPos["openPrice"].(float64) diff --git a/trader/auto_trader.go b/trader/auto_trader.go index b23bb052..7214f998 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -75,6 +75,15 @@ type AutoTraderConfig struct { SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive") } +// PositionSnapshot 持仓快照(用于检测自动平仓) +type PositionSnapshot struct { + Symbol string + Side string + Quantity float64 + EntryPrice float64 + Leverage int +} + // AutoTrader 自动交易器 type AutoTrader struct { id string // Trader唯一标识 @@ -98,6 +107,7 @@ type AutoTrader struct { startTime time.Time // 系统启动时间 callCount int // AI调用次数 positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒) + lastPositions map[string]*PositionSnapshot // 上一个周期的持仓快照 (symbol_side -> snapshot) } // NewAutoTrader 创建自动交易器 @@ -216,6 +226,7 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) { callCount: 0, isRunning: false, positionFirstSeenTime: make(map[string]int64), + lastPositions: make(map[string]*PositionSnapshot), }, nil } @@ -257,9 +268,9 @@ func (at *AutoTrader) Stop() { func (at *AutoTrader) runCycle() error { at.callCount++ - log.Printf("\n" + strings.Repeat("=", 70)) + log.Print("\n" + strings.Repeat("=", 70)) log.Printf("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount) - log.Printf(strings.Repeat("=", 70)) + log.Print(strings.Repeat("=", 70)) // 创建决策记录 record := &logger.DecisionRecord{ @@ -293,6 +304,15 @@ func (at *AutoTrader) runCycle() error { return fmt.Errorf("构建交易上下文失败: %w", err) } + // 3.1 检测自动平仓(止损/止盈触发) + autoClosedActions := at.detectAutoClosedPositions(ctx.Positions) + for _, action := range autoClosedActions { + log.Printf("[AUTO-CLOSE] 检测到自动平仓: %s %s (价格: %.4f)", action.Symbol, action.Action, action.Price) + record.Decisions = append(record.Decisions, action) + record.ExecutionLog = append(record.ExecutionLog, + fmt.Sprintf("[AUTO-CLOSE] 自动平仓: %s %s (止损/止盈触发)", action.Symbol, action.Action)) + } + // 保存账户状态快照 record.AccountState = logger.AccountSnapshot{ TotalBalance: ctx.Account.TotalEquity, @@ -346,19 +366,19 @@ func (at *AutoTrader) runCycle() error { // 打印系统提示词和AI思维链(即使有错误,也要输出以便调试) if decision != nil { if decision.SystemPrompt != "" { - log.Printf("\n" + strings.Repeat("=", 70)) + log.Print("\n" + strings.Repeat("=", 70)) log.Printf("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate) log.Println(strings.Repeat("=", 70)) log.Println(decision.SystemPrompt) - log.Printf(strings.Repeat("=", 70) + "\n") + log.Print(strings.Repeat("=", 70) + "\n") } if decision.CoTTrace != "" { - log.Printf("\n" + strings.Repeat("-", 70)) + log.Print("\n" + strings.Repeat("-", 70)) log.Println("💭 AI思维链分析(错误情况):") log.Println(strings.Repeat("-", 70)) log.Println(decision.CoTTrace) - log.Printf(strings.Repeat("-", 70) + "\n") + log.Print(strings.Repeat("-", 70) + "\n") } } @@ -426,7 +446,47 @@ func (at *AutoTrader) runCycle() error { record.Decisions = append(record.Decisions, actionRecord) } - // 9. 保存决策记录 + // 9. 更新持仓快照(用于下一周期检测自动平仓) + // 注意:需要重新获取当前持仓,因为 AI 可能在本周期执行了平仓操作 + // ctx.Positions 是周期开始时的持仓,不反映本周期的变化 + currentPositionsAfterExecution, err := at.trader.GetPositions() + if err != nil { + log.Printf("⚠ 更新持仓快照失败,无法获取当前持仓: %v", err) + // 即使失败也不影响主流程,下一周期可能会有误报但不会崩溃 + } else { + // 将原始持仓数据转换为快照格式 + snapshots := make([]PositionSnapshot, 0, len(currentPositionsAfterExecution)) + for _, pos := range currentPositionsAfterExecution { + symbol := pos["symbol"].(string) + side := pos["side"].(string) + entryPrice := pos["entryPrice"].(float64) + quantity := pos["positionAmt"].(float64) + if quantity < 0 { + quantity = -quantity // 空仓数量为负,转为正数 + } + leverage := 10 + if lev, ok := pos["leverage"].(float64); ok { + leverage = int(lev) + } + + snapshots = append(snapshots, PositionSnapshot{ + Symbol: symbol, + Side: side, + EntryPrice: entryPrice, + Quantity: quantity, + Leverage: leverage, + }) + } + at.lastPositions = make(map[string]*PositionSnapshot) + for _, snap := range snapshots { + posKey := snap.Symbol + "_" + snap.Side + // 创建副本避免指针问题 + snapshot := snap + at.lastPositions[posKey] = &snapshot + } + } + + // 10. 保存决策记录 if err := at.decisionLogger.LogDecision(record); err != nil { log.Printf("⚠ 保存决策记录失败: %v", err) } @@ -1081,3 +1141,74 @@ func normalizeSymbol(symbol string) string { return symbol } + +// detectAutoClosedPositions 检测自动平仓的持仓(止损/止盈触发) +func (at *AutoTrader) detectAutoClosedPositions(currentPositions []decision.PositionInfo) []logger.DecisionAction { + var autoClosedActions []logger.DecisionAction + + // 创建当前持仓的map便于查找 + currentPosMap := make(map[string]bool) + for _, pos := range currentPositions { + posKey := pos.Symbol + "_" + pos.Side + currentPosMap[posKey] = true + } + + // 检查上一个周期的持仓,哪些现在消失了 + for posKey, lastPos := range at.lastPositions { + if !currentPosMap[posKey] { + // 这个持仓消失了,说明被自动平仓了(止损/止盈触发) + // 获取当前价格作为平仓价格的近似值 + marketData, err := market.Get(lastPos.Symbol) + closePrice := 0.0 + if err == nil { + closePrice = marketData.CurrentPrice + } else { + // 如果无法获取当前价格,使用入场价作为fallback + closePrice = lastPos.EntryPrice + } + + // 确定是平多仓还是平空仓 + action := "auto_close_long" + if lastPos.Side == "short" { + action = "auto_close_short" + } + + // 创建自动平仓记录 + autoClosedAction := logger.DecisionAction{ + Action: action, + Symbol: lastPos.Symbol, + Quantity: lastPos.Quantity, + Leverage: lastPos.Leverage, + Price: closePrice, + OrderID: 0, // 自动平仓没有特定的订单ID + Timestamp: time.Now(), + Success: true, + Error: "", + } + + autoClosedActions = append(autoClosedActions, autoClosedAction) + log.Printf("[AUTO-CLOSE] 检测到自动平仓: %s %s @ %.4f (可能由止损/止盈触发)", + lastPos.Symbol, action, closePrice) + } + } + + return autoClosedActions +} + +// updatePositionSnapshots 更新持仓快照(用于下一周期检测自动平仓) +func (at *AutoTrader) updatePositionSnapshots(currentPositions []decision.PositionInfo) { + // 清空旧的快照 + at.lastPositions = make(map[string]*PositionSnapshot) + + // 记录当前所有持仓 + for _, pos := range currentPositions { + posKey := pos.Symbol + "_" + pos.Side + at.lastPositions[posKey] = &PositionSnapshot{ + Symbol: pos.Symbol, + Side: pos.Side, + Quantity: pos.Quantity, + EntryPrice: pos.EntryPrice, + Leverage: pos.Leverage, + } + } +}