Files
nofx/store/position_history.go
tinkle-community cb31782be4 refactor: split large files and clean up project structure
- 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)
2026-03-12 12:53:57 +08:00

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
}