diff --git a/api/server.go b/api/server.go index 5825df06..36e582fe 100644 --- a/api/server.go +++ b/api/server.go @@ -1452,9 +1452,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy FilledQuantity: quantity, AvgFillPrice: exitPrice, Commission: fee, - FilledAt: time.Now().UTC(), - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + FilledAt: time.Now().UTC().UnixMilli(), + CreatedAt: time.Now().UTC().UnixMilli(), + UpdatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateOrder(orderRecord); err != nil { @@ -1482,7 +1482,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy CommissionAsset: "USDT", RealizedPnL: 0, IsMaker: false, - CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateFill(fillRecord); err != nil { @@ -1557,7 +1557,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang CommissionAsset: "USDT", RealizedPnL: 0, IsMaker: false, - CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC().UnixMilli(), } if err := s.store.Order().CreateFill(fillRecord); err != nil { diff --git a/scripts/diagnose_orders.go b/scripts/diagnose_orders.go index aea7c48d..0a1b2bed 100644 --- a/scripts/diagnose_orders.go +++ b/scripts/diagnose_orders.go @@ -7,6 +7,7 @@ import ( "nofx/store" "os" "path/filepath" + "time" ) func main() { @@ -83,7 +84,7 @@ func main() { filledOrders++ // 检查 filled_at - if !order.FilledAt.IsZero() { + if order.FilledAt > 0 { withFilledAt++ } else { missingFilledAt++ @@ -119,8 +120,8 @@ func main() { } filledAtStr := "N/A" - if !order.FilledAt.IsZero() { - filledAtStr = order.FilledAt.Format("01-02 15:04") + if order.FilledAt > 0 { + filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04") } fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n", diff --git a/store/order.go b/store/order.go index 6aeb69b6..235485f5 100644 --- a/store/order.go +++ b/store/order.go @@ -9,36 +9,37 @@ import ( ) // TraderOrder order record +// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues type TraderOrder struct { - ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` - TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"` - ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` - ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` - ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"` - ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"` - Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"` - Side string `gorm:"column:side;not null" json:"side"` - PositionSide string `gorm:"column:position_side;default:''" json:"position_side"` - Type string `gorm:"column:type;not null" json:"type"` - TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"` - Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` - Price float64 `gorm:"column:price;default:0" json:"price"` - StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"` - Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"` - FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"` - AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"` - Commission float64 `gorm:"column:commission;default:0" json:"commission"` - CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"` - Leverage int `gorm:"column:leverage;default:1" json:"leverage"` - ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"` - ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"` - WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"` - PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"` - OrderAction string `gorm:"column:order_action;default:''" json:"order_action"` - RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` - FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"` + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"` + ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` + ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` + ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"` + ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"` + Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"` + Side string `gorm:"column:side;not null" json:"side"` + PositionSide string `gorm:"column:position_side;default:''" json:"position_side"` + Type string `gorm:"column:type;not null" json:"type"` + TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"` + Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` + Price float64 `gorm:"column:price;default:0" json:"price"` + StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"` + Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"` + FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"` + AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"` + Commission float64 `gorm:"column:commission;default:0" json:"commission"` + CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"` + Leverage int `gorm:"column:leverage;default:1" json:"leverage"` + ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"` + ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"` + WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"` + PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"` + OrderAction string `gorm:"column:order_action;default:''" json:"order_action"` + RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"` + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC + UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC + FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC } // TableName returns the table name for TraderOrder @@ -47,24 +48,25 @@ func (TraderOrder) TableName() string { } // TraderFill trade record +// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues type TraderFill struct { - ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` - TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"` - ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` - ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` - OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"` - ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"` - ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"` - Symbol string `gorm:"column:symbol;not null" json:"symbol"` - Side string `gorm:"column:side;not null" json:"side"` - Price float64 `gorm:"column:price;not null" json:"price"` - Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` - QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"` - Commission float64 `gorm:"column:commission;not null" json:"commission"` - CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"` - RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` - IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"` + ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` + ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` + OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"` + ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"` + ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"` + Symbol string `gorm:"column:symbol;not null" json:"symbol"` + Side string `gorm:"column:side;not null" json:"side"` + Price float64 `gorm:"column:price;not null" json:"price"` + Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` + QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"` + Commission float64 `gorm:"column:commission;not null" json:"commission"` + CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"` + RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` + IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"` + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC } // TableName returns the table name for TraderFill @@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error { s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col)) } + // Migrate timestamp columns to bigint (Unix milliseconds UTC) + // Check if column is still timestamp type before migrating + timestampColumns := []struct{ table, col string }{ + {"trader_orders", "created_at"}, + {"trader_orders", "updated_at"}, + {"trader_orders", "filled_at"}, + {"trader_fills", "created_at"}, + } + for _, c := range timestampColumns { + var dataType string + s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.col).Scan(&dataType) + if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" { + // Convert timestamp to Unix milliseconds (bigint) + s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col)) + } + } + // Ensure indexes exist s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`) @@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr "filled_quantity": filledQty, "avg_fill_price": avgPrice, "commission": commission, + "updated_at": time.Now().UTC().UnixMilli(), } if status == "FILLED" { - updates["filled_at"] = time.Now() + updates["filled_at"] = time.Now().UTC().UnixMilli() } return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error @@ -354,3 +374,29 @@ func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int return result, nil } + +// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange +// Used to recover sync state after service restart +func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) { + var fill TraderFill + err := s.db.Where("exchange_id = ?", exchangeID). + Order("created_at DESC"). + First(&fill).Error + if err != nil { + return 0, err + } + return fill.CreatedAt, nil +} + +// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms) +func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) { + var symbols []string + err := s.db.Model(&TraderFill{}). + Select("DISTINCT symbol"). + Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs). + Pluck("symbol", &symbols).Error + if err != nil { + return nil, err + } + return symbols, nil +} diff --git a/store/position.go b/store/position.go index 3fc42798..bbbc915d 100644 --- a/store/position.go +++ b/store/position.go @@ -25,30 +25,31 @@ type TraderStats struct { } // TraderPosition position record +// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues type TraderPosition struct { - ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` - TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"` - ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"` - ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` - ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"` - Symbol string `gorm:"column:symbol;not null" json:"symbol"` - Side string `gorm:"column:side;not null" json:"side"` - EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"` - Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` - EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"` - EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"` - EntryTime time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` - ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"` - ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"` - ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` - RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` - Fee float64 `gorm:"column:fee;default:0" json:"fee"` - Leverage int `gorm:"column:leverage;default:1" json:"leverage"` - Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"` - CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"` - Source string `gorm:"column:source;default:system" json:"source"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"` + ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"` + ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` + ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"` + Symbol string `gorm:"column:symbol;not null" json:"symbol"` + Side string `gorm:"column:side;not null" json:"side"` + EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"` + Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` + EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"` + EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"` + EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC + ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"` + ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"` + ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set + RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` + Fee float64 `gorm:"column:fee;default:0" json:"fee"` + Leverage int `gorm:"column:leverage;default:1" json:"leverage"` + Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"` + CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"` + Source string `gorm:"column:source;default:system" json:"source"` + CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC + UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC } // TableName returns the table name @@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error { var tableExists int64 s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists) if tableExists > 0 { + // Migrate timestamp columns to bigint (Unix milliseconds UTC) + // Check if column is still timestamp type before migrating + timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"} + for _, col := range timestampColumns { + var dataType string + s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType) + if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" { + // Convert timestamp to Unix milliseconds (bigint) + s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col)) + } + } + // Just ensure index exists s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`) return nil @@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error { // ClosePosition closes position func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error { - now := time.Now() + nowMs := time.Now().UTC().UnixMilli() return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ "exit_price": exitPrice, "exit_order_id": exitOrderID, - "exit_time": now, + "exit_time": nowMs, "realized_pnl": realizedPnL, "fee": fee, "status": "CLOSED", "close_reason": closeReason, + "updated_at": nowMs, }).Error } @@ -190,7 +204,8 @@ func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchang } // ClosePositionFully marks position as fully closed -func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error { +// exitTimeMs is Unix milliseconds UTC +func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error { var pos TraderPosition if err := s.db.First(&pos, id).Error; err != nil { return fmt.Errorf("failed to get position: %w", err) @@ -205,11 +220,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde "quantity": quantity, "exit_price": exitPrice, "exit_order_id": exitOrderID, - "exit_time": exitTime, + "exit_time": exitTimeMs, "realized_pnl": totalRealizedPnL, "fee": totalFee, "status": "CLOSED", "close_reason": closeReason, + "updated_at": time.Now().UTC().UnixMilli(), }).Error } @@ -432,13 +448,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra EntryPrice: pos.EntryPrice, ExitPrice: pos.ExitPrice, RealizedPnL: pos.RealizedPnL, - EntryTime: pos.EntryTime.Unix(), + EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility } - if pos.ExitTime != nil { - t.ExitTime = pos.ExitTime.Unix() - duration := pos.ExitTime.Sub(pos.EntryTime) - t.HoldDuration = formatDuration(duration) + if pos.ExitTime > 0 { + t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds + durationMs := pos.ExitTime - pos.EntryTime + t.HoldDuration = formatDurationMs(durationMs) } if pos.EntryPrice > 0 { @@ -457,26 +473,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra // formatDuration formats a duration func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) + return formatDurationMs(d.Milliseconds()) +} + +// formatDurationMs formats a duration in milliseconds +func formatDurationMs(ms int64) string { + seconds := ms / 1000 + minutes := seconds / 60 + hours := minutes / 60 + days := hours / 24 + + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) + if minutes < 60 { + return fmt.Sprintf("%dm", minutes) } - if d < 24*time.Hour { - hours := int(d.Hours()) - minutes := int(d.Minutes()) % 60 - if minutes == 0 { + if hours < 24 { + remainingMins := minutes % 60 + if remainingMins == 0 { return fmt.Sprintf("%dh", hours) } - return fmt.Sprintf("%dh%dm", hours, minutes) + return fmt.Sprintf("%dh%dm", hours, remainingMins) } - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - if hours == 0 { + remainingHours := hours % 24 + if remainingHours == 0 { return fmt.Sprintf("%dd", days) } - return fmt.Sprintf("%dd%dh", days, hours) + return fmt.Sprintf("%dd%dh", days, remainingHours) } // calculateSharpeRatioFromPnls calculates Sharpe ratio @@ -566,8 +590,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat s.WinTrades++ } - if pos.ExitTime != nil { - holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes() + if pos.ExitTime > 0 { + holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins) } } @@ -615,7 +639,7 @@ type HoldingTimeStats struct { // GetHoldingTimeStats analyzes performance by holding duration func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) { var positions []TraderPosition - err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error + err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error if err != nil { return nil, fmt.Errorf("failed to query holding time stats: %w", err) } @@ -632,10 +656,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats } for _, pos := range positions { - if pos.ExitTime == nil { + if pos.ExitTime == 0 { continue } - holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours() + holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours var rangeKey string switch { @@ -792,12 +816,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err // Calculate average holding time var positions []TraderPosition - s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions) + s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions) if len(positions) > 0 { var totalMins float64 for _, pos := range positions { - if pos.ExitTime != nil { - totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes() + if pos.ExitTime > 0 { + totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes } } summary.AvgHoldingMins = totalMins / float64(len(positions)) @@ -917,6 +941,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange } // ClosedPnLRecord represents a closed position record from exchange +// All time fields use int64 millisecond timestamps (UTC) type ClosedPnLRecord struct { Symbol string Side string @@ -926,8 +951,8 @@ type ClosedPnLRecord struct { RealizedPnL float64 Fee float64 Leverage int - EntryTime time.Time - ExitTime time.Time + EntryTime int64 // Unix milliseconds UTC + ExitTime int64 // Unix milliseconds UTC OrderID string CloseType string ExchangeID string @@ -954,7 +979,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s exchangePositionID := record.ExchangeID if exchangePositionID == "" { - exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL) + exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL) } exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID) @@ -965,19 +990,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s return false, nil } - exitTime := record.ExitTime - entryTime := record.EntryTime + exitTimeMs := record.ExitTime + entryTimeMs := record.EntryTime - if exitTime.IsZero() || exitTime.Year() < 2000 { + // Validate timestamps (must be after year 2000 = ~946684800000 ms) + minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds + if exitTimeMs < minValidTime { return false, nil } - if entryTime.IsZero() || entryTime.Year() < 2000 { - entryTime = exitTime + if entryTimeMs < minValidTime { + entryTimeMs = exitTimeMs } - if entryTime.After(exitTime) { - entryTime = exitTime + if entryTimeMs > exitTimeMs { + entryTimeMs = exitTimeMs } + nowMs := time.Now().UTC().UnixMilli() pos := &TraderPosition{ TraderID: traderID, ExchangeID: exchangeID, @@ -988,16 +1016,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s Quantity: record.Quantity, EntryQuantity: record.Quantity, EntryPrice: record.EntryPrice, - EntryTime: entryTime, + EntryTime: entryTimeMs, ExitPrice: record.ExitPrice, ExitOrderID: record.OrderID, - ExitTime: &exitTime, + ExitTime: exitTimeMs, RealizedPnL: record.RealizedPnL, Fee: record.Fee, Leverage: record.Leverage, Status: "CLOSED", CloseReason: record.CloseType, Source: "sync", + CreatedAt: nowMs, + UpdatedAt: nowMs, } err = s.db.Create(pos).Error @@ -1011,21 +1041,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s return true, nil } -// GetLastClosedPositionTime gets the most recent exit time -func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) { +// GetLastClosedPositionTime gets the most recent exit time (Unix ms) +func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) { var pos TraderPosition - err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED"). + err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED"). Order("exit_time DESC"). First(&pos).Error - if err == gorm.ErrRecordNotFound || pos.ExitTime == nil { - return time.Now().Add(-30 * 24 * time.Hour), nil + if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 { + return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil } if err != nil { - return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err) + return 0, fmt.Errorf("failed to get last closed position time: %w", err) } - return *pos.ExitTime, nil + return pos.ExitTime, nil } // CreateOpenPosition creates an open position @@ -1076,15 +1106,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { } // 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 { +// exitTimeMs is Unix milliseconds UTC +func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error { return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ "exit_price": exitPrice, "exit_order_id": exitOrderID, - "exit_time": exitTime, + "exit_time": exitTimeMs, "realized_pnl": realizedPnL, "fee": fee, "status": "CLOSED", "close_reason": closeReason, + "updated_at": time.Now().UTC().UnixMilli(), }).Error } diff --git a/store/position_builder.go b/store/position_builder.go index 880928fe..d4b4a06e 100644 --- a/store/position_builder.go +++ b/store/position_builder.go @@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder { } // ProcessTrade processes a single trade and updates position accordingly +// tradeTimeMs is Unix milliseconds UTC func (pb *PositionBuilder) ProcessTrade( traderID, exchangeID, exchangeType, symbol, side, action string, quantity, price, fee, realizedPnL float64, - tradeTime time.Time, + tradeTimeMs int64, orderID string, ) error { if strings.HasPrefix(action, "open_") { - return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID) + return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID) } else if strings.HasPrefix(action, "close_") { - return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID) + return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID) } return nil } // handleOpen handles opening positions (create new or average into existing) +// tradeTimeMs is Unix milliseconds UTC func (pb *PositionBuilder) handleOpen( traderID, exchangeID, exchangeType, symbol, side string, quantity, price, fee float64, - tradeTime time.Time, + tradeTimeMs int64, orderID string, ) error { // Get existing OPEN position for (symbol, side) @@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen( return fmt.Errorf("failed to get open position: %w", err) } + nowMs := time.Now().UTC().UnixMilli() if existing == nil { // Create new position position := &TraderPosition{ TraderID: traderID, ExchangeID: exchangeID, ExchangeType: exchangeType, - ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()), + ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs), Symbol: symbol, Side: side, Quantity: quantity, EntryPrice: price, EntryOrderID: orderID, - EntryTime: tradeTime, + EntryTime: tradeTimeMs, Leverage: 1, Status: "OPEN", Source: "sync", Fee: fee, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + CreatedAt: nowMs, + UpdatedAt: nowMs, } return pb.positionStore.CreateOpenPosition(position) } @@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen( } // handleClose handles closing positions (partial or full) +// tradeTimeMs is Unix milliseconds UTC func (pb *PositionBuilder) handleClose( traderID, exchangeID, exchangeType, symbol, side string, quantity, price, fee, realizedPnL float64, - tradeTime time.Time, + tradeTimeMs int64, orderID string, ) error { // Get OPEN position @@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose( position.ID, finalExitPrice, orderID, - tradeTime, + tradeTimeMs, totalPnL, totalFee, "sync", diff --git a/trader/aster_order_sync.go b/trader/aster_order_sync.go index e993138c..f7d7d526 100644 --- a/trader/aster_order_sync.go +++ b/trader/aster_order_sync.go @@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].Time.Before(trades[j].Time) + return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -68,8 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex // Normalize side for storage side := strings.ToUpper(trade.Side) - // Create order record - use UTC time to avoid timezone issues - tradeTimeUTC := trade.Time.UTC() + // Create order record - use Unix milliseconds UTC + tradeTimeMs := trade.Time.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -86,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex FilledQuantity: trade.Quantity, AvgFillPrice: trade.Price, Commission: trade.Fee, - FilledAt: tradeTimeUTC, - CreatedAt: tradeTimeUTC, - UpdatedAt: tradeTimeUTC, + FilledAt: tradeTimeMs, + CreatedAt: tradeTimeMs, + UpdatedAt: tradeTimeMs, } // Insert order record @@ -97,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex continue } - // Create fill record - use UTC time + // Create fill record - use Unix milliseconds UTC fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -114,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex CommissionAsset: "USDT", RealizedPnL: trade.RealizedPnL, IsMaker: false, - CreatedAt: tradeTimeUTC, + CreatedAt: tradeTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -126,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex traderID, exchangeID, exchangeType, symbol, positionSide, orderAction, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, - trade.Time, trade.TradeID, + tradeTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { diff --git a/trader/auto_trader.go b/trader/auto_trader.go index e95bb8a6..72ee4d35 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -744,8 +744,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { // 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() + if dbPos.EntryTime > 0 { + updateTime = dbPos.EntryTime } } } @@ -1967,6 +1967,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, switch action { case "open_long", "open_short": // Open position: create new position record + nowMs := time.Now().UTC().UnixMilli() pos := &store.TraderPosition{ TraderID: at.id, ExchangeID: at.exchangeID, // Exchange account UUID @@ -1976,9 +1977,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, Quantity: quantity, EntryPrice: price, EntryOrderID: orderID, - EntryTime: time.Now().UTC(), + EntryTime: nowMs, Leverage: leverage, Status: "OPEN", + CreatedAt: nowMs, + UpdatedAt: nowMs, } if err := at.store.Position().Create(pos); err != nil { logger.Infof(" ⚠️ Failed to record position: %v", err) @@ -1996,7 +1999,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, at.id, at.exchangeID, at.exchange, symbol, side, action, quantity, price, fee, 0, // realizedPnL will be calculated - time.Now().UTC(), orderID, + time.Now().UTC().UnixMilli(), orderID, ); err != nil { logger.Infof(" ⚠️ Failed to process close position: %v", err) } else { @@ -2049,8 +2052,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st ReduceOnly: reduceOnly, ClosePosition: reduceOnly, OrderAction: orderAction, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC().UnixMilli(), + UpdatedAt: time.Now().UTC().UnixMilli(), } } @@ -2091,7 +2094,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb CommissionAsset: "USDT", RealizedPnL: 0, // Will be calculated for close orders IsMaker: false, // Market orders are usually taker - CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC().UnixMilli(), } // Calculate realized PnL for close orders diff --git a/trader/binance_futures.go b/trader/binance_futures.go index 72d1de67..6a9ca2ff 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -1244,3 +1244,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string, return symbols, nil } + +// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime +// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount) +func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) { + incomes, err := t.client.NewGetIncomeHistoryService(). + IncomeType("REALIZED_PNL"). + StartTime(lastSyncTime.UnixMilli()). + Limit(1000). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get PnL history: %w", err) + } + + symbolMap := make(map[string]bool) + for _, income := range incomes { + if income.Symbol != "" { + symbolMap[income.Symbol] = true + } + } + + var symbols []string + for symbol := range symbolMap { + symbols = append(symbols, symbol) + } + + return symbols, nil +} diff --git a/trader/binance_order_sync.go b/trader/binance_order_sync.go index 6fe685fe..0c0d14ac 100644 --- a/trader/binance_order_sync.go +++ b/trader/binance_order_sync.go @@ -11,9 +11,9 @@ import ( "time" ) -// syncState stores the last sync time for incremental sync +// syncState stores the last sync time (Unix ms) for incremental sync var ( - binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime + binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms) binanceSyncStateMutex sync.RWMutex ) @@ -25,42 +25,106 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string return fmt.Errorf("store is nil") } - // Get last sync time (default to 24 hours ago for first sync) + orderStore := st.Order() + + // Get last sync time (Unix ms) - first try memory, then database, then default binanceSyncStateMutex.RLock() - lastSyncTime, exists := binanceSyncState[exchangeID] + lastSyncTimeMs, exists := binanceSyncState[exchangeID] binanceSyncStateMutex.RUnlock() + nowMs := time.Now().UTC().UnixMilli() if !exists { - lastSyncTime = time.Now().Add(-24 * time.Hour) + // Try to get last fill time from database (persist across restarts) + lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID) + if err == nil && lastFillTimeMs > 0 { + // If recovered time is in the future, it's clearly wrong - use default + if lastFillTimeMs > nowMs { + logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default", + lastFillTimeMs, nowMs) + lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago + } else { + // Add 1 second buffer to avoid re-fetching the same fill + lastSyncTimeMs = lastFillTimeMs + 1000 + logger.Infof("📅 Recovered last sync time from DB: %s (UTC)", + time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05")) + } + } else { + // First sync: go back 24 hours + lastSyncTimeMs = nowMs - 24*60*60*1000 + logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)", + time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05")) + } } // Record current time BEFORE querying, to avoid missing trades during sync // This prevents race condition where trades happen between query and lastSyncTime update - syncStartTime := time.Now() + syncStartTimeMs := nowMs - logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339)) + logger.Infof("🔄 Syncing Binance trades from: %s (UTC)", + time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05")) // Step 1: Get max trade IDs from local DB for incremental sync - orderStore := st.Order() maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID) if err != nil { logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err) maxTradeIDs = make(map[string]int64) } - // Step 2: Use COMMISSION to detect which symbols have new trades (1 API call) - changedSymbols, err := t.GetCommissionSymbols(lastSyncTime) + // Step 2: Detect symbols to sync using multiple methods + // COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades) + symbolMap := make(map[string]bool) + lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls + + // Method 1: COMMISSION income detection + commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime) if err != nil { - logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err) - // Fallback: only sync symbols with active positions - changedSymbols = t.getPositionSymbols() + logger.Infof(" ⚠️ Failed to get commission symbols: %v", err) + } else { + logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols) + for _, s := range commissionSymbols { + symbolMap[s] = true + } + } + + // Method 2: Always include active positions (catches trades that COMMISSION missed) + positionSymbols := t.getPositionSymbols() + logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols) + for _, s := range positionSymbols { + symbolMap[s] = true + } + + // Method 3: Include symbols from recent fills in DB (in case some were partially synced) + recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs) + logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols) + for _, s := range recentSymbols { + symbolMap[s] = true + } + + // Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades + // This catches trades that COMMISSION missed (VIP users, BNB fee discount) + if len(symbolMap) == 0 { + logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...") + pnlSymbols, err := t.GetPnLSymbols(lastSyncTime) + if err != nil { + logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err) + } else { + logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols) + for _, s := range pnlSymbols { + symbolMap[s] = true + } + } + } + + var changedSymbols []string + for s := range symbolMap { + changedSymbols = append(changedSymbols, s) } if len(changedSymbols) == 0 { logger.Infof("📭 No symbols with new trades to sync") // Update last sync time even if no changes binanceSyncStateMutex.Lock() - binanceSyncState[exchangeID] = syncStartTime + binanceSyncState[exchangeID] = syncStartTimeMs binanceSyncStateMutex.Unlock() return nil } @@ -98,7 +162,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string // This prevents data loss when some symbols fail due to rate limit or network issues if len(failedSymbols) == 0 { binanceSyncStateMutex.Lock() - binanceSyncState[exchangeID] = syncStartTime + binanceSyncState[exchangeID] = syncStartTimeMs binanceSyncStateMutex.Unlock() } else { logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols) @@ -110,7 +174,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string // Sort trades by time ASC (oldest first) for proper position building sort.Slice(allTrades, func(i, j int) bool { - return allTrades[i].Time.Before(allTrades[j].Time) + return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli() }) // Process trades one by one @@ -145,8 +209,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string // Normalize side side := strings.ToUpper(trade.Side) - // Create order record - use UTC time to avoid timezone issues - tradeTimeUTC := trade.Time.UTC() + // Create order record - use Unix milliseconds UTC + tradeTimeMs := trade.Time.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, @@ -163,9 +227,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string FilledQuantity: trade.Quantity, AvgFillPrice: trade.Price, Commission: trade.Fee, - FilledAt: tradeTimeUTC, - CreatedAt: tradeTimeUTC, - UpdatedAt: tradeTimeUTC, + FilledAt: tradeTimeMs, + CreatedAt: tradeTimeMs, + UpdatedAt: tradeTimeMs, } // Insert order record @@ -174,7 +238,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string continue } - // Create fill record - use UTC time + // Create fill record - use Unix milliseconds UTC fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, @@ -191,7 +255,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string CommissionAsset: "USDT", RealizedPnL: trade.RealizedPnL, IsMaker: false, - CreatedAt: tradeTimeUTC, + CreatedAt: tradeTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -203,7 +267,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string traderID, exchangeID, exchangeType, symbol, positionSide, orderAction, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, - trade.Time, trade.TradeID, + tradeTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { @@ -211,8 +275,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string } syncedCount++ - logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", - trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction) + logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)", + trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction, + trade.Time.UTC().Format("01-02 15:04:05")) } logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount) @@ -279,6 +344,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized // StartOrderSync starts background order sync task for Binance func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { + // Run first sync immediately + go func() { + logger.Infof("🔄 Running initial Binance order sync...") + if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil { + logger.Infof("⚠️ Initial Binance order sync failed: %v", err) + } + }() + + // Then run periodically ticker := time.NewTicker(interval) go func() { for range ticker.C { diff --git a/trader/binance_order_sync_test.go b/trader/binance_order_sync_test.go new file mode 100644 index 00000000..c46ce07f --- /dev/null +++ b/trader/binance_order_sync_test.go @@ -0,0 +1,461 @@ +package trader + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +func skipIfNoLiveTest(t *testing.T) { + if os.Getenv("BINANCE_LIVE_TEST") != "1" { + t.Skip("Skipping live test. Set BINANCE_LIVE_TEST=1 to run") + } +} + +func getBinanceTestCredentials(t *testing.T) (string, string) { + apiKey := os.Getenv("BINANCE_TEST_API_KEY") + secretKey := os.Getenv("BINANCE_TEST_SECRET_KEY") + if apiKey == "" || secretKey == "" { + t.Skip("Skipping test. Set BINANCE_TEST_API_KEY and BINANCE_TEST_SECRET_KEY env vars") + } + return apiKey, secretKey +} + +func createBinanceTestTrader(t *testing.T) *FuturesTrader { + apiKey, secretKey := getBinanceTestCredentials(t) + trader := NewFuturesTrader(apiKey, secretKey, "test-user") + return trader +} + +// TestBinanceConnection tests basic API connectivity +func TestBinanceConnection(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + balance, err := trader.GetBalance() + if err != nil { + t.Fatalf("Failed to get balance: %v", err) + } + t.Logf("✅ Connection OK - Balance: %v", balance) +} + +// TestBinanceGetPositions tests position retrieval +func TestBinanceGetPositions(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + positions, err := trader.GetPositions() + if err != nil { + t.Fatalf("Failed to get positions: %v", err) + } + + t.Logf("📊 Found %d positions with non-zero amount:", len(positions)) + for i, pos := range positions { + symbol := pos["symbol"].(string) + side := pos["side"].(string) + posAmt := pos["positionAmt"].(float64) + entryPrice := pos["entryPrice"].(float64) + unrealizedPnl := pos["unRealizedProfit"].(float64) + + t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f pnl=%.4f", + i+1, symbol, side, posAmt, entryPrice, unrealizedPnl) + } +} + +// TestBinanceGetCommissionSymbols tests COMMISSION income detection +func TestBinanceGetCommissionSymbols(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + // Test different time ranges + timeRanges := []struct { + name string + duration time.Duration + }{ + {"1 hour", 1 * time.Hour}, + {"24 hours", 24 * time.Hour}, + {"7 days", 7 * 24 * time.Hour}, + {"30 days", 30 * 24 * time.Hour}, + } + + for _, tr := range timeRanges { + startTime := time.Now().Add(-tr.duration) + symbols, err := trader.GetCommissionSymbols(startTime) + if err != nil { + t.Logf("❌ %s: Failed to get commission symbols: %v", tr.name, err) + continue + } + t.Logf("📋 %s: COMMISSION symbols = %d - %v", tr.name, len(symbols), symbols) + } +} + +// TestBinanceGetPnLSymbols tests REALIZED_PNL income detection +func TestBinanceGetPnLSymbols(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + timeRanges := []struct { + name string + duration time.Duration + }{ + {"1 hour", 1 * time.Hour}, + {"24 hours", 24 * time.Hour}, + {"7 days", 7 * 24 * time.Hour}, + {"30 days", 30 * 24 * time.Hour}, + } + + for _, tr := range timeRanges { + startTime := time.Now().Add(-tr.duration) + symbols, err := trader.GetPnLSymbols(startTime) + if err != nil { + t.Logf("❌ %s: Failed to get PnL symbols: %v", tr.name, err) + continue + } + t.Logf("📋 %s: REALIZED_PNL symbols = %d - %v", tr.name, len(symbols), symbols) + } +} + +// TestBinanceGetAllIncomeTypes tests all income types to understand data availability +func TestBinanceGetAllIncomeTypes(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + // All possible income types from Binance API + incomeTypes := []string{ + "TRANSFER", + "WELCOME_BONUS", + "REALIZED_PNL", + "FUNDING_FEE", + "COMMISSION", + "INSURANCE_CLEAR", + "REFERRAL_KICKBACK", + "COMMISSION_REBATE", + "API_REBATE", + "CONTEST_REWARD", + "CROSS_COLLATERAL_TRANSFER", + "OPTIONS_PREMIUM_FEE", + "OPTIONS_SETTLE_PROFIT", + "INTERNAL_TRANSFER", + "AUTO_EXCHANGE", + "DELIVERED_SETTELMENT", + "COIN_SWAP_DEPOSIT", + "COIN_SWAP_WITHDRAW", + "POSITION_LIMIT_INCREASE_FEE", + } + + startTime := time.Now().Add(-7 * 24 * time.Hour) + t.Logf("🔍 Checking all income types from %s:", startTime.Format(time.RFC3339)) + + for _, incomeType := range incomeTypes { + incomes, err := trader.client.NewGetIncomeHistoryService(). + IncomeType(incomeType). + StartTime(startTime.UnixMilli()). + Limit(100). + Do(context.Background()) + if err != nil { + t.Logf(" ❌ %s: error - %v", incomeType, err) + continue + } + + if len(incomes) > 0 { + symbolMap := make(map[string]int) + for _, inc := range incomes { + if inc.Symbol != "" { + symbolMap[inc.Symbol]++ + } + } + t.Logf(" ✅ %s: %d records, symbols: %v", incomeType, len(incomes), symbolMap) + } else { + t.Logf(" ⚪ %s: 0 records", incomeType) + } + } +} + +// TestBinanceGetTradesForSymbol tests trade retrieval for specific symbols +func TestBinanceGetTradesForSymbol(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + // Common trading pairs + symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"} + startTime := time.Now().Add(-7 * 24 * time.Hour) + + t.Logf("🔍 Checking trades for common symbols from %s:", startTime.Format(time.RFC3339)) + + for _, symbol := range symbols { + trades, err := trader.GetTradesForSymbol(symbol, startTime, 100) + if err != nil { + t.Logf(" ❌ %s: error - %v", symbol, err) + continue + } + + if len(trades) > 0 { + t.Logf(" ✅ %s: %d trades", symbol, len(trades)) + // Print first and last trade + first := trades[0] + last := trades[len(trades)-1] + t.Logf(" First: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s", + first.TradeID, first.Symbol, first.Side, + first.Quantity, first.Price, first.RealizedPnL, + first.Time.Format(time.RFC3339)) + if len(trades) > 1 { + t.Logf(" Last: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s", + last.TradeID, last.Symbol, last.Side, + last.Quantity, last.Price, last.RealizedPnL, + last.Time.Format(time.RFC3339)) + } + } else { + t.Logf(" ⚪ %s: 0 trades", symbol) + } + } +} + +// TestBinanceTimestampFormats tests different timestamp formats +func TestBinanceTimestampFormats(t *testing.T) { + skipIfNoLiveTest(t) + + now := time.Now() + nowUTC := time.Now().UTC() + + t.Logf("🕐 Time comparison:") + t.Logf(" time.Now(): %s (UnixMilli: %d)", now.Format(time.RFC3339), now.UnixMilli()) + t.Logf(" time.Now().UTC(): %s (UnixMilli: %d)", nowUTC.Format(time.RFC3339), nowUTC.UnixMilli()) + t.Logf(" Difference: %v", now.Sub(nowUTC)) + + // The key insight: UnixMilli() should be the SAME regardless of timezone + if now.UnixMilli() != nowUTC.UnixMilli() { + t.Errorf("❌ UnixMilli() differs between local and UTC! This should never happen.") + } else { + t.Logf(" ✅ UnixMilli() is the same (correct behavior)") + } + + // Test what happens when we parse a time stored in DB + // Simulate old DB value stored in local time + oldLocalTime := time.Date(2026, 1, 6, 18, 0, 0, 0, time.Local) // 18:00 local + oldLocalTimeAsUTC := time.Date(2026, 1, 6, 18, 0, 0, 0, time.UTC) // Same numbers but UTC + + t.Logf("\n🔍 Timezone mismatch scenario:") + t.Logf(" Old DB time (local): %s (UnixMilli: %d)", oldLocalTime.Format(time.RFC3339), oldLocalTime.UnixMilli()) + t.Logf(" Same time parsed as UTC: %s (UnixMilli: %d)", oldLocalTimeAsUTC.Format(time.RFC3339), oldLocalTimeAsUTC.UnixMilli()) + t.Logf(" Difference: %v", time.Duration(oldLocalTimeAsUTC.UnixMilli()-oldLocalTime.UnixMilli())*time.Millisecond) + + // If server is in +8 timezone, the difference should be 8 hours + _, offset := now.Zone() + t.Logf(" Local timezone offset: %d seconds (%d hours)", offset, offset/3600) +} + +// TestBinanceFullSyncSimulation simulates the full sync process +func TestBinanceFullSyncSimulation(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + t.Logf("🔄 Simulating full sync process...") + + // Step 1: Determine lastSyncTime (simulating first run) + lastSyncTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + t.Logf("\n📅 Step 1: lastSyncTime = %s", lastSyncTime.Format(time.RFC3339)) + + // Step 2: Detect symbols using all methods + symbolMap := make(map[string]bool) + + // Method 1: COMMISSION + commissionSymbols, err := trader.GetCommissionSymbols(lastSyncTime) + if err != nil { + t.Logf(" ⚠️ COMMISSION failed: %v", err) + } else { + t.Logf(" 📋 COMMISSION symbols: %d - %v", len(commissionSymbols), commissionSymbols) + for _, s := range commissionSymbols { + symbolMap[s] = true + } + } + + // Method 2: Positions + positions, err := trader.GetPositions() + if err != nil { + t.Logf(" ⚠️ GetPositions failed: %v", err) + } else { + var posSymbols []string + for _, pos := range positions { + if symbol, ok := pos["symbol"].(string); ok && symbol != "" { + posSymbols = append(posSymbols, symbol) + symbolMap[symbol] = true + } + } + t.Logf(" 📋 Position symbols: %d - %v", len(posSymbols), posSymbols) + } + + // Method 3: REALIZED_PNL (fallback) + pnlSymbols, err := trader.GetPnLSymbols(lastSyncTime) + if err != nil { + t.Logf(" ⚠️ REALIZED_PNL failed: %v", err) + } else { + t.Logf(" 📋 REALIZED_PNL symbols: %d - %v", len(pnlSymbols), pnlSymbols) + for _, s := range pnlSymbols { + symbolMap[s] = true + } + } + + // Collect all symbols + var allSymbols []string + for s := range symbolMap { + allSymbols = append(allSymbols, s) + } + t.Logf("\n📊 Step 2: Total unique symbols to sync: %d - %v", len(allSymbols), allSymbols) + + if len(allSymbols) == 0 { + t.Logf("❌ No symbols found! This is the bug - nothing to sync") + t.Logf("\n🔍 Investigating why no symbols found...") + + // Try to query all income (without type filter) to see if there's ANY activity + incomes, err := trader.client.NewGetIncomeHistoryService(). + StartTime(lastSyncTime.UnixMilli()). + Limit(100). + Do(context.Background()) + if err != nil { + t.Logf(" Failed to get all income: %v", err) + } else { + t.Logf(" All income records (no type filter): %d", len(incomes)) + typeCount := make(map[string]int) + for _, inc := range incomes { + typeCount[inc.IncomeType]++ + } + t.Logf(" Income types breakdown: %v", typeCount) + } + return + } + + // Step 3: Query trades for each symbol + t.Logf("\n📥 Step 3: Querying trades for each symbol...") + totalTrades := 0 + for _, symbol := range allSymbols { + trades, err := trader.GetTradesForSymbol(symbol, lastSyncTime, 500) + if err != nil { + t.Logf(" ❌ %s: error - %v", symbol, err) + continue + } + totalTrades += len(trades) + t.Logf(" ✅ %s: %d trades", symbol, len(trades)) + + // Print sample trades + for i, trade := range trades { + if i >= 3 { + t.Logf(" ... and %d more trades", len(trades)-3) + break + } + t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f fee=%.6f time=%s", + i+1, trade.TradeID, trade.Symbol, trade.Side, + trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, + trade.Time.Format(time.RFC3339)) + } + } + + t.Logf("\n✅ Sync simulation complete: %d total trades found across %d symbols", + totalTrades, len(allSymbols)) +} + +// TestBinanceTradeIDRange tests trade ID ranges to understand the data +func TestBinanceTradeIDRange(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + // First find symbols with trades + startTime := time.Now().Add(-30 * 24 * time.Hour) + commissionSymbols, _ := trader.GetCommissionSymbols(startTime) + pnlSymbols, _ := trader.GetPnLSymbols(startTime) + + symbolMap := make(map[string]bool) + for _, s := range commissionSymbols { + symbolMap[s] = true + } + for _, s := range pnlSymbols { + symbolMap[s] = true + } + + if len(symbolMap) == 0 { + t.Log("No symbols with activity found") + return + } + + t.Logf("🔍 Checking trade ID ranges for symbols with activity:") + + for symbol := range symbolMap { + trades, err := trader.GetTradesForSymbol(symbol, startTime, 100) + if err != nil || len(trades) == 0 { + continue + } + + var minID, maxID int64 = 1<<62, 0 + for _, trade := range trades { + var id int64 + fmt.Sscanf(trade.TradeID, "%d", &id) + if id < minID { + minID = id + } + if id > maxID { + maxID = id + } + } + + t.Logf(" %s: %d trades, ID range [%d - %d]", symbol, len(trades), minID, maxID) + + // Check if any ID exceeds PostgreSQL INTEGER max + if maxID > 2147483647 { + t.Logf(" ⚠️ Max trade ID %d exceeds PostgreSQL INTEGER max (2147483647)", maxID) + } + } +} + +// TestBinanceIncomeAPIDirectCall makes direct API call to understand response +func TestBinanceIncomeAPIDirectCall(t *testing.T) { + skipIfNoLiveTest(t) + trader := createBinanceTestTrader(t) + + startTime := time.Now().Add(-24 * time.Hour) + t.Logf("🔍 Direct income API call from %s:", startTime.Format(time.RFC3339)) + t.Logf(" StartTime UnixMilli: %d", startTime.UnixMilli()) + + // Call without income type filter to get ALL income + incomes, err := trader.client.NewGetIncomeHistoryService(). + StartTime(startTime.UnixMilli()). + Limit(1000). + Do(context.Background()) + if err != nil { + t.Fatalf("Failed to get income: %v", err) + } + + t.Logf("📋 Total income records: %d", len(incomes)) + + // Group by type and symbol + typeSymbolCount := make(map[string]map[string]int) + for _, inc := range incomes { + if typeSymbolCount[inc.IncomeType] == nil { + typeSymbolCount[inc.IncomeType] = make(map[string]int) + } + typeSymbolCount[inc.IncomeType][inc.Symbol]++ + } + + for incType, symbols := range typeSymbolCount { + t.Logf(" %s:", incType) + for symbol, count := range symbols { + if symbol == "" { + symbol = "(no symbol)" + } + t.Logf(" %s: %d records", symbol, count) + } + } + + // Print sample records + if len(incomes) > 0 { + t.Logf("\n📝 Sample income records (first 5):") + for i, inc := range incomes { + if i >= 5 { + break + } + t.Logf(" [%d] Type=%s Symbol=%s Amount=%s Time=%s", + i+1, inc.IncomeType, inc.Symbol, inc.Income, + time.UnixMilli(inc.Time).Format(time.RFC3339)) + } + } +} diff --git a/trader/binance_sync_e2e_test.go b/trader/binance_sync_e2e_test.go new file mode 100644 index 00000000..9024b43b --- /dev/null +++ b/trader/binance_sync_e2e_test.go @@ -0,0 +1,216 @@ +package trader + +import ( + "nofx/store" + "os" + "testing" + "time" +) + +// TestBinanceSyncE2E tests the complete sync flow end-to-end +func TestBinanceSyncE2E(t *testing.T) { + skipIfNoLiveTest(t) + + // Get credentials from environment + apiKey, secretKey := getBinanceTestCredentials(t) + + // Create test database using full store initialization (includes table creation) + testDBPath := "/tmp/test_binance_sync.db" + os.Remove(testDBPath) // Clean up previous test + + st, err := store.New(testDBPath) + if err != nil { + t.Fatalf("Failed to init test store: %v", err) + } + db := st.GormDB() + + // Create trader + trader := NewFuturesTrader(apiKey, secretKey, "test-user") + + // Test parameters + traderID := "test-trader-id" + exchangeID := "test-exchange-id" + exchangeType := "binance" + + t.Logf("🧪 Running end-to-end sync test...") + t.Logf(" DB Path: %s", testDBPath) + + // Run sync + t.Logf("\n📥 Running SyncOrdersFromBinance...") + startTime := time.Now() + err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st) + elapsed := time.Since(startTime) + + if err != nil { + t.Fatalf("❌ Sync failed: %v", err) + } + t.Logf("✅ Sync completed in %v", elapsed) + + // Check results in database + orderStore := st.Order() + + // Count orders + var orderCount int64 + db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&orderCount) + t.Logf("\n📊 Results:") + t.Logf(" Orders in DB: %d", orderCount) + + // Count fills + var fillCount int64 + db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount) + t.Logf(" Fills in DB: %d", fillCount) + + // Get symbols + var symbols []string + db.Model(&store.TraderFill{}). + Select("DISTINCT symbol"). + Where("exchange_id = ?", exchangeID). + Pluck("symbol", &symbols) + t.Logf(" Unique symbols: %d - %v", len(symbols), symbols) + + // Check max trade IDs (test the fix) + maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID) + if err != nil { + t.Logf(" ⚠️ GetMaxTradeIDsByExchange error: %v", err) + } else { + t.Logf(" Max trade IDs per symbol:") + for symbol, maxID := range maxTradeIDs { + if maxID > 2147483647 { + t.Logf(" %s: %d (⚠️ exceeds PostgreSQL INTEGER max)", symbol, maxID) + } else { + t.Logf(" %s: %d", symbol, maxID) + } + } + } + + // Sample some orders + var sampleOrders []store.TraderOrder + db.Where("exchange_id = ?", exchangeID).Limit(5).Find(&sampleOrders) + if len(sampleOrders) > 0 { + t.Logf("\n📝 Sample orders:") + for i, order := range sampleOrders { + t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s", + i+1, order.ExchangeOrderID, order.Symbol, order.Side, + order.Quantity, order.Price, order.OrderAction, + order.FilledAt.Format(time.RFC3339)) + } + } + + // Test incremental sync - run again, should find no new trades + t.Logf("\n🔄 Running incremental sync (should skip existing trades)...") + startTime = time.Now() + err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st) + elapsed = time.Since(startTime) + if err != nil { + t.Fatalf("❌ Incremental sync failed: %v", err) + } + t.Logf("✅ Incremental sync completed in %v", elapsed) + + // Check counts again - should be the same + var newOrderCount int64 + db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&newOrderCount) + t.Logf(" Orders after incremental sync: %d (was %d)", newOrderCount, orderCount) + + if newOrderCount != orderCount { + t.Logf(" ⚠️ Order count changed - possible duplicate detection issue") + } else { + t.Logf(" ✅ No duplicates - incremental sync working correctly") + } + + // Test GetLastFillTimeByExchange + lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID) + if err != nil { + t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err) + } else { + t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339)) + + // Check if it would be in the future (the bug we fixed) + now := time.Now().UTC() + if lastFillTime.After(now) { + t.Logf(" ❌ BUG: Last fill time is in the future! (now: %s)", now.Format(time.RFC3339)) + } else { + t.Logf(" ✅ Last fill time is in the past (correct)") + } + } + + // Cleanup + os.Remove(testDBPath) + t.Logf("\n✅ E2E test completed successfully!") +} + +// TestBinanceSyncWithExistingData tests sync behavior with pre-existing data +func TestBinanceSyncWithExistingData(t *testing.T) { + skipIfNoLiveTest(t) + + // Get credentials from environment + apiKey, secretKey := getBinanceTestCredentials(t) + + testDBPath := "/tmp/test_binance_sync_existing.db" + os.Remove(testDBPath) + + st, err := store.New(testDBPath) + if err != nil { + t.Fatalf("Failed to init test store: %v", err) + } + db := st.GormDB() + orderStore := st.Order() + + trader := NewFuturesTrader(apiKey, secretKey, "test-user") + + traderID := "test-trader-id" + exchangeID := "test-exchange-id" + exchangeType := "binance" + + // Insert a fake "old" fill with LOCAL time (simulating the bug scenario) + // This tests that our timezone fix works + localTime := time.Now().Add(8 * time.Hour) // Simulate +8 timezone stored as if it were UTC + fakeFill := &store.TraderFill{ + TraderID: traderID, + ExchangeID: exchangeID, + ExchangeType: exchangeType, + ExchangeOrderID: "fake-old-order", + ExchangeTradeID: "fake-old-trade", + Symbol: "BTCUSDT", + Side: "BUY", + Price: 50000, + Quantity: 0.001, + QuoteQuantity: 50, + CreatedAt: localTime, // This time is "in the future" if interpreted as UTC + } + if err := orderStore.CreateFill(fakeFill); err != nil { + t.Fatalf("Failed to create fake fill: %v", err) + } + + t.Logf("🧪 Testing sync with existing 'future' data...") + t.Logf(" Fake fill time: %s", localTime.Format(time.RFC3339)) + t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339)) + + // Check GetLastFillTimeByExchange + lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID) + t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339)) + + if lastFillTime.After(time.Now().UTC()) { + t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!") + } + + // Run sync - it should detect the future time and fall back + t.Logf("\n📥 Running sync (should detect future time and fall back)...") + err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st) + if err != nil { + t.Fatalf("❌ Sync failed: %v", err) + } + t.Logf("✅ Sync completed") + + // Check that trades were actually synced despite the bad data + var fillCount int64 + db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount) + t.Logf(" Total fills in DB: %d (includes 1 fake)", fillCount) + + if fillCount > 1 { + t.Logf(" ✅ Real trades were synced despite 'future' data!") + } else { + t.Logf(" ❌ No real trades synced - the bug might still exist") + } + + os.Remove(testDBPath) +} diff --git a/trader/binance_sync_verify_test.go b/trader/binance_sync_verify_test.go new file mode 100644 index 00000000..2c05461d --- /dev/null +++ b/trader/binance_sync_verify_test.go @@ -0,0 +1,511 @@ +package trader + +import ( + "context" + "math" + "nofx/store" + "os" + "sort" + "strings" + "testing" + "time" +) + +func repeatStr(s string, n int) string { + return strings.Repeat(s, n) +} + +// TestBinanceSyncVerification verifies synced data matches exchange data exactly +func TestBinanceSyncVerification(t *testing.T) { + skipIfNoLiveTest(t) + + // Get credentials from environment + apiKey, secretKey := getBinanceTestCredentials(t) + + // Create test database + testDBPath := "/tmp/test_binance_verify.db" + os.Remove(testDBPath) + + st, err := store.New(testDBPath) + if err != nil { + t.Fatalf("Failed to init test store: %v", err) + } + db := st.GormDB() + + trader := NewFuturesTrader(apiKey, secretKey, "test-user") + + traderID := "test-trader-id" + exchangeID := "test-exchange-id" + exchangeType := "binance" + + // Step 1: Run sync + t.Logf("%s", repeatStr("=", 60)) + t.Logf("STEP 1: Running order sync...") + t.Logf("%s", repeatStr("=", 60)) + + err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + + // Step 2: Get all trades from exchange for verification + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 2: Fetching trades from exchange for verification...") + t.Logf("%s", repeatStr("=", 60)) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + + // Get symbols from DB + var symbols []string + db.Model(&store.TraderFill{}). + Select("DISTINCT symbol"). + Where("exchange_id = ?", exchangeID). + Pluck("symbol", &symbols) + + t.Logf("Symbols to verify: %v", symbols) + + // Fetch all trades from exchange + type ExchangeTrade struct { + TradeID string + Symbol string + Side string + Price float64 + Quantity float64 + Fee float64 + RealizedPnL float64 + Time time.Time + } + + var exchangeTrades []ExchangeTrade + for _, symbol := range symbols { + trades, err := trader.GetTradesForSymbol(symbol, startTime, 1000) + if err != nil { + t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err) + continue + } + for _, trade := range trades { + exchangeTrades = append(exchangeTrades, ExchangeTrade{ + TradeID: trade.TradeID, + Symbol: trade.Symbol, + Side: trade.Side, + Price: trade.Price, + Quantity: trade.Quantity, + Fee: trade.Fee, + RealizedPnL: trade.RealizedPnL, + Time: trade.Time, + }) + } + } + + t.Logf("Total trades from exchange: %d", len(exchangeTrades)) + + // Step 3: Get all fills from DB + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 3: Comparing with local database...") + t.Logf("%s", repeatStr("=", 60)) + + var dbFills []store.TraderFill + db.Where("exchange_id = ?", exchangeID).Find(&dbFills) + + t.Logf("Total fills in DB: %d", len(dbFills)) + + // Create maps for comparison + exchangeTradeMap := make(map[string]ExchangeTrade) + for _, t := range exchangeTrades { + exchangeTradeMap[t.TradeID] = t + } + + dbFillMap := make(map[string]store.TraderFill) + for _, f := range dbFills { + dbFillMap[f.ExchangeTradeID] = f + } + + // Step 4: Check for missing trades + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 4: Checking for MISSING trades (in exchange but not in DB)...") + t.Logf("%s", repeatStr("=", 60)) + + var missingTrades []ExchangeTrade + for tradeID, trade := range exchangeTradeMap { + if _, exists := dbFillMap[tradeID]; !exists { + missingTrades = append(missingTrades, trade) + } + } + + if len(missingTrades) > 0 { + t.Logf("❌ MISSING %d trades:", len(missingTrades)) + for i, trade := range missingTrades { + if i >= 10 { + t.Logf(" ... and %d more", len(missingTrades)-10) + break + } + t.Logf(" - %s %s %s qty=%.6f price=%.4f time=%s", + trade.TradeID, trade.Symbol, trade.Side, + trade.Quantity, trade.Price, trade.Time.Format(time.RFC3339)) + } + } else { + t.Logf("✅ No missing trades") + } + + // Step 5: Check for extra/duplicate trades + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 5: Checking for EXTRA trades (in DB but not in exchange)...") + t.Logf("%s", repeatStr("=", 60)) + + var extraTrades []store.TraderFill + for tradeID, fill := range dbFillMap { + if _, exists := exchangeTradeMap[tradeID]; !exists { + extraTrades = append(extraTrades, fill) + } + } + + if len(extraTrades) > 0 { + t.Logf("❌ EXTRA %d trades in DB:", len(extraTrades)) + for i, fill := range extraTrades { + if i >= 10 { + t.Logf(" ... and %d more", len(extraTrades)-10) + break + } + t.Logf(" - %s %s %s qty=%.6f price=%.4f", + fill.ExchangeTradeID, fill.Symbol, fill.Side, + fill.Quantity, fill.Price) + } + } else { + t.Logf("✅ No extra/duplicate trades") + } + + // Step 6: Check for data accuracy + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 6: Verifying data accuracy (price, qty, fee, pnl)...") + t.Logf("%s", repeatStr("=", 60)) + + type DataMismatch struct { + TradeID string + Field string + DB float64 + Exchange float64 + } + + var mismatches []DataMismatch + for tradeID, exchangeTrade := range exchangeTradeMap { + dbFill, exists := dbFillMap[tradeID] + if !exists { + continue + } + + // Compare price + if !floatEqual(dbFill.Price, exchangeTrade.Price, 0.0001) { + mismatches = append(mismatches, DataMismatch{ + TradeID: tradeID, Field: "Price", + DB: dbFill.Price, Exchange: exchangeTrade.Price, + }) + } + + // Compare quantity + if !floatEqual(dbFill.Quantity, exchangeTrade.Quantity, 0.000001) { + mismatches = append(mismatches, DataMismatch{ + TradeID: tradeID, Field: "Quantity", + DB: dbFill.Quantity, Exchange: exchangeTrade.Quantity, + }) + } + + // Compare fee + if !floatEqual(dbFill.Commission, exchangeTrade.Fee, 0.000001) { + mismatches = append(mismatches, DataMismatch{ + TradeID: tradeID, Field: "Fee", + DB: dbFill.Commission, Exchange: exchangeTrade.Fee, + }) + } + + // Compare realized PnL + if !floatEqual(dbFill.RealizedPnL, exchangeTrade.RealizedPnL, 0.01) { + mismatches = append(mismatches, DataMismatch{ + TradeID: tradeID, Field: "RealizedPnL", + DB: dbFill.RealizedPnL, Exchange: exchangeTrade.RealizedPnL, + }) + } + } + + if len(mismatches) > 0 { + t.Logf("❌ DATA MISMATCHES: %d", len(mismatches)) + for i, m := range mismatches { + if i >= 20 { + t.Logf(" ... and %d more", len(mismatches)-20) + break + } + t.Logf(" - %s %s: DB=%.6f, Exchange=%.6f", + m.TradeID, m.Field, m.DB, m.Exchange) + } + } else { + t.Logf("✅ All data matches exactly") + } + + // Step 7: Summary by symbol + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 7: Summary by symbol...") + t.Logf("%s", repeatStr("=", 60)) + + type SymbolSummary struct { + Symbol string + ExchangeCount int + DBCount int + TotalQty float64 + TotalFee float64 + TotalPnL float64 + ExchangeTotalQty float64 + ExchangeTotalFee float64 + ExchangeTotalPnL float64 + } + + summaryMap := make(map[string]*SymbolSummary) + + for _, trade := range exchangeTrades { + if summaryMap[trade.Symbol] == nil { + summaryMap[trade.Symbol] = &SymbolSummary{Symbol: trade.Symbol} + } + s := summaryMap[trade.Symbol] + s.ExchangeCount++ + s.ExchangeTotalQty += trade.Quantity + s.ExchangeTotalFee += trade.Fee + s.ExchangeTotalPnL += trade.RealizedPnL + } + + for _, fill := range dbFills { + if summaryMap[fill.Symbol] == nil { + summaryMap[fill.Symbol] = &SymbolSummary{Symbol: fill.Symbol} + } + s := summaryMap[fill.Symbol] + s.DBCount++ + s.TotalQty += fill.Quantity + s.TotalFee += fill.Commission + s.TotalPnL += fill.RealizedPnL + } + + t.Logf("\n%-15s %10s %10s %15s %15s %15s", "Symbol", "Exchange", "DB", "Fee(Exc/DB)", "PnL(Exc/DB)", "Match") + t.Logf("%s", repeatStr("-", 80)) + + for _, s := range summaryMap { + countMatch := s.ExchangeCount == s.DBCount + feeMatch := floatEqual(s.ExchangeTotalFee, s.TotalFee, 0.01) + pnlMatch := floatEqual(s.ExchangeTotalPnL, s.TotalPnL, 0.01) + + matchStr := "✅" + if !countMatch || !feeMatch || !pnlMatch { + matchStr = "❌" + } + + t.Logf("%-15s %10d %10d %7.2f/%-7.2f %7.2f/%-7.2f %s", + s.Symbol, s.ExchangeCount, s.DBCount, + s.ExchangeTotalFee, s.TotalFee, + s.ExchangeTotalPnL, s.TotalPnL, + matchStr) + } + + // Step 8: Position verification + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("STEP 8: Verifying position calculations...") + t.Logf("%s", repeatStr("=", 60)) + + // Get positions from DB + var dbPositions []store.TraderPosition + db.Where("exchange_id = ? AND status = ?", exchangeID, "closed").Find(&dbPositions) + + t.Logf("Closed positions in DB: %d", len(dbPositions)) + + // Get current positions from exchange + exchangePositions, err := trader.GetPositions() + if err != nil { + t.Logf("⚠️ Failed to get exchange positions: %v", err) + } else { + t.Logf("Active positions on exchange: %d", len(exchangePositions)) + for _, pos := range exchangePositions { + t.Logf(" - %s %s qty=%.6f entry=%.4f pnl=%.4f", + pos["symbol"], pos["side"], + pos["positionAmt"], pos["entryPrice"], pos["unRealizedProfit"]) + } + } + + // Calculate total PnL from trades + var totalRealizedPnL float64 + var totalFees float64 + for _, fill := range dbFills { + totalRealizedPnL += fill.RealizedPnL + totalFees += fill.Commission + } + + t.Logf("\n📊 PnL Summary from DB:") + t.Logf(" Total Realized PnL: %.4f USDT", totalRealizedPnL) + t.Logf(" Total Fees: %.4f USDT", totalFees) + t.Logf(" Net PnL: %.4f USDT", totalRealizedPnL-totalFees) + + // Calculate from exchange + var exchangeTotalPnL float64 + var exchangeTotalFees float64 + for _, trade := range exchangeTrades { + exchangeTotalPnL += trade.RealizedPnL + exchangeTotalFees += trade.Fee + } + + t.Logf("\n📊 PnL Summary from Exchange:") + t.Logf(" Total Realized PnL: %.4f USDT", exchangeTotalPnL) + t.Logf(" Total Fees: %.4f USDT", exchangeTotalFees) + t.Logf(" Net PnL: %.4f USDT", exchangeTotalPnL-exchangeTotalFees) + + // Compare + pnlMatch := floatEqual(totalRealizedPnL, exchangeTotalPnL, 0.01) + feeMatch := floatEqual(totalFees, exchangeTotalFees, 0.01) + + t.Logf("\n%s", repeatStr("=", 60)) + t.Logf("FINAL VERIFICATION RESULT") + t.Logf("%s", repeatStr("=", 60)) + + allPassed := true + + if len(missingTrades) > 0 { + t.Logf("❌ Missing trades: %d", len(missingTrades)) + allPassed = false + } else { + t.Logf("✅ No missing trades") + } + + if len(extraTrades) > 0 { + t.Logf("❌ Extra/duplicate trades: %d", len(extraTrades)) + allPassed = false + } else { + t.Logf("✅ No extra/duplicate trades") + } + + if len(mismatches) > 0 { + t.Logf("❌ Data mismatches: %d", len(mismatches)) + allPassed = false + } else { + t.Logf("✅ All data accurate") + } + + if !pnlMatch { + t.Logf("❌ PnL mismatch: DB=%.4f, Exchange=%.4f", totalRealizedPnL, exchangeTotalPnL) + allPassed = false + } else { + t.Logf("✅ PnL matches") + } + + if !feeMatch { + t.Logf("❌ Fee mismatch: DB=%.4f, Exchange=%.4f", totalFees, exchangeTotalFees) + allPassed = false + } else { + t.Logf("✅ Fees match") + } + + if allPassed { + t.Logf("\n🎉 ALL VERIFICATIONS PASSED!") + } else { + t.Logf("\n⚠️ SOME VERIFICATIONS FAILED - CHECK ABOVE FOR DETAILS") + } + + // Cleanup + os.Remove(testDBPath) +} + +// floatEqual compares two floats with tolerance +func floatEqual(a, b, tolerance float64) bool { + return math.Abs(a-b) <= tolerance +} + +// TestBinanceDetailedTradeComparison shows detailed trade-by-trade comparison +func TestBinanceDetailedTradeComparison(t *testing.T) { + skipIfNoLiveTest(t) + + // Get credentials from environment + apiKey, secretKey := getBinanceTestCredentials(t) + trader := NewFuturesTrader(apiKey, secretKey, "test-user") + + startTime := time.Now().UTC().Add(-24 * time.Hour) + + // Get all income (to find symbols with activity) + incomes, err := trader.client.NewGetIncomeHistoryService(). + StartTime(startTime.UnixMilli()). + Limit(100). + Do(context.Background()) + if err != nil { + t.Fatalf("Failed to get income: %v", err) + } + + // Find unique symbols + symbolMap := make(map[string]bool) + for _, inc := range incomes { + if inc.Symbol != "" { + symbolMap[inc.Symbol] = true + } + } + + if len(symbolMap) == 0 { + t.Log("No trading activity in the last 24 hours") + return + } + + t.Logf("=%s", repeatStr("=", 100)) + t.Logf("DETAILED TRADE REPORT (Last 24 hours)") + t.Logf("=%s", repeatStr("=", 100)) + + var grandTotalQty float64 + var grandTotalFee float64 + var grandTotalPnL float64 + + for symbol := range symbolMap { + trades, err := trader.GetTradesForSymbol(symbol, startTime, 500) + if err != nil { + t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err) + continue + } + + if len(trades) == 0 { + continue + } + + // Sort by time + sort.Slice(trades, func(i, j int) bool { + return trades[i].Time.Before(trades[j].Time) + }) + + t.Logf("\n%s", repeatStr("-", 100)) + t.Logf("📊 %s - %d trades", symbol, len(trades)) + t.Logf("%s", repeatStr("-", 100)) + t.Logf("%-15s %-6s %12s %12s %12s %12s %20s", + "TradeID", "Side", "Quantity", "Price", "Fee", "PnL", "Time") + + var totalQty, totalFee, totalPnL float64 + var buyQty, sellQty float64 + + for _, trade := range trades { + t.Logf("%-15s %-6s %12.6f %12.4f %12.6f %12.4f %20s", + trade.TradeID, trade.Side, + trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, + trade.Time.Format("2006-01-02 15:04:05")) + + totalQty += trade.Quantity + totalFee += trade.Fee + totalPnL += trade.RealizedPnL + + if trade.Side == "BUY" { + buyQty += trade.Quantity + } else { + sellQty += trade.Quantity + } + } + + t.Logf("%s", repeatStr("-", 100)) + t.Logf("SUBTOTAL: %d trades, Buy=%.6f, Sell=%.6f, Fee=%.6f, PnL=%.4f", + len(trades), buyQty, sellQty, totalFee, totalPnL) + + grandTotalQty += totalQty + grandTotalFee += totalFee + grandTotalPnL += totalPnL + } + + t.Logf("\n%s", repeatStr("=", 100)) + t.Logf("GRAND TOTAL") + t.Logf("=%s", repeatStr("=", 100)) + t.Logf("Total Fee: %.6f USDT", grandTotalFee) + t.Logf("Total PnL: %.4f USDT", grandTotalPnL) + t.Logf("Net PnL: %.4f USDT", grandTotalPnL-grandTotalFee) +} diff --git a/trader/bitget_order_sync.go b/trader/bitget_order_sync.go index 1f4d6384..9be223b8 100644 --- a/trader/bitget_order_sync.go +++ b/trader/bitget_order_sync.go @@ -146,7 +146,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].ExecTime.Before(trades[j].ExecTime) + return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -174,8 +174,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, // Normalize side for storage side := strings.ToUpper(trade.Side) - // Create order record - use UTC time to avoid timezone issues - execTimeUTC := trade.ExecTime.UTC() + // Create order record - use UTC time in milliseconds to avoid timezone issues + execTimeMs := trade.ExecTime.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -192,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, FilledQuantity: trade.FillQty, AvgFillPrice: trade.FillPrice, Commission: trade.Fee, - FilledAt: execTimeUTC, - CreatedAt: execTimeUTC, - UpdatedAt: execTimeUTC, + FilledAt: execTimeMs, + CreatedAt: execTimeMs, + UpdatedAt: execTimeMs, } // Insert order record @@ -203,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, continue } - // Create fill record - use UTC time + // Create fill record - use UTC time in milliseconds fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -220,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, CommissionAsset: trade.FeeAsset, RealizedPnL: trade.ProfitLoss, IsMaker: false, - CreatedAt: execTimeUTC, + CreatedAt: execTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -232,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, traderID, exchangeID, exchangeType, symbol, positionSide, trade.OrderAction, trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, - trade.ExecTime, trade.TradeID, + execTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { diff --git a/trader/bybit_order_sync.go b/trader/bybit_order_sync.go index 21e67c06..662f8d9d 100644 --- a/trader/bybit_order_sync.go +++ b/trader/bybit_order_sync.go @@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].ExecTime.Before(trades[j].ExecTime) + return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -223,8 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex // Normalize side for storage side := strings.ToUpper(trade.Side) - // Create order record - use UTC time to avoid timezone issues - execTimeUTC := trade.ExecTime.UTC() + // Create order record - use UTC time in milliseconds to avoid timezone issues + execTimeMs := trade.ExecTime.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -241,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex FilledQuantity: trade.ExecQty, AvgFillPrice: trade.ExecPrice, Commission: trade.ExecFee, - FilledAt: execTimeUTC, - CreatedAt: execTimeUTC, - UpdatedAt: execTimeUTC, + FilledAt: execTimeMs, + CreatedAt: execTimeMs, + UpdatedAt: execTimeMs, } // Insert order record @@ -269,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex CommissionAsset: "USDT", RealizedPnL: trade.ClosedPnL, IsMaker: trade.IsMaker, - CreatedAt: execTimeUTC, + CreatedAt: execTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -281,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex traderID, exchangeID, exchangeType, symbol, positionSide, trade.OrderAction, trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL, - trade.ExecTime, trade.ExecID, + execTimeMs, trade.ExecID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err) } else { diff --git a/trader/hyperliquid_order_sync.go b/trader/hyperliquid_order_sync.go index 22efdd7c..bd1dd996 100644 --- a/trader/hyperliquid_order_sync.go +++ b/trader/hyperliquid_order_sync.go @@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].Time.Before(trades[j].Time) + return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -61,8 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI positionSide = "SHORT" } - // Create order record - use UTC time to avoid timezone issues - tradeTimeUTC := trade.Time.UTC() + // Create order record - use Unix milliseconds UTC + tradeTimeMs := trade.Time.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -79,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI FilledQuantity: trade.Quantity, AvgFillPrice: trade.Price, Commission: trade.Fee, - FilledAt: tradeTimeUTC, - CreatedAt: tradeTimeUTC, - UpdatedAt: tradeTimeUTC, + FilledAt: tradeTimeMs, + CreatedAt: tradeTimeMs, + UpdatedAt: tradeTimeMs, } // Insert order record @@ -90,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI continue } - // Create fill record - use UTC time + // Create fill record - use Unix milliseconds UTC fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -107,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI CommissionAsset: "USDT", RealizedPnL: trade.RealizedPnL, IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info - CreatedAt: tradeTimeUTC, + CreatedAt: tradeTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -119,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI traderID, exchangeID, exchangeType, symbol, positionSide, orderAction, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, - trade.Time, trade.TradeID, + tradeTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { diff --git a/trader/lighter_order_sync.go b/trader/lighter_order_sync.go index d37973ab..1c84541f 100644 --- a/trader/lighter_order_sync.go +++ b/trader/lighter_order_sync.go @@ -34,7 +34,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].Time.Before(trades[j].Time) + return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -70,8 +70,8 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri } } - // Create order record - use UTC time to avoid timezone issues - tradeTimeUTC := trade.Time.UTC() + // Create order record - use Unix milliseconds UTC + tradeTimeMs := trade.Time.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -88,9 +88,9 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri FilledQuantity: trade.Quantity, AvgFillPrice: trade.Price, Commission: trade.Fee, - FilledAt: tradeTimeUTC, - CreatedAt: tradeTimeUTC, - UpdatedAt: tradeTimeUTC, + FilledAt: tradeTimeMs, + CreatedAt: tradeTimeMs, + UpdatedAt: tradeTimeMs, } // Insert order record @@ -99,7 +99,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri continue } - // Create fill record - use UTC time + // Create fill record - use Unix milliseconds UTC fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -116,7 +116,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri CommissionAsset: "USDT", RealizedPnL: trade.RealizedPnL, IsMaker: false, - CreatedAt: tradeTimeUTC, + CreatedAt: tradeTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -128,7 +128,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri traderID, exchangeID, exchangeType, symbol, positionSide, orderAction, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, - trade.Time, trade.TradeID, + tradeTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { diff --git a/trader/okx_order_sync.go b/trader/okx_order_sync.go index d1feb81c..89e8731b 100644 --- a/trader/okx_order_sync.go +++ b/trader/okx_order_sync.go @@ -169,7 +169,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan // Sort trades by time ASC (oldest first) for proper position building sort.Slice(trades, func(i, j int) bool { - return trades[i].ExecTime.Before(trades[j].ExecTime) + return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli() }) // Process trades one by one (no transaction to avoid deadlock) @@ -197,8 +197,8 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan // Normalize side for storage side := strings.ToUpper(trade.Side) - // Create order record - use UTC time to avoid timezone issues - execTimeUTC := trade.ExecTime.UTC() + // Create order record - use UTC time in milliseconds to avoid timezone issues + execTimeMs := trade.ExecTime.UTC().UnixMilli() orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -215,9 +215,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan FilledQuantity: trade.FillQtyBase, AvgFillPrice: trade.FillPrice, Commission: trade.Fee, - FilledAt: execTimeUTC, - CreatedAt: execTimeUTC, - UpdatedAt: execTimeUTC, + FilledAt: execTimeMs, + CreatedAt: execTimeMs, + UpdatedAt: execTimeMs, } // Insert order record @@ -226,7 +226,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan continue } - // Create fill record - use UTC time + // Create fill record - use UTC time in milliseconds fillRecord := &store.TraderFill{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -243,7 +243,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan CommissionAsset: trade.FeeAsset, RealizedPnL: 0, // OKX fills don't include PnL per trade IsMaker: trade.IsMaker, - CreatedAt: execTimeUTC, + CreatedAt: execTimeMs, } if err := orderStore.CreateFill(fillRecord); err != nil { @@ -255,7 +255,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan traderID, exchangeID, exchangeType, symbol, positionSide, trade.OrderAction, trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX - trade.ExecTime, trade.TradeID, + execTimeMs, trade.TradeID, ); err != nil { logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) } else { diff --git a/trader/position_snapshot.go b/trader/position_snapshot.go index 029b694a..200f3f7a 100644 --- a/trader/position_snapshot.go +++ b/trader/position_snapshot.go @@ -40,7 +40,7 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr logger.Infof("📥 Found %d positions on exchange", len(positions)) // Step 3: Create snapshot record for each position - now := time.Now() + nowMs := time.Now().UnixMilli() createdCount := 0 for _, posMap := range positions { @@ -74,18 +74,18 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr TraderID: traderID, ExchangeID: exchangeID, ExchangeType: exchangeType, - ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, now.UnixMilli()), + ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, nowMs), Symbol: symbol, Side: side, Quantity: positionAmt, EntryPrice: entryPrice, EntryOrderID: "snapshot", // Mark as snapshot - EntryTime: now, + EntryTime: nowMs, Leverage: int(leverage), Status: "OPEN", Source: "snapshot", // Mark source as snapshot - CreatedAt: now, - UpdatedAt: now, + CreatedAt: nowMs, + UpdatedAt: nowMs, } if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil { diff --git a/web/src/components/PositionHistory.tsx b/web/src/components/PositionHistory.tsx index 9b1fffdb..d99ff79c 100644 --- a/web/src/components/PositionHistory.tsx +++ b/web/src/components/PositionHistory.tsx @@ -303,6 +303,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) { {displayQty.toFixed(4)} + {/* Position Value (Entry Price * Quantity) */} + + {formatNumber(entryPrice * displayQty)} + + {/* P&L */}
@@ -764,6 +769,12 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { > {t('positionHistory.qty', language)} + + {t('positionHistory.value', language)} +
- + {selectedTrader.trader_name} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b88fce1d..e33ef1ac 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -21,6 +21,7 @@ export default { 'nofx-accent': '#00F0FF', // Cyan Cyber 'nofx-text': { DEFAULT: '#EAECEF', + main: '#EAECEF', muted: '#848E9C', }, 'nofx-success': '#0ECB81',