mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
cb31782be4
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
309 lines
8.1 KiB
Go
309 lines
8.1 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// HistorySummary comprehensive trading history for AI context
|
|
type HistorySummary struct {
|
|
TotalTrades int `json:"total_trades"`
|
|
WinRate float64 `json:"win_rate"`
|
|
TotalPnL float64 `json:"total_pnl"`
|
|
AvgTradeReturn float64 `json:"avg_trade_return"`
|
|
|
|
BestSymbols []SymbolStats `json:"best_symbols"`
|
|
WorstSymbols []SymbolStats `json:"worst_symbols"`
|
|
|
|
LongWinRate float64 `json:"long_win_rate"`
|
|
ShortWinRate float64 `json:"short_win_rate"`
|
|
LongPnL float64 `json:"long_pnl"`
|
|
ShortPnL float64 `json:"short_pnl"`
|
|
|
|
AvgHoldingMins float64 `json:"avg_holding_mins"`
|
|
BestHoldRange string `json:"best_hold_range"`
|
|
|
|
RecentWinRate float64 `json:"recent_win_rate"`
|
|
RecentPnL float64 `json:"recent_pnl"`
|
|
|
|
CurrentStreak int `json:"current_streak"`
|
|
MaxWinStreak int `json:"max_win_streak"`
|
|
MaxLoseStreak int `json:"max_lose_streak"`
|
|
}
|
|
|
|
// GetHistorySummary generates comprehensive AI context summary
|
|
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
|
|
summary := &HistorySummary{}
|
|
|
|
fullStats, err := s.GetFullStats(traderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary.TotalTrades = fullStats.TotalTrades
|
|
summary.WinRate = fullStats.WinRate
|
|
summary.TotalPnL = fullStats.TotalPnL
|
|
if fullStats.TotalTrades > 0 {
|
|
summary.AvgTradeReturn = fullStats.TotalPnL / float64(fullStats.TotalTrades)
|
|
}
|
|
|
|
symbolStats, _ := s.GetSymbolStats(traderID, 20)
|
|
if len(symbolStats) > 0 {
|
|
for i := 0; i < len(symbolStats) && i < 3; i++ {
|
|
if symbolStats[i].TotalPnL > 0 {
|
|
summary.BestSymbols = append(summary.BestSymbols, symbolStats[i])
|
|
}
|
|
}
|
|
for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- {
|
|
if symbolStats[i].TotalPnL < 0 {
|
|
summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
dirStats, _ := s.GetDirectionStats(traderID)
|
|
for _, d := range dirStats {
|
|
if d.Side == "LONG" {
|
|
summary.LongWinRate = d.WinRate
|
|
summary.LongPnL = d.TotalPnL
|
|
} else if d.Side == "SHORT" {
|
|
summary.ShortWinRate = d.WinRate
|
|
summary.ShortPnL = d.TotalPnL
|
|
}
|
|
}
|
|
|
|
holdStats, _ := s.GetHoldingTimeStats(traderID)
|
|
var bestHoldWinRate float64
|
|
for _, h := range holdStats {
|
|
if h.WinRate > bestHoldWinRate && h.TradeCount >= 3 {
|
|
bestHoldWinRate = h.WinRate
|
|
summary.BestHoldRange = h.Range
|
|
}
|
|
}
|
|
|
|
// Calculate average holding time
|
|
var positions []TraderPosition
|
|
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
|
|
if len(positions) > 0 {
|
|
var totalMins float64
|
|
for _, pos := range positions {
|
|
if pos.ExitTime > 0 {
|
|
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
|
}
|
|
}
|
|
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
|
}
|
|
|
|
// Recent 20 trades
|
|
var recent []TraderPosition
|
|
s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
|
|
Order("exit_time DESC").Limit(20).Find(&recent)
|
|
for _, pos := range recent {
|
|
summary.RecentPnL += pos.RealizedPnL
|
|
if pos.RealizedPnL > 0 {
|
|
summary.RecentWinRate++
|
|
}
|
|
}
|
|
if len(recent) > 0 {
|
|
summary.RecentWinRate = summary.RecentWinRate / float64(len(recent)) * 100
|
|
}
|
|
|
|
// Calculate streaks
|
|
s.calculateStreaks(traderID, summary)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// calculateStreaks calculates win/loss streaks
|
|
func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) {
|
|
var positions []TraderPosition
|
|
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
|
|
Order("exit_time DESC").
|
|
Find(&positions).Error
|
|
if err != nil || len(positions) == 0 {
|
|
return
|
|
}
|
|
|
|
var currentStreak, maxWin, maxLose int
|
|
var prevWin *bool
|
|
isFirst := true
|
|
|
|
for _, pos := range positions {
|
|
isWin := pos.RealizedPnL > 0
|
|
|
|
if isFirst {
|
|
if isWin {
|
|
currentStreak = 1
|
|
} else {
|
|
currentStreak = -1
|
|
}
|
|
isFirst = false
|
|
}
|
|
|
|
if prevWin == nil {
|
|
prevWin = &isWin
|
|
} else if *prevWin == isWin {
|
|
if isWin {
|
|
currentStreak++
|
|
if currentStreak > maxWin {
|
|
maxWin = currentStreak
|
|
}
|
|
} else {
|
|
currentStreak--
|
|
if -currentStreak > maxLose {
|
|
maxLose = -currentStreak
|
|
}
|
|
}
|
|
} else {
|
|
if isWin {
|
|
currentStreak = 1
|
|
} else {
|
|
currentStreak = -1
|
|
}
|
|
*prevWin = isWin
|
|
}
|
|
}
|
|
|
|
summary.CurrentStreak = currentStreak
|
|
summary.MaxWinStreak = maxWin
|
|
summary.MaxLoseStreak = maxLose
|
|
}
|
|
|
|
// ClosedPnLRecord represents a closed position record from exchange
|
|
// All time fields use int64 millisecond timestamps (UTC)
|
|
type ClosedPnLRecord struct {
|
|
Symbol string
|
|
Side string
|
|
EntryPrice float64
|
|
ExitPrice float64
|
|
Quantity float64
|
|
RealizedPnL float64
|
|
Fee float64
|
|
Leverage int
|
|
EntryTime int64 // Unix milliseconds UTC
|
|
ExitTime int64 // Unix milliseconds UTC
|
|
OrderID string
|
|
CloseType string
|
|
ExchangeID string
|
|
}
|
|
|
|
// CreateFromClosedPnL creates a closed position record from exchange data
|
|
func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType string, record *ClosedPnLRecord) (bool, error) {
|
|
if record.Symbol == "" {
|
|
return false, nil
|
|
}
|
|
|
|
side := strings.ToUpper(record.Side)
|
|
if side == "LONG" || side == "BUY" {
|
|
side = "LONG"
|
|
} else if side == "SHORT" || side == "SELL" {
|
|
side = "SHORT"
|
|
} else {
|
|
return false, nil
|
|
}
|
|
|
|
if record.Quantity <= 0 || record.ExitPrice <= 0 || record.EntryPrice <= 0 {
|
|
return false, nil
|
|
}
|
|
|
|
exchangePositionID := record.ExchangeID
|
|
if exchangePositionID == "" {
|
|
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
|
|
}
|
|
|
|
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if exists {
|
|
return false, nil
|
|
}
|
|
|
|
exitTimeMs := record.ExitTime
|
|
entryTimeMs := record.EntryTime
|
|
|
|
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
|
|
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
|
|
if exitTimeMs < minValidTime {
|
|
return false, nil
|
|
}
|
|
if entryTimeMs < minValidTime {
|
|
entryTimeMs = exitTimeMs
|
|
}
|
|
if entryTimeMs > exitTimeMs {
|
|
entryTimeMs = exitTimeMs
|
|
}
|
|
|
|
nowMs := time.Now().UTC().UnixMilli()
|
|
pos := &TraderPosition{
|
|
TraderID: traderID,
|
|
ExchangeID: exchangeID,
|
|
ExchangeType: exchangeType,
|
|
ExchangePositionID: exchangePositionID,
|
|
Symbol: record.Symbol,
|
|
Side: side,
|
|
Quantity: record.Quantity,
|
|
EntryQuantity: record.Quantity,
|
|
EntryPrice: record.EntryPrice,
|
|
EntryTime: entryTimeMs,
|
|
ExitPrice: record.ExitPrice,
|
|
ExitOrderID: record.OrderID,
|
|
ExitTime: exitTimeMs,
|
|
RealizedPnL: record.RealizedPnL,
|
|
Fee: record.Fee,
|
|
Leverage: record.Leverage,
|
|
Status: "CLOSED",
|
|
CloseReason: record.CloseType,
|
|
Source: "sync",
|
|
CreatedAt: nowMs,
|
|
UpdatedAt: nowMs,
|
|
}
|
|
|
|
err = s.db.Create(pos).Error
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("failed to create position from closed PnL: %w", err)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
|
|
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
|
|
var pos TraderPosition
|
|
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
|
|
Order("exit_time DESC").
|
|
First(&pos).Error
|
|
|
|
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
|
|
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
|
|
}
|
|
|
|
return pos.ExitTime, nil
|
|
}
|
|
|
|
// SyncClosedPositions syncs closed positions from exchange
|
|
func (s *PositionStore) SyncClosedPositions(traderID, exchangeID, exchangeType string, records []ClosedPnLRecord) (int, int, error) {
|
|
created, skipped := 0, 0
|
|
for _, record := range records {
|
|
rec := record
|
|
wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, exchangeType, &rec)
|
|
if err != nil {
|
|
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
|
|
}
|
|
if wasCreated {
|
|
created++
|
|
} else {
|
|
skipped++
|
|
}
|
|
}
|
|
return created, skipped, nil
|
|
}
|