mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
769 lines
25 KiB
Go
769 lines
25 KiB
Go
package logger
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/ioutil"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
)
|
||
|
||
// DecisionRecord 决策记录
|
||
type DecisionRecord struct {
|
||
Timestamp time.Time `json:"timestamp"` // 决策时间
|
||
CycleNumber int `json:"cycle_number"` // 周期编号
|
||
SystemPrompt string `json:"system_prompt"` // 系统提示词(发送给AI的系统prompt)
|
||
InputPrompt string `json:"input_prompt"` // 发送给AI的输入prompt
|
||
CoTTrace string `json:"cot_trace"` // AI思维链(输出)
|
||
DecisionJSON string `json:"decision_json"` // 决策JSON
|
||
AccountState AccountSnapshot `json:"account_state"` // 账户状态快照
|
||
Positions []PositionSnapshot `json:"positions"` // 持仓快照
|
||
CandidateCoins []string `json:"candidate_coins"` // 候选币种列表
|
||
Decisions []DecisionAction `json:"decisions"` // 执行的决策
|
||
ExecutionLog []string `json:"execution_log"` // 执行日志
|
||
Success bool `json:"success"` // 是否成功
|
||
ErrorMessage string `json:"error_message"` // 错误信息(如果有)
|
||
// AIRequestDurationMs 记录 AI API 调用耗时(毫秒),方便评估调用性能
|
||
AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"`
|
||
}
|
||
|
||
// AccountSnapshot 账户状态快照
|
||
type AccountSnapshot struct {
|
||
TotalBalance float64 `json:"total_balance"`
|
||
AvailableBalance float64 `json:"available_balance"`
|
||
TotalUnrealizedProfit float64 `json:"total_unrealized_profit"`
|
||
PositionCount int `json:"position_count"`
|
||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||
InitialBalance float64 `json:"initial_balance"` // 记录当时的初始余额基准
|
||
}
|
||
|
||
// PositionSnapshot 持仓快照
|
||
type PositionSnapshot struct {
|
||
Symbol string `json:"symbol"`
|
||
Side string `json:"side"`
|
||
PositionAmt float64 `json:"position_amt"`
|
||
EntryPrice float64 `json:"entry_price"`
|
||
MarkPrice float64 `json:"mark_price"`
|
||
UnrealizedProfit float64 `json:"unrealized_profit"`
|
||
Leverage float64 `json:"leverage"`
|
||
LiquidationPrice float64 `json:"liquidation_price"`
|
||
}
|
||
|
||
// DecisionAction 决策动作
|
||
type DecisionAction struct {
|
||
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"` // 数量(部分平仓时使用)
|
||
Leverage int `json:"leverage"` // 杠杆(开仓时)
|
||
Price float64 `json:"price"` // 执行价格
|
||
OrderID int64 `json:"order_id"` // 订单ID
|
||
Timestamp time.Time `json:"timestamp"` // 执行时间
|
||
Success bool `json:"success"` // 是否成功
|
||
Error string `json:"error"` // 错误信息
|
||
}
|
||
|
||
// IDecisionLogger 决策日志记录器接口
|
||
type IDecisionLogger interface {
|
||
// LogDecision 记录决策
|
||
LogDecision(record *DecisionRecord) error
|
||
// GetLatestRecords 获取最近N条记录(按时间正序:从旧到新)
|
||
GetLatestRecords(n int) ([]*DecisionRecord, error)
|
||
// GetRecordByDate 获取指定日期的所有记录
|
||
GetRecordByDate(date time.Time) ([]*DecisionRecord, error)
|
||
// CleanOldRecords 清理N天前的旧记录
|
||
CleanOldRecords(days int) error
|
||
// GetStatistics 获取统计信息
|
||
GetStatistics() (*Statistics, error)
|
||
// AnalyzePerformance 分析最近N个周期的交易表现
|
||
AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error)
|
||
// SetCycleNumber 允许恢复内部计数(用于回测恢复)
|
||
SetCycleNumber(n int)
|
||
}
|
||
|
||
// DecisionLogger 决策日志记录器
|
||
type DecisionLogger struct {
|
||
logDir string
|
||
cycleNumber int
|
||
}
|
||
|
||
// NewDecisionLogger 创建决策日志记录器
|
||
func NewDecisionLogger(logDir string) IDecisionLogger {
|
||
if logDir == "" {
|
||
logDir = "decision_logs"
|
||
}
|
||
|
||
// 确保日志目录存在(使用安全权限:只有所有者可访问)
|
||
if err := os.MkdirAll(logDir, 0700); err != nil {
|
||
fmt.Printf("⚠ 创建日志目录失败: %v\n", err)
|
||
}
|
||
|
||
// 强制设置目录权限(即使目录已存在)- 确保安全
|
||
if err := os.Chmod(logDir, 0700); err != nil {
|
||
fmt.Printf("⚠ 设置日志目录权限失败: %v\n", err)
|
||
}
|
||
|
||
return &DecisionLogger{
|
||
logDir: logDir,
|
||
cycleNumber: 0,
|
||
}
|
||
}
|
||
|
||
// SetCycleNumber 允许外部恢复内部的周期计数(用于回测恢复)。
|
||
func (l *DecisionLogger) SetCycleNumber(n int) {
|
||
if n > 0 {
|
||
l.cycleNumber = n
|
||
}
|
||
}
|
||
|
||
// LogDecision 记录决策
|
||
func (l *DecisionLogger) LogDecision(record *DecisionRecord) error {
|
||
l.cycleNumber++
|
||
record.CycleNumber = l.cycleNumber
|
||
if record.Timestamp.IsZero() {
|
||
record.Timestamp = time.Now().UTC()
|
||
} else {
|
||
record.Timestamp = record.Timestamp.UTC()
|
||
}
|
||
|
||
// 生成文件名:decision_YYYYMMDD_HHMMSS_cycleN.json
|
||
filename := fmt.Sprintf("decision_%s_cycle%d.json",
|
||
record.Timestamp.Format("20060102_150405"),
|
||
record.CycleNumber)
|
||
|
||
filepath := filepath.Join(l.logDir, filename)
|
||
|
||
// 序列化为JSON(带缩进,方便阅读)
|
||
data, err := json.MarshalIndent(record, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("序列化决策记录失败: %w", err)
|
||
}
|
||
|
||
// 写入文件(使用安全权限:只有所有者可读写)
|
||
if err := ioutil.WriteFile(filepath, data, 0600); err != nil {
|
||
return fmt.Errorf("写入决策记录失败: %w", err)
|
||
}
|
||
|
||
fmt.Printf("📝 决策记录已保存: %s\n", filename)
|
||
return nil
|
||
}
|
||
|
||
// GetLatestRecords 获取最近N条记录(按时间正序:从旧到新)
|
||
func (l *DecisionLogger) GetLatestRecords(n int) ([]*DecisionRecord, error) {
|
||
files, err := ioutil.ReadDir(l.logDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取日志目录失败: %w", err)
|
||
}
|
||
|
||
// 先按修改时间倒序收集(最新的在前)
|
||
var records []*DecisionRecord
|
||
count := 0
|
||
for i := len(files) - 1; i >= 0 && count < n; i-- {
|
||
file := files[i]
|
||
if file.IsDir() {
|
||
continue
|
||
}
|
||
|
||
filepath := filepath.Join(l.logDir, file.Name())
|
||
data, err := ioutil.ReadFile(filepath)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
var record DecisionRecord
|
||
if err := json.Unmarshal(data, &record); err != nil {
|
||
continue
|
||
}
|
||
|
||
records = append(records, &record)
|
||
count++
|
||
}
|
||
|
||
// 反转数组,让时间从旧到新排列(用于图表显示)
|
||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||
records[i], records[j] = records[j], records[i]
|
||
}
|
||
|
||
return records, nil
|
||
}
|
||
|
||
// GetRecordByDate 获取指定日期的所有记录
|
||
func (l *DecisionLogger) GetRecordByDate(date time.Time) ([]*DecisionRecord, error) {
|
||
dateStr := date.Format("20060102")
|
||
pattern := filepath.Join(l.logDir, fmt.Sprintf("decision_%s_*.json", dateStr))
|
||
|
||
files, err := filepath.Glob(pattern)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查找日志文件失败: %w", err)
|
||
}
|
||
|
||
var records []*DecisionRecord
|
||
for _, filepath := range files {
|
||
data, err := ioutil.ReadFile(filepath)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
var record DecisionRecord
|
||
if err := json.Unmarshal(data, &record); err != nil {
|
||
continue
|
||
}
|
||
|
||
records = append(records, &record)
|
||
}
|
||
|
||
return records, nil
|
||
}
|
||
|
||
// CleanOldRecords 清理N天前的旧记录
|
||
func (l *DecisionLogger) CleanOldRecords(days int) error {
|
||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||
|
||
files, err := ioutil.ReadDir(l.logDir)
|
||
if err != nil {
|
||
return fmt.Errorf("读取日志目录失败: %w", err)
|
||
}
|
||
|
||
removedCount := 0
|
||
for _, file := range files {
|
||
if file.IsDir() {
|
||
continue
|
||
}
|
||
|
||
if file.ModTime().Before(cutoffTime) {
|
||
filepath := filepath.Join(l.logDir, file.Name())
|
||
if err := os.Remove(filepath); err != nil {
|
||
fmt.Printf("⚠ 删除旧记录失败 %s: %v\n", file.Name(), err)
|
||
continue
|
||
}
|
||
removedCount++
|
||
}
|
||
}
|
||
|
||
if removedCount > 0 {
|
||
fmt.Printf("🗑️ 已清理 %d 条旧记录(%d天前)\n", removedCount, days)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetStatistics 获取统计信息
|
||
func (l *DecisionLogger) GetStatistics() (*Statistics, error) {
|
||
files, err := ioutil.ReadDir(l.logDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取日志目录失败: %w", err)
|
||
}
|
||
|
||
stats := &Statistics{}
|
||
|
||
for _, file := range files {
|
||
if file.IsDir() {
|
||
continue
|
||
}
|
||
|
||
filepath := filepath.Join(l.logDir, file.Name())
|
||
data, err := ioutil.ReadFile(filepath)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
var record DecisionRecord
|
||
if err := json.Unmarshal(data, &record); err != nil {
|
||
continue
|
||
}
|
||
|
||
stats.TotalCycles++
|
||
|
||
for _, action := range record.Decisions {
|
||
if action.Success {
|
||
switch action.Action {
|
||
case "open_long", "open_short":
|
||
stats.TotalOpenPositions++
|
||
case "close_long", "close_short", "auto_close_long", "auto_close_short":
|
||
stats.TotalClosePositions++
|
||
// 🔧 BUG FIX:partial_close 不計入 TotalClosePositions,避免重複計數
|
||
// case "partial_close": // 不計數,因為只有完全平倉才算一次
|
||
// update_stop_loss 和 update_take_profit 不計入統計
|
||
}
|
||
}
|
||
}
|
||
|
||
if record.Success {
|
||
stats.SuccessfulCycles++
|
||
} else {
|
||
stats.FailedCycles++
|
||
}
|
||
}
|
||
|
||
return stats, nil
|
||
}
|
||
|
||
// Statistics 统计信息
|
||
type Statistics struct {
|
||
TotalCycles int `json:"total_cycles"`
|
||
SuccessfulCycles int `json:"successful_cycles"`
|
||
FailedCycles int `json:"failed_cycles"`
|
||
TotalOpenPositions int `json:"total_open_positions"`
|
||
TotalClosePositions int `json:"total_close_positions"`
|
||
}
|
||
|
||
// TradeOutcome 单笔交易结果
|
||
type TradeOutcome struct {
|
||
Symbol string `json:"symbol"` // 币种
|
||
Side string `json:"side"` // long/short
|
||
Quantity float64 `json:"quantity"` // 仓位数量
|
||
Leverage int `json:"leverage"` // 杠杆倍数
|
||
OpenPrice float64 `json:"open_price"` // 开仓价
|
||
ClosePrice float64 `json:"close_price"` // 平仓价
|
||
PositionValue float64 `json:"position_value"` // 仓位价值(quantity × openPrice)
|
||
MarginUsed float64 `json:"margin_used"` // 保证金使用(positionValue / leverage)
|
||
PnL float64 `json:"pn_l"` // 盈亏(USDT)
|
||
PnLPct float64 `json:"pn_l_pct"` // 盈亏百分比(相对保证金)
|
||
Duration string `json:"duration"` // 持仓时长
|
||
OpenTime time.Time `json:"open_time"` // 开仓时间
|
||
CloseTime time.Time `json:"close_time"` // 平仓时间
|
||
WasStopLoss bool `json:"was_stop_loss"` // 是否止损
|
||
}
|
||
|
||
// PerformanceAnalysis 交易表现分析
|
||
type PerformanceAnalysis struct {
|
||
TotalTrades int `json:"total_trades"` // 总交易数
|
||
WinningTrades int `json:"winning_trades"` // 盈利交易数
|
||
LosingTrades int `json:"losing_trades"` // 亏损交易数
|
||
WinRate float64 `json:"win_rate"` // 胜率
|
||
AvgWin float64 `json:"avg_win"` // 平均盈利
|
||
AvgLoss float64 `json:"avg_loss"` // 平均亏损
|
||
ProfitFactor float64 `json:"profit_factor"` // 盈亏比
|
||
SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比率(风险调整后收益)
|
||
RecentTrades []TradeOutcome `json:"recent_trades"` // 最近N笔交易
|
||
SymbolStats map[string]*SymbolPerformance `json:"symbol_stats"` // 各币种表现
|
||
BestSymbol string `json:"best_symbol"` // 表现最好的币种
|
||
WorstSymbol string `json:"worst_symbol"` // 表现最差的币种
|
||
}
|
||
|
||
// SymbolPerformance 币种表现统计
|
||
type SymbolPerformance struct {
|
||
Symbol string `json:"symbol"` // 币种
|
||
TotalTrades int `json:"total_trades"` // 交易次数
|
||
WinningTrades int `json:"winning_trades"` // 盈利次数
|
||
LosingTrades int `json:"losing_trades"` // 亏损次数
|
||
WinRate float64 `json:"win_rate"` // 胜率
|
||
TotalPnL float64 `json:"total_pn_l"` // 总盈亏
|
||
AvgPnL float64 `json:"avg_pn_l"` // 平均盈亏
|
||
}
|
||
|
||
// AnalyzePerformance 分析最近N个周期的交易表现
|
||
func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error) {
|
||
records, err := l.GetLatestRecords(lookbackCycles)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取历史记录失败: %w", err)
|
||
}
|
||
|
||
if len(records) == 0 {
|
||
return &PerformanceAnalysis{
|
||
RecentTrades: []TradeOutcome{},
|
||
SymbolStats: make(map[string]*SymbolPerformance),
|
||
}, nil
|
||
}
|
||
|
||
analysis := &PerformanceAnalysis{
|
||
RecentTrades: []TradeOutcome{},
|
||
SymbolStats: make(map[string]*SymbolPerformance),
|
||
}
|
||
|
||
// 追踪持仓状态:symbol_side -> {side, openPrice, openTime, quantity, leverage}
|
||
openPositions := make(map[string]map[string]interface{})
|
||
|
||
// 为了避免开仓记录在窗口外导致匹配失败,需要先从所有历史记录中找出未平仓的持仓
|
||
// 获取更多历史记录来构建完整的持仓状态(使用更大的窗口)
|
||
allRecords, err := l.GetLatestRecords(lookbackCycles * 3) // 扩大3倍窗口
|
||
if err == nil && len(allRecords) > len(records) {
|
||
// 先从扩大的窗口中收集所有开仓记录
|
||
for _, record := range allRecords {
|
||
for _, action := range record.Decisions {
|
||
if !action.Success {
|
||
continue
|
||
}
|
||
|
||
symbol := action.Symbol
|
||
side := ""
|
||
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" || 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 {
|
||
case "open_long", "open_short":
|
||
// 记录开仓
|
||
openPositions[posKey] = map[string]interface{}{
|
||
"side": side,
|
||
"openPrice": action.Price,
|
||
"openTime": action.Timestamp,
|
||
"quantity": action.Quantity,
|
||
"leverage": action.Leverage,
|
||
}
|
||
case "close_long", "close_short", "auto_close_long", "auto_close_short":
|
||
// 移除已平仓记录
|
||
delete(openPositions, posKey)
|
||
// partial_close 不處理,保留持倉記錄
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 遍历分析窗口内的记录,生成交易结果
|
||
for _, record := range records {
|
||
for _, action := range record.Decisions {
|
||
if !action.Success {
|
||
continue
|
||
}
|
||
|
||
symbol := action.Symbol
|
||
side := ""
|
||
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" || 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,
|
||
"remainingQuantity": action.Quantity, // 🔧 BUG FIX:追蹤剩餘數量
|
||
"accumulatedPnL": 0.0, // 🔧 BUG FIX:累積部分平倉盈虧
|
||
"partialCloseCount": 0, // 🔧 BUG FIX:部分平倉次數
|
||
"partialCloseVolume": 0.0, // 🔧 BUG FIX:部分平倉總量
|
||
}
|
||
|
||
case "close_long", "close_short", "partial_close", "auto_close_long", "auto_close_short":
|
||
// 查找对应的开仓记录(可能来自预填充或当前窗口)
|
||
if openPos, exists := openPositions[posKey]; exists {
|
||
openPrice := openPos["openPrice"].(float64)
|
||
openTime := openPos["openTime"].(time.Time)
|
||
side := openPos["side"].(string)
|
||
quantity := openPos["quantity"].(float64)
|
||
leverage := openPos["leverage"].(int)
|
||
|
||
// 🔧 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 = actualQuantity * (action.Price - openPrice)
|
||
} else {
|
||
pnl = actualQuantity * (openPrice - action.Price)
|
||
}
|
||
|
||
// 🔧 BUG FIX:處理 partial_close 聚合邏輯
|
||
if action.Action == "partial_close" {
|
||
// 累積盈虧和數量
|
||
accumulatedPnL += pnl
|
||
remainingQty -= actualQuantity
|
||
partialCloseCount++
|
||
partialCloseVolume += actualQuantity
|
||
|
||
// 更新 openPositions(保留持倉記錄,但更新追蹤數據)
|
||
openPos["remainingQuantity"] = remainingQty
|
||
openPos["accumulatedPnL"] = accumulatedPnL
|
||
openPos["partialCloseCount"] = partialCloseCount
|
||
openPos["partialCloseVolume"] = partialCloseVolume
|
||
|
||
// 判斷是否已完全平倉
|
||
if remainingQty <= 0.0001 { // 使用小閾值避免浮點誤差
|
||
// ✅ 完全平倉:記錄為一筆完整交易
|
||
positionValue := quantity * openPrice
|
||
marginUsed := positionValue / float64(leverage)
|
||
pnlPct := 0.0
|
||
if marginUsed > 0 {
|
||
pnlPct = (accumulatedPnL / marginUsed) * 100
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
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)
|
||
}
|
||
// ⚠️ 否則不做任何操作(等待後續 partial_close 或 full close)
|
||
|
||
} 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 计算统计指标
|
||
if analysis.TotalTrades > 0 {
|
||
analysis.WinRate = (float64(analysis.WinningTrades) / float64(analysis.TotalTrades)) * 100
|
||
|
||
// 计算总盈利和总亏损
|
||
totalWinAmount := analysis.AvgWin // 当前是累加的总和
|
||
totalLossAmount := analysis.AvgLoss // 当前是累加的总和(负数)
|
||
|
||
if analysis.WinningTrades > 0 {
|
||
analysis.AvgWin /= float64(analysis.WinningTrades)
|
||
}
|
||
if analysis.LosingTrades > 0 {
|
||
analysis.AvgLoss /= float64(analysis.LosingTrades)
|
||
}
|
||
|
||
// Profit Factor = 总盈利 / 总亏损(绝对值)
|
||
// 注意:totalLossAmount 是负数,所以取负号得到绝对值
|
||
if totalLossAmount != 0 {
|
||
analysis.ProfitFactor = totalWinAmount / (-totalLossAmount)
|
||
} else if totalWinAmount > 0 {
|
||
// 只有盈利没有亏损的情况,设置为一个很大的值表示完美策略
|
||
analysis.ProfitFactor = 999.0
|
||
}
|
||
}
|
||
|
||
// 计算各币种胜率和平均盈亏
|
||
bestPnL := -999999.0
|
||
worstPnL := 999999.0
|
||
for symbol, stats := range analysis.SymbolStats {
|
||
if stats.TotalTrades > 0 {
|
||
stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100
|
||
stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades)
|
||
|
||
if stats.TotalPnL > bestPnL {
|
||
bestPnL = stats.TotalPnL
|
||
analysis.BestSymbol = symbol
|
||
}
|
||
if stats.TotalPnL < worstPnL {
|
||
worstPnL = stats.TotalPnL
|
||
analysis.WorstSymbol = symbol
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只保留最近的交易(倒序:最新的在前)
|
||
if len(analysis.RecentTrades) > 10 {
|
||
// 反转数组,让最新的在前
|
||
for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 {
|
||
analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i]
|
||
}
|
||
analysis.RecentTrades = analysis.RecentTrades[:10]
|
||
} else if len(analysis.RecentTrades) > 0 {
|
||
// 反转数组
|
||
for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 {
|
||
analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i]
|
||
}
|
||
}
|
||
|
||
// 计算夏普比率(需要至少2个数据点)
|
||
analysis.SharpeRatio = l.calculateSharpeRatio(records)
|
||
|
||
return analysis, nil
|
||
}
|
||
|
||
// calculateSharpeRatio 计算夏普比率
|
||
// 基于账户净值的变化计算风险调整后收益
|
||
func (l *DecisionLogger) calculateSharpeRatio(records []*DecisionRecord) float64 {
|
||
if len(records) < 2 {
|
||
return 0.0
|
||
}
|
||
|
||
// 提取每个周期的账户净值
|
||
// 注意:TotalBalance字段实际存储的是TotalEquity(账户总净值)
|
||
// TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额的盈亏)
|
||
var equities []float64
|
||
for _, record := range records {
|
||
// 直接使用TotalBalance,因为它已经是完整的账户净值
|
||
equity := record.AccountState.TotalBalance
|
||
if equity > 0 {
|
||
equities = append(equities, equity)
|
||
}
|
||
}
|
||
|
||
if len(equities) < 2 {
|
||
return 0.0
|
||
}
|
||
|
||
// 计算周期收益率(period returns)
|
||
var returns []float64
|
||
for i := 1; i < len(equities); i++ {
|
||
if equities[i-1] > 0 {
|
||
periodReturn := (equities[i] - equities[i-1]) / equities[i-1]
|
||
returns = append(returns, periodReturn)
|
||
}
|
||
}
|
||
|
||
if len(returns) == 0 {
|
||
return 0.0
|
||
}
|
||
|
||
// 计算平均收益率
|
||
sumReturns := 0.0
|
||
for _, r := range returns {
|
||
sumReturns += r
|
||
}
|
||
meanReturn := sumReturns / float64(len(returns))
|
||
|
||
// 计算收益率标准差
|
||
sumSquaredDiff := 0.0
|
||
for _, r := range returns {
|
||
diff := r - meanReturn
|
||
sumSquaredDiff += diff * diff
|
||
}
|
||
variance := sumSquaredDiff / float64(len(returns))
|
||
stdDev := math.Sqrt(variance)
|
||
|
||
// 避免除以零
|
||
if stdDev == 0 {
|
||
if meanReturn > 0 {
|
||
return 999.0 // 无波动的正收益
|
||
} else if meanReturn < 0 {
|
||
return -999.0 // 无波动的负收益
|
||
}
|
||
return 0.0
|
||
}
|
||
|
||
// 计算夏普比率(假设无风险利率为0)
|
||
// 注:直接返回周期级别的夏普比率(非年化),正常范围 -2 到 +2
|
||
sharpeRatio := meanReturn / stdDev
|
||
return sharpeRatio
|
||
}
|