mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 16:26:57 +08:00
fix: initial balance calculation and UI improvements
- Fix initial balance using available_balance instead of total_equity - Fix WSMonitor nil pointer by starting market monitor before loading traders - Add strategy name display on traders list and dashboard pages - Various position sync and trading improvements
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -24,25 +25,27 @@ type TraderStats struct {
|
||||
|
||||
// TraderPosition position record (complete open/close position tracking)
|
||||
type TraderPosition struct {
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // LONG/SHORT
|
||||
Quantity float64 `json:"quantity"` // Opening quantity
|
||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||
Fee float64 `json:"fee"` // Fee
|
||||
Leverage int `json:"leverage"` // Leverage multiplier
|
||||
Status string `json:"status"` // OPEN/CLOSED
|
||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
||||
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // LONG/SHORT
|
||||
Quantity float64 `json:"quantity"` // Opening quantity
|
||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||
Fee float64 `json:"fee"` // Fee
|
||||
Leverage int `json:"leverage"` // Leverage multiplier
|
||||
Status string `json:"status"` // OPEN/CLOSED
|
||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||
Source string `json:"source"` // Source: system/manual/sync
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PositionStore position storage
|
||||
@@ -62,6 +65,7 @@ func (s *PositionStore) InitTables() error {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trader_id TEXT NOT NULL,
|
||||
exchange_id TEXT NOT NULL DEFAULT '',
|
||||
exchange_position_id TEXT NOT NULL DEFAULT '',
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
@@ -76,6 +80,7 @@ func (s *PositionStore) InitTables() error {
|
||||
leverage INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'OPEN',
|
||||
close_reason TEXT DEFAULT '',
|
||||
source TEXT DEFAULT 'system',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -87,6 +92,10 @@ func (s *PositionStore) InitTables() error {
|
||||
// Migration: add exchange_id column to existing table (if not exists)
|
||||
// Must be executed before creating indexes!
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
|
||||
// Migration: add exchange_position_id for deduplication
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`)
|
||||
// Migration: add source field (system/manual/sync)
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN source TEXT DEFAULT 'system'`)
|
||||
|
||||
// Create indexes (after migration)
|
||||
indices := []string{
|
||||
@@ -96,10 +105,14 @@ func (s *PositionStore) InitTables() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_unique ON trader_positions(trader_id, exchange_position_id) WHERE exchange_position_id != ''`,
|
||||
}
|
||||
for _, idx := range indices {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
// Ignore unique index creation errors for existing data
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,19 +355,21 @@ func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
|
||||
// RecentTrade recent trade record (for AI input)
|
||||
type RecentTrade struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // long/short
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
ExitPrice float64 `json:"exit_price"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
ExitTime string `json:"exit_time"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // long/short
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
ExitPrice float64 `json:"exit_price"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
EntryTime string `json:"entry_time"` // Entry time (开仓时间)
|
||||
ExitTime string `json:"exit_time"` // Exit time (平仓时间)
|
||||
HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m"
|
||||
}
|
||||
|
||||
// GetRecentTrades gets recent closed trades
|
||||
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, exit_time
|
||||
SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, entry_time, exit_time
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
@@ -369,9 +384,9 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
for rows.Next() {
|
||||
var t RecentTrade
|
||||
var leverage int
|
||||
var exitTime sql.NullString
|
||||
var entryTime, exitTime sql.NullString
|
||||
|
||||
err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &exitTime)
|
||||
err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &entryTime, &exitTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -392,19 +407,58 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
}
|
||||
}
|
||||
|
||||
// Format time
|
||||
// Format entry time and exit time (always use UTC and indicate it)
|
||||
var parsedEntryTime, parsedExitTime time.Time
|
||||
if entryTime.Valid {
|
||||
if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil {
|
||||
parsedEntryTime = parsed.UTC()
|
||||
t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC")
|
||||
}
|
||||
}
|
||||
if exitTime.Valid {
|
||||
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
||||
t.ExitTime = parsed.Format("01-02 15:04")
|
||||
parsedExitTime = parsed.UTC()
|
||||
t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC")
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate hold duration
|
||||
if !parsedEntryTime.IsZero() && !parsedExitTime.IsZero() {
|
||||
duration := parsedExitTime.Sub(parsedEntryTime)
|
||||
t.HoldDuration = formatDuration(duration)
|
||||
}
|
||||
|
||||
trades = append(trades, t)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a human-readable string
|
||||
// e.g. "2d3h", "5h30m", "45m", "30s"
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
if hours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd%dh", days, hours)
|
||||
}
|
||||
|
||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
||||
if len(pnls) < 2 {
|
||||
@@ -493,3 +547,532 @@ func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitT
|
||||
pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
}
|
||||
|
||||
// SymbolStats per-symbol trading statistics
|
||||
type SymbolStats struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinTrades int `json:"win_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
AvgHoldMins float64 `json:"avg_hold_mins"` // Average holding time in minutes
|
||||
}
|
||||
|
||||
// GetSymbolStats gets per-symbol trading statistics
|
||||
func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
symbol,
|
||||
COUNT(*) as total_trades,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as win_trades,
|
||||
COALESCE(SUM(realized_pnl), 0) as total_pnl,
|
||||
COALESCE(AVG(realized_pnl), 0) as avg_pnl,
|
||||
COALESCE(AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60), 0) as avg_hold_mins
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
GROUP BY symbol
|
||||
ORDER BY total_pnl DESC
|
||||
LIMIT ?
|
||||
`, traderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query symbol stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []SymbolStats
|
||||
for rows.Next() {
|
||||
var s SymbolStats
|
||||
err := rows.Scan(&s.Symbol, &s.TotalTrades, &s.WinTrades, &s.TotalPnL, &s.AvgPnL, &s.AvgHoldMins)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s.TotalTrades > 0 {
|
||||
s.WinRate = float64(s.WinTrades) / float64(s.TotalTrades) * 100
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// HoldingTimeStats holding duration analysis
|
||||
type HoldingTimeStats struct {
|
||||
Range string `json:"range"` // e.g., "<1h", "1-4h", "4-24h", ">24h"
|
||||
TradeCount int `json:"trade_count"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
}
|
||||
|
||||
// GetHoldingTimeStats analyzes performance by holding duration
|
||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
WITH holding AS (
|
||||
SELECT
|
||||
realized_pnl,
|
||||
(julianday(exit_time) - julianday(entry_time)) * 24 as hold_hours
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN hold_hours < 1 THEN '<1h'
|
||||
WHEN hold_hours < 4 THEN '1-4h'
|
||||
WHEN hold_hours < 24 THEN '4-24h'
|
||||
ELSE '>24h'
|
||||
END as time_range,
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate,
|
||||
AVG(realized_pnl) as avg_pnl
|
||||
FROM holding
|
||||
GROUP BY time_range
|
||||
ORDER BY
|
||||
CASE time_range
|
||||
WHEN '<1h' THEN 1
|
||||
WHEN '1-4h' THEN 2
|
||||
WHEN '4-24h' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []HoldingTimeStats
|
||||
for rows.Next() {
|
||||
var s HoldingTimeStats
|
||||
err := rows.Scan(&s.Range, &s.TradeCount, &s.WinRate, &s.AvgPnL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// DirectionStats long/short performance comparison
|
||||
type DirectionStats struct {
|
||||
Side string `json:"side"`
|
||||
TradeCount int `json:"trade_count"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
}
|
||||
|
||||
// GetDirectionStats analyzes long vs short performance
|
||||
func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
side,
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate,
|
||||
COALESCE(SUM(realized_pnl), 0) as total_pnl,
|
||||
COALESCE(AVG(realized_pnl), 0) as avg_pnl
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
GROUP BY side
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query direction stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []DirectionStats
|
||||
for rows.Next() {
|
||||
var s DirectionStats
|
||||
err := rows.Scan(&s.Side, &s.TradeCount, &s.WinRate, &s.TotalPnL, &s.AvgPnL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// HistorySummary comprehensive trading history for AI context
|
||||
type HistorySummary struct {
|
||||
// Overall stats
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgTradeReturn float64 `json:"avg_trade_return"` // Percentage
|
||||
|
||||
// Best/Worst performers
|
||||
BestSymbols []SymbolStats `json:"best_symbols"` // Top 3 profitable
|
||||
WorstSymbols []SymbolStats `json:"worst_symbols"` // Top 3 losing
|
||||
|
||||
// Direction analysis
|
||||
LongWinRate float64 `json:"long_win_rate"`
|
||||
ShortWinRate float64 `json:"short_win_rate"`
|
||||
LongPnL float64 `json:"long_pnl"`
|
||||
ShortPnL float64 `json:"short_pnl"`
|
||||
|
||||
// Time analysis
|
||||
AvgHoldingMins float64 `json:"avg_holding_mins"`
|
||||
BestHoldRange string `json:"best_hold_range"` // e.g., "1-4h"
|
||||
|
||||
// Recent performance (last 20 trades)
|
||||
RecentWinRate float64 `json:"recent_win_rate"`
|
||||
RecentPnL float64 `json:"recent_pnl"`
|
||||
|
||||
// Streak info
|
||||
CurrentStreak int `json:"current_streak"` // Positive = wins, negative = losses
|
||||
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{}
|
||||
|
||||
// Get overall stats
|
||||
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)
|
||||
}
|
||||
|
||||
// Get symbol stats - best performers
|
||||
symbolStats, _ := s.GetSymbolStats(traderID, 20)
|
||||
if len(symbolStats) > 0 {
|
||||
// Best 3
|
||||
for i := 0; i < len(symbolStats) && i < 3; i++ {
|
||||
if symbolStats[i].TotalPnL > 0 {
|
||||
summary.BestSymbols = append(summary.BestSymbols, symbolStats[i])
|
||||
}
|
||||
}
|
||||
// Worst 3 (from the end)
|
||||
for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- {
|
||||
if symbolStats[i].TotalPnL < 0 {
|
||||
summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get direction stats
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Get holding time stats
|
||||
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 avgHold sql.NullFloat64
|
||||
s.db.QueryRow(`
|
||||
SELECT AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60)
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
`, traderID).Scan(&avgHold)
|
||||
if avgHold.Valid {
|
||||
summary.AvgHoldingMins = avgHold.Float64
|
||||
}
|
||||
|
||||
// Get recent 20 trades performance
|
||||
var recentWins int
|
||||
var recentTotal int
|
||||
var recentPnL float64
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC LIMIT 20
|
||||
`, traderID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pnl float64
|
||||
rows.Scan(&pnl)
|
||||
recentTotal++
|
||||
recentPnL += pnl
|
||||
if pnl > 0 {
|
||||
recentWins++
|
||||
}
|
||||
}
|
||||
}
|
||||
if recentTotal > 0 {
|
||||
summary.RecentWinRate = float64(recentWins) / float64(recentTotal) * 100
|
||||
summary.RecentPnL = recentPnL
|
||||
}
|
||||
|
||||
// Calculate streaks
|
||||
s.calculateStreaks(traderID, summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// calculateStreaks calculates win/loss streaks
|
||||
func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var currentStreak, maxWin, maxLose int
|
||||
var prevWin *bool
|
||||
isFirst := true
|
||||
|
||||
for rows.Next() {
|
||||
var pnl float64
|
||||
rows.Scan(&pnl)
|
||||
isWin := pnl > 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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deduplication and Sync Methods
|
||||
// =============================================================================
|
||||
|
||||
// ExistsWithExchangePositionID checks if a position with the given exchange position ID already exists
|
||||
func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionID string) (bool, error) {
|
||||
if exchangePositionID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM trader_positions
|
||||
WHERE trader_id = ? AND exchange_position_id = ?
|
||||
`, traderID, exchangePositionID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check position existence: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CreateFromClosedPnL creates a closed position record from exchange closed PnL data
|
||||
// This is used for syncing historical positions from exchange
|
||||
// Returns true if created, false if already exists (deduped)
|
||||
func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record *ClosedPnLRecord) (bool, error) {
|
||||
// Generate unique exchange position ID from record data
|
||||
exchangePositionID := record.ExchangeID
|
||||
if exchangePositionID == "" {
|
||||
// Fallback: generate from order ID + exit time
|
||||
exchangePositionID = fmt.Sprintf("%s_%d", record.OrderID, record.ExitTime.UnixMilli())
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
exists, err := s.ExistsWithExchangePositionID(traderID, exchangePositionID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil // Already exists, skip
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
side := strings.ToUpper(record.Side)
|
||||
if side == "LONG" || side == "BUY" {
|
||||
side = "LONG"
|
||||
} else {
|
||||
side = "SHORT"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
exitTime := record.ExitTime
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
|
||||
entry_price, entry_order_id, entry_time,
|
||||
exit_price, exit_order_id, exit_time,
|
||||
realized_pnl, fee, leverage, status, close_reason, source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?)
|
||||
`,
|
||||
traderID, exchangeID, exchangePositionID, record.Symbol, side, record.Quantity,
|
||||
record.EntryPrice, "", record.EntryTime.Format(time.RFC3339),
|
||||
record.ExitPrice, record.OrderID, exitTime.Format(time.RFC3339),
|
||||
record.RealizedPnL, record.Fee, record.Leverage, record.CloseType,
|
||||
now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
// Could be duplicate key error, treat as already exists
|
||||
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
|
||||
}
|
||||
|
||||
// ClosedPnLRecord represents a closed position record from exchange (duplicated here for store package)
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string
|
||||
Side string
|
||||
EntryPrice float64
|
||||
ExitPrice float64
|
||||
Quantity float64
|
||||
RealizedPnL float64
|
||||
Fee float64
|
||||
Leverage int
|
||||
EntryTime time.Time
|
||||
ExitTime time.Time
|
||||
OrderID string
|
||||
CloseType string
|
||||
ExchangeID string
|
||||
}
|
||||
|
||||
// GetLastClosedPositionTime gets the most recent exit time from closed positions
|
||||
// This is used to determine the start time for syncing new closed positions
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
||||
var exitTime sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT exit_time FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
ORDER BY exit_time DESC LIMIT 1
|
||||
`, traderID).Scan(&exitTime)
|
||||
|
||||
if err == sql.ErrNoRows || !exitTime.Valid {
|
||||
// No closed positions, return 30 days ago as default
|
||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339, exitTime.String)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateOpenPosition creates an open position record with exchange position ID
|
||||
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
// Check if already exists by exchange position ID
|
||||
if pos.ExchangePositionID != "" {
|
||||
exists, err := s.ExistsWithExchangePositionID(pos.TraderID, pos.ExchangePositionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil // Already exists, skip
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
pos.CreatedAt = now
|
||||
pos.UpdatedAt = now
|
||||
pos.Status = "OPEN"
|
||||
if pos.Source == "" {
|
||||
pos.Source = "system"
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
|
||||
entry_price, entry_order_id, entry_time, leverage, status, source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
pos.TraderID, pos.ExchangeID, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity,
|
||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||
pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil // Already exists
|
||||
}
|
||||
return fmt.Errorf("failed to create open position: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
pos.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosePositionWithAccurateData closes a position with accurate data from exchange
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
exit_price = ?, exit_order_id = ?, exit_time = ?,
|
||||
realized_pnl = ?, fee = ?, status = 'CLOSED',
|
||||
close_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
exitPrice, exitOrderID, exitTime.Format(time.RFC3339),
|
||||
realizedPnL, fee, closeReason, now.Format(time.RFC3339), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close position with accurate data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncClosedPositions syncs closed positions from exchange to local database
|
||||
// Returns (created count, skipped count, error)
|
||||
func (s *PositionStore) SyncClosedPositions(traderID, exchangeID string, records []ClosedPnLRecord) (int, int, error) {
|
||||
created, skipped := 0, 0
|
||||
for _, record := range records {
|
||||
rec := record // Create local copy to avoid closure issues
|
||||
wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, &rec)
|
||||
if err != nil {
|
||||
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
|
||||
}
|
||||
if wasCreated {
|
||||
created++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
return created, skipped, nil
|
||||
}
|
||||
|
||||
@@ -128,22 +128,46 @@ type ExternalDataSource struct {
|
||||
}
|
||||
|
||||
// RiskControlConfig risk control configuration
|
||||
// All parameters are clearly defined without ambiguity:
|
||||
//
|
||||
// Position Limits:
|
||||
// - MaxPositions: max number of coins held simultaneously (CODE ENFORCED)
|
||||
//
|
||||
// Trading Leverage (exchange leverage for opening positions):
|
||||
// - BTCETHMaxLeverage: BTC/ETH max exchange leverage (AI guided)
|
||||
// - AltcoinMaxLeverage: Altcoin max exchange leverage (AI guided)
|
||||
//
|
||||
// Position Value Limits (single position notional value / account equity):
|
||||
// - BTCETHMaxPositionValueRatio: BTC/ETH max = equity × ratio (CODE ENFORCED)
|
||||
// - AltcoinMaxPositionValueRatio: Altcoin max = equity × ratio (CODE ENFORCED)
|
||||
//
|
||||
// Risk Controls:
|
||||
// - MaxMarginUsage: max margin utilization percentage (CODE ENFORCED)
|
||||
// - MinPositionSize: minimum position size in USDT (CODE ENFORCED)
|
||||
// - MinRiskRewardRatio: min take_profit / stop_loss ratio (AI guided)
|
||||
// - MinConfidence: min AI confidence to open position (AI guided)
|
||||
type RiskControlConfig struct {
|
||||
// maximum number of positions
|
||||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||
MaxPositions int `json:"max_positions"`
|
||||
// BTC/ETH maximum leverage
|
||||
|
||||
// BTC/ETH exchange leverage for opening positions (AI guided)
|
||||
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||||
// altcoin maximum leverage
|
||||
// Altcoin exchange leverage for opening positions (AI guided)
|
||||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||||
// minimum risk-reward ratio
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// maximum margin usage
|
||||
|
||||
// BTC/ETH single position max value = equity × this ratio (CODE ENFORCED, default: 5)
|
||||
BTCETHMaxPositionValueRatio float64 `json:"btc_eth_max_position_value_ratio"`
|
||||
// Altcoin single position max value = equity × this ratio (CODE ENFORCED, default: 1)
|
||||
AltcoinMaxPositionValueRatio float64 `json:"altcoin_max_position_value_ratio"`
|
||||
|
||||
// Max margin utilization (e.g. 0.9 = 90%) (CODE ENFORCED)
|
||||
MaxMarginUsage float64 `json:"max_margin_usage"`
|
||||
// maximum position ratio per coin (relative to account equity)
|
||||
MaxPositionRatio float64 `json:"max_position_ratio"`
|
||||
// minimum position size (USDT)
|
||||
// Min position size in USDT (CODE ENFORCED)
|
||||
MinPositionSize float64 `json:"min_position_size"`
|
||||
// minimum confidence level
|
||||
|
||||
// Min take_profit / stop_loss ratio (AI guided)
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// Min AI confidence to open position (AI guided)
|
||||
MinConfidence int `json:"min_confidence"`
|
||||
}
|
||||
|
||||
@@ -192,7 +216,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 30,
|
||||
CoinPoolLimit: 10,
|
||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||
UseOITop: false,
|
||||
OITopLimit: 20,
|
||||
@@ -224,14 +248,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
EnableQuantNetflow: true,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: 5,
|
||||
AltcoinMaxLeverage: 5,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MaxPositionRatio: 1.5,
|
||||
MinPositionSize: 12,
|
||||
MinConfidence: 75,
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user