diff --git a/store/decision.go b/store/decision.go index be62bc79..917f53e5 100644 --- a/store/decision.go +++ b/store/decision.go @@ -287,16 +287,15 @@ func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) { } stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles - // Count open positions from trader_orders table + // Count from trader_positions table s.db.QueryRow(` - SELECT COUNT(*) FROM trader_orders - WHERE trader_id = ? AND status = 'FILLED' AND action IN ('open_long', 'open_short') + SELECT COUNT(*) FROM trader_positions + WHERE trader_id = ? `, traderID).Scan(&stats.TotalOpenPositions) - // Count close positions from trader_orders table s.db.QueryRow(` - SELECT COUNT(*) FROM trader_orders - WHERE trader_id = ? AND status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + SELECT COUNT(*) FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' `, traderID).Scan(&stats.TotalClosePositions) return stats, nil @@ -310,15 +309,14 @@ func (s *DecisionStore) GetAllStatistics() (*Statistics, error) { s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles) stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles - // Count from trader_orders table + // Count from trader_positions table s.db.QueryRow(` - SELECT COUNT(*) FROM trader_orders - WHERE status = 'FILLED' AND action IN ('open_long', 'open_short') + SELECT COUNT(*) FROM trader_positions `).Scan(&stats.TotalOpenPositions) s.db.QueryRow(` - SELECT COUNT(*) FROM trader_orders - WHERE status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + SELECT COUNT(*) FROM trader_positions + WHERE status = 'CLOSED' `).Scan(&stats.TotalClosePositions) return stats, nil diff --git a/store/order.go b/store/order.go deleted file mode 100644 index 752d4a49..00000000 --- a/store/order.go +++ /dev/null @@ -1,511 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "math" - "time" -) - -// TraderOrder trader order record -type TraderOrder struct { - ID int64 `json:"id"` - TraderID string `json:"trader_id"` // Trader ID - OrderID string `json:"order_id"` // Exchange order ID - ClientOrderID string `json:"client_order_id"` // Client order ID - Symbol string `json:"symbol"` // Trading pair - Side string `json:"side"` // BUY/SELL - PositionSide string `json:"position_side"` // LONG/SHORT/BOTH - Action string `json:"action"` // open_long/close_long/open_short/close_short - OrderType string `json:"order_type"` // MARKET/LIMIT - Quantity float64 `json:"quantity"` // Order quantity - Price float64 `json:"price"` // Order price - AvgPrice float64 `json:"avg_price"` // Actual average execution price - ExecutedQty float64 `json:"executed_qty"` // Executed quantity - Leverage int `json:"leverage"` // Leverage multiplier - Status string `json:"status"` // NEW/FILLED/CANCELED/EXPIRED - Fee float64 `json:"fee"` // Fee - FeeAsset string `json:"fee_asset"` // Fee asset - RealizedPnL float64 `json:"realized_pnl"` // Realized PnL (when closing) - EntryPrice float64 `json:"entry_price"` // Entry price (recorded when closing) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - FilledAt time.Time `json:"filled_at"` // Filled time -} - -// TraderStats trading statistics metrics -type TraderStats struct { - TotalTrades int `json:"total_trades"` // Total trades (closed) - WinTrades int `json:"win_trades"` // Winning trades - LossTrades int `json:"loss_trades"` // Losing trades - WinRate float64 `json:"win_rate"` // Win rate (%) - ProfitFactor float64 `json:"profit_factor"` // Profit factor - SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio - TotalPnL float64 `json:"total_pnl"` // Total PnL - TotalFee float64 `json:"total_fee"` // Total fees - AvgWin float64 `json:"avg_win"` // Average win - AvgLoss float64 `json:"avg_loss"` // Average loss - MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Max drawdown (%) -} - -// CompletedOrder completed order (for AI input) -type CompletedOrder struct { - Symbol string `json:"symbol"` // Trading pair - Action string `json:"action"` // close_long/close_short - Side string `json:"side"` // long/short - Quantity float64 `json:"quantity"` // Quantity - EntryPrice float64 `json:"entry_price"` // Entry price - ExitPrice float64 `json:"exit_price"` // Exit price - RealizedPnL float64 `json:"realized_pnl"` // Realized PnL - PnLPct float64 `json:"pnl_pct"` // PnL percentage - Fee float64 `json:"fee"` // Fee - Leverage int `json:"leverage"` // Leverage - FilledAt time.Time `json:"filled_at"` // Filled time -} - -// OrderStore order storage -type OrderStore struct { - db *sql.DB -} - -// NewOrderStore creates order storage instance -func NewOrderStore(db *sql.DB) *OrderStore { - return &OrderStore{db: db} -} - -// InitTables initializes order tables -func (s *OrderStore) InitTables() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS trader_orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trader_id TEXT NOT NULL, - order_id TEXT NOT NULL, - client_order_id TEXT DEFAULT '', - symbol TEXT NOT NULL, - side TEXT NOT NULL, - position_side TEXT DEFAULT '', - action TEXT NOT NULL, - order_type TEXT DEFAULT 'MARKET', - quantity REAL NOT NULL, - price REAL DEFAULT 0, - avg_price REAL DEFAULT 0, - executed_qty REAL DEFAULT 0, - leverage INTEGER DEFAULT 1, - status TEXT DEFAULT 'NEW', - fee REAL DEFAULT 0, - fee_asset TEXT DEFAULT 'USDT', - realized_pnl REAL DEFAULT 0, - entry_price REAL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - filled_at DATETIME, - UNIQUE(trader_id, order_id) - ) - `) - if err != nil { - return fmt.Errorf("failed to create trader_orders table: %w", err) - } - - // Create indexes - indices := []string{ - `CREATE INDEX IF NOT EXISTS idx_trader_orders_trader ON trader_orders(trader_id)`, - `CREATE INDEX IF NOT EXISTS idx_trader_orders_status ON trader_orders(trader_id, status)`, - `CREATE INDEX IF NOT EXISTS idx_trader_orders_symbol ON trader_orders(trader_id, symbol)`, - `CREATE INDEX IF NOT EXISTS idx_trader_orders_filled ON trader_orders(trader_id, filled_at DESC)`, - } - for _, idx := range indices { - if _, err := s.db.Exec(idx); err != nil { - return fmt.Errorf("failed to create index: %w", err) - } - } - - return nil -} - -// Create creates order record -func (s *OrderStore) Create(order *TraderOrder) error { - now := time.Now().Format(time.RFC3339) - result, err := s.db.Exec(` - INSERT INTO trader_orders ( - trader_id, order_id, client_order_id, symbol, side, position_side, - action, order_type, quantity, price, avg_price, executed_qty, - leverage, status, fee, fee_asset, realized_pnl, entry_price, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - order.TraderID, order.OrderID, order.ClientOrderID, order.Symbol, - order.Side, order.PositionSide, order.Action, order.OrderType, - order.Quantity, order.Price, order.AvgPrice, order.ExecutedQty, - order.Leverage, order.Status, order.Fee, order.FeeAsset, - order.RealizedPnL, order.EntryPrice, now, now, - ) - if err != nil { - return fmt.Errorf("failed to create order record: %w", err) - } - - id, _ := result.LastInsertId() - order.ID = id - return nil -} - -// Update updates order record -func (s *OrderStore) Update(order *TraderOrder) error { - now := time.Now().Format(time.RFC3339) - filledAt := "" - if !order.FilledAt.IsZero() { - filledAt = order.FilledAt.Format(time.RFC3339) - } - - _, err := s.db.Exec(` - UPDATE trader_orders SET - avg_price = ?, executed_qty = ?, status = ?, fee = ?, - realized_pnl = ?, entry_price = ?, updated_at = ?, filled_at = ? - WHERE trader_id = ? AND order_id = ? - `, - order.AvgPrice, order.ExecutedQty, order.Status, order.Fee, - order.RealizedPnL, order.EntryPrice, now, filledAt, - order.TraderID, order.OrderID, - ) - if err != nil { - return fmt.Errorf("failed to update order record: %w", err) - } - return nil -} - -// GetByOrderID gets order by order ID -func (s *OrderStore) GetByOrderID(traderID, orderID string) (*TraderOrder, error) { - var order TraderOrder - var createdAt, updatedAt, filledAt sql.NullString - - err := s.db.QueryRow(` - SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, - action, order_type, quantity, price, avg_price, executed_qty, - leverage, status, fee, fee_asset, realized_pnl, entry_price, - created_at, updated_at, filled_at - FROM trader_orders WHERE trader_id = ? AND order_id = ? - `, traderID, orderID).Scan( - &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, - &order.Symbol, &order.Side, &order.PositionSide, &order.Action, - &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, - &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, - &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, - &createdAt, &updatedAt, &filledAt, - ) - if err != nil { - return nil, err - } - - if createdAt.Valid { - order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) - } - if updatedAt.Valid { - order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) - } - if filledAt.Valid { - order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) - } - - return &order, nil -} - -// GetLatestOpenOrder gets the latest open order for a symbol (for calculating close PnL) -func (s *OrderStore) GetLatestOpenOrder(traderID, symbol, side string) (*TraderOrder, error) { - // side: long -> find open_long, short -> find open_short - action := "open_long" - if side == "short" { - action = "open_short" - } - - var order TraderOrder - var createdAt, updatedAt, filledAt sql.NullString - - err := s.db.QueryRow(` - SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, - action, order_type, quantity, price, avg_price, executed_qty, - leverage, status, fee, fee_asset, realized_pnl, entry_price, - created_at, updated_at, filled_at - FROM trader_orders - WHERE trader_id = ? AND symbol = ? AND action = ? AND status = 'FILLED' - ORDER BY filled_at DESC LIMIT 1 - `, traderID, symbol, action).Scan( - &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, - &order.Symbol, &order.Side, &order.PositionSide, &order.Action, - &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, - &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, - &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, - &createdAt, &updatedAt, &filledAt, - ) - if err != nil { - return nil, err - } - - if createdAt.Valid { - order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) - } - if updatedAt.Valid { - order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) - } - if filledAt.Valid { - order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) - } - - return &order, nil -} - -// GetRecentCompletedOrders gets recent completed close orders -func (s *OrderStore) GetRecentCompletedOrders(traderID string, limit int) ([]CompletedOrder, error) { - rows, err := s.db.Query(` - SELECT symbol, action, side, executed_qty, entry_price, avg_price, - realized_pnl, fee, leverage, filled_at - FROM trader_orders - WHERE trader_id = ? AND status = 'FILLED' - AND (action = 'close_long' OR action = 'close_short') - ORDER BY filled_at DESC - LIMIT ? - `, traderID, limit) - if err != nil { - return nil, fmt.Errorf("failed to query completed orders: %w", err) - } - defer rows.Close() - - var orders []CompletedOrder - for rows.Next() { - var o CompletedOrder - var filledAt sql.NullString - var side sql.NullString - - err := rows.Scan( - &o.Symbol, &o.Action, &side, &o.Quantity, &o.EntryPrice, &o.ExitPrice, - &o.RealizedPnL, &o.Fee, &o.Leverage, &filledAt, - ) - if err != nil { - continue - } - - // Infer side from action - if o.Action == "close_long" { - o.Side = "long" - } else if o.Action == "close_short" { - o.Side = "short" - } else if side.Valid { - o.Side = side.String - } - - // Calculate PnL percentage - if o.EntryPrice > 0 { - if o.Side == "long" { - o.PnLPct = (o.ExitPrice - o.EntryPrice) / o.EntryPrice * 100 * float64(o.Leverage) - } else { - o.PnLPct = (o.EntryPrice - o.ExitPrice) / o.EntryPrice * 100 * float64(o.Leverage) - } - } - - if filledAt.Valid { - o.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) - } - - orders = append(orders, o) - } - - return orders, nil -} - -// GetTraderStats gets trading statistics metrics -func (s *OrderStore) GetTraderStats(traderID string) (*TraderStats, error) { - stats := &TraderStats{} - - // Query all completed close orders - rows, err := s.db.Query(` - SELECT realized_pnl, fee, filled_at - FROM trader_orders - WHERE trader_id = ? AND status = 'FILLED' - AND (action = 'close_long' OR action = 'close_short') - ORDER BY filled_at ASC - `, traderID) - if err != nil { - return nil, fmt.Errorf("failed to query order statistics: %w", err) - } - defer rows.Close() - - var pnls []float64 - var totalWin, totalLoss float64 - - for rows.Next() { - var pnl, fee float64 - var filledAt sql.NullString - if err := rows.Scan(&pnl, &fee, &filledAt); err != nil { - continue - } - - stats.TotalTrades++ - stats.TotalPnL += pnl - stats.TotalFee += fee - pnls = append(pnls, pnl) - - if pnl > 0 { - stats.WinTrades++ - totalWin += pnl - } else if pnl < 0 { - stats.LossTrades++ - totalLoss += math.Abs(pnl) - } - } - - // Calculate win rate - if stats.TotalTrades > 0 { - stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100 - } - - // Calculate profit factor - if totalLoss > 0 { - stats.ProfitFactor = totalWin / totalLoss - } - - // Calculate average win/loss - if stats.WinTrades > 0 { - stats.AvgWin = totalWin / float64(stats.WinTrades) - } - if stats.LossTrades > 0 { - stats.AvgLoss = totalLoss / float64(stats.LossTrades) - } - - // Calculate Sharpe ratio (using PnL sequence) - if len(pnls) > 1 { - stats.SharpeRatio = calculateSharpeRatio(pnls) - } - - // Calculate max drawdown - if len(pnls) > 0 { - stats.MaxDrawdownPct = calculateMaxDrawdown(pnls) - } - - return stats, nil -} - -// calculateSharpeRatio calculates Sharpe ratio -func calculateSharpeRatio(pnls []float64) float64 { - if len(pnls) < 2 { - return 0 - } - - // Calculate average return - var sum float64 - for _, pnl := range pnls { - sum += pnl - } - mean := sum / float64(len(pnls)) - - // Calculate standard deviation - var variance float64 - for _, pnl := range pnls { - variance += (pnl - mean) * (pnl - mean) - } - stdDev := math.Sqrt(variance / float64(len(pnls)-1)) - - if stdDev == 0 { - return 0 - } - - // Sharpe ratio = average return / standard deviation - return mean / stdDev -} - -// calculateMaxDrawdown calculates max drawdown -func calculateMaxDrawdown(pnls []float64) float64 { - if len(pnls) == 0 { - return 0 - } - - // Calculate cumulative equity curve - var cumulative float64 - var peak float64 - var maxDD float64 - - for _, pnl := range pnls { - cumulative += pnl - if cumulative > peak { - peak = cumulative - } - if peak > 0 { - dd := (peak - cumulative) / peak * 100 - if dd > maxDD { - maxDD = dd - } - } - } - - return maxDD -} - -// GetPendingOrders gets pending orders (for polling) -func (s *OrderStore) GetPendingOrders(traderID string) ([]*TraderOrder, error) { - rows, err := s.db.Query(` - SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, - action, order_type, quantity, price, avg_price, executed_qty, - leverage, status, fee, fee_asset, realized_pnl, entry_price, - created_at, updated_at, filled_at - FROM trader_orders - WHERE trader_id = ? AND status = 'NEW' - ORDER BY created_at ASC - `, traderID) - if err != nil { - return nil, fmt.Errorf("failed to query pending orders: %w", err) - } - defer rows.Close() - - return s.scanOrders(rows) -} - -// GetAllPendingOrders gets all pending orders (for global sync) -func (s *OrderStore) GetAllPendingOrders() ([]*TraderOrder, error) { - rows, err := s.db.Query(` - SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, - action, order_type, quantity, price, avg_price, executed_qty, - leverage, status, fee, fee_asset, realized_pnl, entry_price, - created_at, updated_at, filled_at - FROM trader_orders - WHERE status = 'NEW' - ORDER BY trader_id, created_at ASC - `) - if err != nil { - return nil, fmt.Errorf("failed to query pending orders: %w", err) - } - defer rows.Close() - - return s.scanOrders(rows) -} - -// scanOrders scans order rows to structs -func (s *OrderStore) scanOrders(rows *sql.Rows) ([]*TraderOrder, error) { - var orders []*TraderOrder - for rows.Next() { - var order TraderOrder - var createdAt, updatedAt, filledAt sql.NullString - - err := rows.Scan( - &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, - &order.Symbol, &order.Side, &order.PositionSide, &order.Action, - &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, - &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, - &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, - &createdAt, &updatedAt, &filledAt, - ) - if err != nil { - continue - } - - if createdAt.Valid { - order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) - } - if updatedAt.Valid { - order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) - } - if filledAt.Valid { - order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) - } - - orders = append(orders, &order) - } - - return orders, nil -} diff --git a/store/position.go b/store/position.go index a0735601..3ec0d7f7 100644 --- a/store/position.go +++ b/store/position.go @@ -7,6 +7,21 @@ import ( "time" ) +// TraderStats trading statistics metrics +type TraderStats struct { + TotalTrades int `json:"total_trades"` // Total trades (closed) + WinTrades int `json:"win_trades"` // Winning trades + LossTrades int `json:"loss_trades"` // Losing trades + WinRate float64 `json:"win_rate"` // Win rate (%) + ProfitFactor float64 `json:"profit_factor"` // Profit factor + SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio + TotalPnL float64 `json:"total_pnl"` // Total PnL + TotalFee float64 `json:"total_fee"` // Total fees + AvgWin float64 `json:"avg_win"` // Average win + AvgLoss float64 `json:"avg_loss"` // Average loss + MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Max drawdown (%) +} + // TraderPosition position record (complete open/close position tracking) type TraderPosition struct { ID int64 `json:"id"` diff --git a/store/store.go b/store/store.go index 7397d045..ef8d3ac2 100644 --- a/store/store.go +++ b/store/store.go @@ -22,7 +22,6 @@ type Store struct { trader *TraderStore decision *DecisionStore backtest *BacktestStore - order *OrderStore position *PositionStore strategy *StrategyStore equity *EquityStore @@ -135,9 +134,6 @@ func (s *Store) initTables() error { if err := s.Backtest().initTables(); err != nil { return fmt.Errorf("failed to initialize backtest tables: %w", err) } - if err := s.Order().InitTables(); err != nil { - return fmt.Errorf("failed to initialize order tables: %w", err) - } if err := s.Position().InitTables(); err != nil { return fmt.Errorf("failed to initialize position tables: %w", err) } @@ -241,16 +237,6 @@ func (s *Store) Backtest() *BacktestStore { return s.backtest } -// Order gets order storage -func (s *Store) Order() *OrderStore { - s.mu.Lock() - defer s.mu.Unlock() - if s.order == nil { - s.order = NewOrderStore(s.db) - } - return s.order -} - // Position gets position storage func (s *Store) Position() *PositionStore { s.mu.Lock() diff --git a/trader/auto_trader.go b/trader/auto_trader.go index a0cddaf7..b262b761 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -572,14 +572,32 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { // Calculate P&L percentage (based on margin, considering leverage) pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed) - // Track position first seen time + // Get position open time from exchange (preferred) or fallback to local tracking posKey := symbol + "_" + side currentPositionKeys[posKey] = true - if _, exists := at.positionFirstSeenTime[posKey]; !exists { - // New position, record current time - at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() + + var updateTime int64 + // Priority 1: Get from database (trader_positions table) - most accurate + if at.store != nil { + if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil { + if !dbPos.EntryTime.IsZero() { + updateTime = dbPos.EntryTime.UnixMilli() + } + } + } + // Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime) + if updateTime == 0 { + if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 { + updateTime = createdTime + } + } + // Priority 3: Fallback to local tracking + if updateTime == 0 { + if _, exists := at.positionFirstSeenTime[posKey]; !exists { + at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() + } + updateTime = at.positionFirstSeenTime[posKey] } - updateTime := at.positionFirstSeenTime[posKey] // Get peak profit rate for this position at.peakPnLCacheMutex.RLock() @@ -910,13 +928,21 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac } actionRecord.Price = marketData.CurrentPrice - // Get entry price (for P&L calculation) + // Get entry price and quantity from exchange API (most accurate) var entryPrice float64 var quantity float64 - if at.store != nil { - if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "long"); err == nil { - entryPrice = openOrder.AvgPrice - quantity = openOrder.ExecutedQty + positions, err := at.trader.GetPositions() + if err == nil { + for _, pos := range positions { + if pos["symbol"] == decision.Symbol && pos["side"] == "long" { + if ep, ok := pos["entryPrice"].(float64); ok { + entryPrice = ep + } + if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 { + quantity = amt + } + break + } } } @@ -949,13 +975,21 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a } actionRecord.Price = marketData.CurrentPrice - // Get entry price (for P&L calculation) + // Get entry price and quantity from exchange API (most accurate) var entryPrice float64 var quantity float64 - if at.store != nil { - if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "short"); err == nil { - entryPrice = openOrder.AvgPrice - quantity = openOrder.ExecutedQty + positions, err := at.trader.GetPositions() + if err == nil { + for _, pos := range positions { + if pos["symbol"] == decision.Symbol && pos["side"] == "short" { + if ep, ok := pos["entryPrice"].(float64); ok { + entryPrice = ep + } + if amt, ok := pos["positionAmt"].(float64); ok { + quantity = -amt // positionAmt is negative for short + } + break + } } } @@ -1435,7 +1469,7 @@ func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) { delete(at.peakPnLCache, posKey) } -// recordAndConfirmOrder records order and polls for confirmation status +// recordAndConfirmOrder polls order status for actual fill data and records position // action: open_long, open_short, close_long, close_short // entryPrice: entry price when closing (0 when opening) func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) { @@ -1461,53 +1495,58 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, return } - // Determine side and positionSide - var side, positionSide string + // Determine positionSide + var positionSide string switch action { - case "open_long": - side = "BUY" + case "open_long", "close_long": positionSide = "LONG" - case "close_long": - side = "SELL" - positionSide = "LONG" - case "open_short": - side = "SELL" - positionSide = "SHORT" - case "close_short": - side = "BUY" + case "open_short", "close_short": positionSide = "SHORT" } - // Create order record - order := &store.TraderOrder{ - TraderID: at.id, - OrderID: orderID, - Symbol: symbol, - Side: side, - PositionSide: positionSide, - Action: action, - OrderType: "MARKET", - Quantity: quantity, - Price: price, - Leverage: leverage, - Status: "NEW", - EntryPrice: entryPrice, + // Poll order status to get actual fill price, quantity and fee + var actualPrice = price // fallback to market price + var actualQty = quantity // fallback to requested quantity + var fee float64 + + // Wait for order to be filled and get actual fill data + time.Sleep(500 * time.Millisecond) + for i := 0; i < 5; i++ { + status, err := at.trader.GetOrderStatus(symbol, orderID) + if err == nil { + statusStr, _ := status["status"].(string) + if statusStr == "FILLED" { + // Get actual fill price + if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 { + actualPrice = avgPrice + } + // Get actual executed quantity + if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 { + actualQty = execQty + } + // Get commission/fee + if commission, ok := status["commission"].(float64); ok { + fee = commission + } + logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee) + break + } else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" { + logger.Infof(" ⚠️ Order %s, skipping position record", statusStr) + return + } + } + time.Sleep(500 * time.Millisecond) } - // Save to database - if err := at.store.Order().Create(order); err != nil { - logger.Infof(" ⚠️ Failed to record order: %v", err) - return - } + logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)", + orderID, action, actualPrice, actualQty, fee) - logger.Infof(" 📝 Order recorded (ID: %s, action: %s)", orderID, action) - - // Record position change - at.recordPositionChange(orderID, symbol, positionSide, action, quantity, price, leverage, entryPrice) + // Record position change with actual fill data + at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee) } // recordPositionChange records position change (create record on open, update record on close) -func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64) { +func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) { if at.store == nil { return } @@ -1555,14 +1594,14 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, price, // exitPrice orderID, // exitOrderID realizedPnL, - 0, // fee (not calculated yet) + fee, // fee from exchange API "ai_decision", ) if err != nil { logger.Infof(" ⚠️ Failed to update position: %v", err) } else { - logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f", - at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL) + logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f, Fee: %.4f", + at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL, fee) } } } diff --git a/trader/binance_futures.go b/trader/binance_futures.go index cb001955..b415ca8a 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -195,6 +195,7 @@ func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) { posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64) posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64) posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64) + // Note: Binance SDK doesn't expose updateTime field, will fallback to local tracking // Determine direction if posAmt > 0 { diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index 4bf54818..010486bc 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -220,6 +220,12 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { liqPriceStr, _ := pos["liqPrice"].(string) liqPrice, _ := strconv.ParseFloat(liqPriceStr, 64) + // Position created/updated time (milliseconds timestamp) + createdTimeStr, _ := pos["createdTime"].(string) + createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64) + updatedTimeStr, _ := pos["updatedTime"].(string) + updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64) + positionSide, _ := pos["side"].(string) // Buy = LONG, Sell = SHORT // Convert to unified format @@ -240,6 +246,8 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { "unrealizedPnL": unrealisedPnl, "liquidationPrice": liqPrice, "leverage": leverage, + "createdTime": createdTime, // Position open time (ms) + "updatedTime": updatedTime, // Position last update time (ms) } positions = append(positions, position) diff --git a/trader/okx_trader.go b/trader/okx_trader.go index 7ea72ccd..f06bcbe7 100644 --- a/trader/okx_trader.go +++ b/trader/okx_trader.go @@ -312,6 +312,8 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) { Lever string `json:"lever"` LiqPx string `json:"liqPx"` Margin string `json:"margin"` + CTime string `json:"cTime"` // Position created time (ms) + UTime string `json:"uTime"` // Position last update time (ms) } if err := json.Unmarshal(data, &positions); err != nil { @@ -344,6 +346,10 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) { posAmt = -posAmt } + // Parse timestamps + cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) + uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) + posMap := map[string]interface{}{ "symbol": symbol, "positionAmt": posAmt, @@ -353,6 +359,8 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) { "leverage": leverage, "liquidationPrice": liqPrice, "side": side, + "createdTime": cTime, // Position open time (ms) + "updatedTime": uTime, // Position last update time (ms) } result = append(result, posMap) } diff --git a/trader/order_sync.go b/trader/order_sync.go deleted file mode 100644 index b3b7be5c..00000000 --- a/trader/order_sync.go +++ /dev/null @@ -1,313 +0,0 @@ -package trader - -import ( - "fmt" - "nofx/logger" - "nofx/store" - "sync" - "time" -) - -// OrderSyncManager Order status synchronization manager -// Responsible for periodically scanning all NEW status orders and updating their status -type OrderSyncManager struct { - store *store.Store - interval time.Duration - stopCh chan struct{} - wg sync.WaitGroup - traderCache map[string]Trader // trader_id -> Trader instance cache - configCache map[string]*store.TraderFullConfig // trader_id -> config cache - cacheMutex sync.RWMutex -} - -// NewOrderSyncManager Create order synchronization manager -func NewOrderSyncManager(st *store.Store, interval time.Duration) *OrderSyncManager { - if interval == 0 { - interval = 10 * time.Second - } - return &OrderSyncManager{ - store: st, - interval: interval, - stopCh: make(chan struct{}), - traderCache: make(map[string]Trader), - configCache: make(map[string]*store.TraderFullConfig), - } -} - -// Start Start order synchronization service -func (m *OrderSyncManager) Start() { - m.wg.Add(1) - go m.run() - logger.Info("📦 Order sync manager started") -} - -// Stop Stop order synchronization service -func (m *OrderSyncManager) Stop() { - close(m.stopCh) - m.wg.Wait() - - // Clear cache - m.cacheMutex.Lock() - m.traderCache = make(map[string]Trader) - m.configCache = make(map[string]*store.TraderFullConfig) - m.cacheMutex.Unlock() - - logger.Info("📦 Order sync manager stopped") -} - -// run Main loop -func (m *OrderSyncManager) run() { - defer m.wg.Done() - - // Execute immediately on startup - m.syncOrders() - - ticker := time.NewTicker(m.interval) - defer ticker.Stop() - - for { - select { - case <-m.stopCh: - return - case <-ticker.C: - m.syncOrders() - } - } -} - -// syncOrders Synchronize all pending orders -func (m *OrderSyncManager) syncOrders() { - // Get all NEW status orders - orders, err := m.store.Order().GetAllPendingOrders() - if err != nil { - logger.Infof("⚠️ Failed to get pending orders: %v", err) - return - } - - if len(orders) == 0 { - return - } - - logger.Infof("📦 Starting to sync %d pending orders...", len(orders)) - - // Group by trader_id - ordersByTrader := make(map[string][]*store.TraderOrder) - for _, order := range orders { - ordersByTrader[order.TraderID] = append(ordersByTrader[order.TraderID], order) - } - - // Process each trader - for traderID, traderOrders := range ordersByTrader { - m.syncTraderOrders(traderID, traderOrders) - } -} - -// syncTraderOrders Synchronize orders for a single trader -func (m *OrderSyncManager) syncTraderOrders(traderID string, orders []*store.TraderOrder) { - // Get or create trader instance - trader, err := m.getOrCreateTrader(traderID) - if err != nil { - logger.Infof("⚠️ Failed to get trader instance (ID: %s): %v", traderID, err) - return - } - - for _, order := range orders { - m.syncSingleOrder(trader, order) - } -} - -// syncSingleOrder Synchronize single order status -func (m *OrderSyncManager) syncSingleOrder(trader Trader, order *store.TraderOrder) { - status, err := trader.GetOrderStatus(order.Symbol, order.OrderID) - if err != nil { - // Query failed, check order creation time, assume filled after certain time - if time.Since(order.CreatedAt) > 5*time.Minute { - logger.Infof("⚠️ Order query timeout, assuming filled (ID: %s)", order.OrderID) - m.markOrderFilled(order, 0, 0, 0) - } - return - } - - statusStr, _ := status["status"].(string) - - switch statusStr { - case "FILLED": - avgPrice, _ := status["avgPrice"].(float64) - executedQty, _ := status["executedQty"].(float64) - commission, _ := status["commission"].(float64) - - // If API doesn't return quantity, use original quantity - if executedQty == 0 { - executedQty = order.Quantity - } - - m.markOrderFilled(order, avgPrice, executedQty, commission) - - case "CANCELED", "EXPIRED": - order.Status = statusStr - if err := m.store.Order().Update(order); err != nil { - logger.Infof("⚠️ Failed to update order status: %v", err) - } else { - logger.Infof("📦 Order status updated: %s (ID: %s)", statusStr, order.OrderID) - } - } -} - -// markOrderFilled Mark order as filled -func (m *OrderSyncManager) markOrderFilled(order *store.TraderOrder, avgPrice, executedQty, commission float64) { - // If avgPrice is 0, use order price - if avgPrice == 0 { - avgPrice = order.Price - } - if executedQty == 0 { - executedQty = order.Quantity - } - - // Calculate realized PnL (only for closing orders) - var realizedPnL float64 - if (order.Action == "close_long" || order.Action == "close_short") && order.EntryPrice > 0 && avgPrice > 0 { - if order.Action == "close_long" { - // Long close PnL = (close price - entry price) * quantity - realizedPnL = (avgPrice - order.EntryPrice) * executedQty - } else { - // Short close PnL = (entry price - close price) * quantity - realizedPnL = (order.EntryPrice - avgPrice) * executedQty - } - } - - order.AvgPrice = avgPrice - order.ExecutedQty = executedQty - order.Status = "FILLED" - order.Fee = commission - order.RealizedPnL = realizedPnL - order.FilledAt = time.Now() - - if err := m.store.Order().Update(order); err != nil { - logger.Infof("⚠️ Failed to update order status: %v", err) - } else { - if realizedPnL != 0 { - logger.Infof("✅ Order filled (ID: %s, avgPrice: %.4f, qty: %.4f, PnL: %.2f)", - order.OrderID, avgPrice, executedQty, realizedPnL) - } else { - logger.Infof("✅ Order filled (ID: %s, avgPrice: %.4f, qty: %.4f)", - order.OrderID, avgPrice, executedQty) - } - } -} - -// getOrCreateTrader Get or create trader instance -func (m *OrderSyncManager) getOrCreateTrader(traderID string) (Trader, error) { - m.cacheMutex.RLock() - trader, exists := m.traderCache[traderID] - m.cacheMutex.RUnlock() - - if exists && trader != nil { - return trader, nil - } - - // Need to create new trader instance - // First get trader config - config, err := m.getTraderConfig(traderID) - if err != nil { - return nil, fmt.Errorf("failed to get trader config: %w", err) - } - - // Create trader based on exchange type - trader, err = m.createTrader(config) - if err != nil { - return nil, fmt.Errorf("failed to create trader instance: %w", err) - } - - m.cacheMutex.Lock() - m.traderCache[traderID] = trader - m.cacheMutex.Unlock() - - return trader, nil -} - -// getTraderConfig Get trader configuration -func (m *OrderSyncManager) getTraderConfig(traderID string) (*store.TraderFullConfig, error) { - m.cacheMutex.RLock() - config, exists := m.configCache[traderID] - m.cacheMutex.RUnlock() - - if exists { - return config, nil - } - - // Get from database - need to find trader's corresponding userID - // First query all traders to find corresponding userID - traders, err := m.store.Trader().ListAll() - if err != nil { - return nil, fmt.Errorf("failed to get trader list: %w", err) - } - - var userID string - for _, t := range traders { - if t.ID == traderID { - userID = t.UserID - break - } - } - - if userID == "" { - return nil, fmt.Errorf("trader not found: %s", traderID) - } - - config, err = m.store.Trader().GetFullConfig(userID, traderID) - if err != nil { - return nil, err - } - - m.cacheMutex.Lock() - m.configCache[traderID] = config - m.cacheMutex.Unlock() - - return config, nil -} - -// createTrader Create trader instance based on configuration -func (m *OrderSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) { - exchange := config.Exchange - - // Use exchange.ID to determine specific exchange, not exchange.Type (cex/dex) - switch exchange.ID { - case "binance": - return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil - - case "bybit": - return NewBybitTrader(exchange.APIKey, exchange.SecretKey), nil - - case "okx": - return NewOKXTrader(exchange.APIKey, exchange.SecretKey, exchange.Passphrase), nil - - case "hyperliquid": - return NewHyperliquidTrader(exchange.SecretKey, exchange.HyperliquidWalletAddr, exchange.Testnet) - - case "aster": - return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey) - - case "lighter": - if exchange.LighterAPIKeyPrivateKey != "" { - return NewLighterTraderV2( - exchange.LighterPrivateKey, - exchange.LighterWalletAddr, - exchange.LighterAPIKeyPrivateKey, - exchange.Testnet, - ) - } - return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet) - - default: - return nil, fmt.Errorf("unsupported exchange: %s", exchange.ID) - } -} - -// InvalidateCache Invalidate cache (call when configuration changes) -func (m *OrderSyncManager) InvalidateCache(traderID string) { - m.cacheMutex.Lock() - defer m.cacheMutex.Unlock() - - delete(m.traderCache, traderID) - delete(m.configCache, traderID) -}