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:
tinkle-community
2025-12-10 14:40:08 +08:00
parent c19ee51dee
commit 319ccb8ca3
45 changed files with 2951 additions and 3392 deletions

View File

@@ -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
}

View File

@@ -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)
},
}