From eba28bcf0e3ab0bc8d9a7794e6699c5d6b17cbee Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 28 Jun 2026 12:17:45 +0800 Subject: [PATCH] Sync manual closes into position history --- api/handler_order.go | 31 ++++++++-- api/handler_trader_status.go | 51 ++++++++++++++- store/position.go | 51 +++++++++++++-- store/position_query.go | 36 +++++++---- store/position_test.go | 92 ++++++++++++++++++++++++++++ web/src/pages/StrategyStudioPage.tsx | 26 +++++--- 6 files changed, 256 insertions(+), 31 deletions(-) diff --git a/api/handler_order.go b/api/handler_order.go index ff484121..b44d7883 100644 --- a/api/handler_order.go +++ b/api/handler_order.go @@ -3,6 +3,7 @@ package api import ( "net/http" "strconv" + "strings" "nofx/logger" "nofx/market" @@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) { return } + userID := c.GetString("user_id") + if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil { + if syncErr := s.syncOrdersFromExchange( + trader.GetUnderlyingTrader(), + trader.GetID(), + fullCfg.Exchange.ID, + fullCfg.Exchange.ExchangeType, + ); syncErr != nil { + logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr) + } + } + + traderIDs := []string{trader.GetID()} + var traderIDPatterns []string + if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" { + // Older one-click launches created new Autopilot trader rows. When a row was + // deleted, its closed position records remained under the old generated ID. + // The generated Autopilot ID embeds userID + "claw402", so this safely + // restores same-user history continuity without joining deleted rows. + traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%") + } + // Get closed positions - positions, err := store.Position().GetClosedPositions(trader.GetID(), limit) + positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit) if err != nil { SafeInternalError(c, "Get position history", err) return } // Get statistics - stats, _ := store.Position().GetFullStats(trader.GetID()) + stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns) // Get symbol stats - symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10) + symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10) // Get direction stats - directionStats, _ := store.Position().GetDirectionStats(trader.GetID()) + directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns) c.JSON(http.StatusOK, gin.H{ "positions": positions, diff --git a/api/handler_trader_status.go b/api/handler_trader_status.go index 4956ece4..198ec9b2 100644 --- a/api/handler_trader_status.go +++ b/api/handler_trader_status.go @@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) { logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result) - // Record order to database (for chart markers and history) - s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result) + // Backfill the just-closed fill immediately. Manual closes may happen while + // the bot runtime is stopped, so the background OrderSync loop is not enough. + if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil { + logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr) + s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result) + } c.JSON(http.StatusOK, gin.H{ "message": "Position closed successfully", @@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) { }) } +func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error { + switch t := exchangeTrader.(type) { + case *binance.FuturesTrader: + return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store) + case *hyperliquidtrader.HyperliquidTrader: + return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store) + case *aster.AsterTrader: + return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store) + case *bybit.BybitTrader: + return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store) + case *okx.OKXTrader: + return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store) + case *bitget.BitgetTrader: + return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store) + case *gate.GateTrader: + return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store) + case *kucoin.KuCoinTrader: + return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store) + case *lighter.LighterTraderV2: + return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store) + default: + return fmt.Errorf("order sync is not available for exchange type %s", exchangeType) + } +} + +func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error { + var lastErr error + for attempt := 1; attempt <= 4; attempt++ { + if attempt > 1 { + time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond) + } + if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil { + lastErr = err + continue + } + return nil + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("manual close sync did not run") +} + // recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status) func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) { // Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates diff --git a/store/position.go b/store/position.go index e0adbe7d..a9c5650f 100644 --- a/store/position.go +++ b/store/position.go @@ -382,11 +382,54 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) ( // GetClosedPositions gets closed positions func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) { + return s.GetClosedPositionsByTraderFilters([]string{traderID}, nil, limit) +} + +func (s *PositionStore) closedPositionsByTraderFilters(traderIDs []string, traderIDPatterns []string) *gorm.DB { + query := s.db.Where("status = ?", "CLOSED") + + conditions := make([]string, 0, len(traderIDs)+len(traderIDPatterns)) + args := make([]interface{}, 0, len(traderIDs)+len(traderIDPatterns)) + + cleanTraderIDs := make([]string, 0, len(traderIDs)) + for _, traderID := range traderIDs { + traderID = strings.TrimSpace(traderID) + if traderID != "" { + cleanTraderIDs = append(cleanTraderIDs, traderID) + } + } + if len(cleanTraderIDs) > 0 { + conditions = append(conditions, "trader_id IN ?") + args = append(args, cleanTraderIDs) + } + + for _, pattern := range traderIDPatterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + conditions = append(conditions, "trader_id LIKE ?") + args = append(args, pattern) + } + + if len(conditions) == 0 { + return query.Where("1 = 0") + } + + return query.Where("("+strings.Join(conditions, " OR ")+")", args...) +} + +// GetClosedPositionsByTraderFilters gets closed positions for explicit trader IDs +// and legacy trader ID patterns. Patterns are used only for same-user Autopilot +// history continuity when an old trader row was deleted but its position records remain. +func (s *PositionStore) GetClosedPositionsByTraderFilters(traderIDs []string, traderIDPatterns []string, limit int) ([]*TraderPosition, error) { var positions []*TraderPosition - err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED"). - Order("exit_time DESC"). - Limit(limit). - Find(&positions).Error + query := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Order("exit_time DESC") + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&positions).Error if err != nil { return nil, fmt.Errorf("failed to query closed positions: %w", err) } diff --git a/store/position_query.go b/store/position_query.go index b50a5034..c642df9a 100644 --- a/store/position_query.go +++ b/store/position_query.go @@ -56,18 +56,16 @@ func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{ // GetFullStats gets complete trading statistics func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) { + return s.GetFullStatsByTraderFilters([]string{traderID}, nil) +} + +// GetFullStatsByTraderFilters gets complete trading statistics for explicit +// trader IDs plus optional legacy trader ID patterns. +func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderIDPatterns []string) (*TraderStats, error) { stats := &TraderStats{} - var count int64 - if err := s.db.Model(&TraderPosition{}).Where("trader_id = ? AND status = ?", traderID, "CLOSED").Count(&count).Error; err != nil { - return nil, err - } - if count == 0 { - return stats, nil - } - var positions []TraderPosition - err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED"). + err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns). Order("exit_time ASC"). Find(&positions).Error if err != nil { @@ -234,8 +232,14 @@ type SymbolStats struct { // GetSymbolStats gets per-symbol trading statistics func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) { + return s.GetSymbolStatsByTraderFilters([]string{traderID}, nil, limit) +} + +// GetSymbolStatsByTraderFilters gets per-symbol trading statistics for explicit +// trader IDs plus optional legacy trader ID patterns. +func (s *PositionStore) GetSymbolStatsByTraderFilters(traderIDs []string, traderIDPatterns []string, limit int) ([]SymbolStats, error) { var positions []TraderPosition - err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error + err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Find(&positions).Error if err != nil { return nil, fmt.Errorf("failed to query symbol stats: %w", err) } @@ -311,8 +315,8 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats } rangeStats := map[string]*struct { - count int - wins int + count int + wins int totalPnL float64 }{ "<1h": {}, @@ -374,8 +378,14 @@ type DirectionStats struct { // GetDirectionStats analyzes long vs short performance func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) { + return s.GetDirectionStatsByTraderFilters([]string{traderID}, nil) +} + +// GetDirectionStatsByTraderFilters analyzes long vs short performance for +// explicit trader IDs plus optional legacy trader ID patterns. +func (s *PositionStore) GetDirectionStatsByTraderFilters(traderIDs []string, traderIDPatterns []string) ([]DirectionStats, error) { var positions []TraderPosition - err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error + err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Find(&positions).Error if err != nil { return nil, fmt.Errorf("failed to query direction stats: %w", err) } diff --git a/store/position_test.go b/store/position_test.go index b9419400..aba29e0d 100644 --- a/store/position_test.go +++ b/store/position_test.go @@ -42,3 +42,95 @@ func TestGetOpenPositionBySymbolMatchesSideCaseInsensitively(t *testing.T) { t.Fatalf("entry time mismatch: got %d want %d", got.EntryTime, entryTime) } } + +func TestGetClosedPositionsByTraderFiltersIncludesLegacyAutopilotIDs(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open in-memory sqlite: %v", err) + } + + positions := NewPositionStore(db) + if err := positions.InitTables(); err != nil { + t.Fatalf("init position table: %v", err) + } + + now := time.Now().UnixMilli() + rows := []*TraderPosition{ + { + TraderID: "current-trader", + Symbol: "xyz:SP500", + Side: "LONG", + Quantity: 1, + EntryPrice: 100, + EntryTime: now - 3000, + ExitPrice: 101, + ExitTime: now - 2000, + RealizedPnL: 1, + Status: "CLOSED", + CreatedAt: now - 3000, + UpdatedAt: now - 2000, + CloseReason: "sync", + ExchangeType: "hyperliquid", + }, + { + TraderID: "exchange_user-123_claw402_111", + Symbol: "AAVEUSDT", + Side: "LONG", + Quantity: 2, + EntryPrice: 50, + EntryTime: now - 5000, + ExitPrice: 49, + ExitTime: now - 4000, + RealizedPnL: -2, + Status: "CLOSED", + CreatedAt: now - 5000, + UpdatedAt: now - 4000, + CloseReason: "sync", + ExchangeType: "hyperliquid", + }, + { + TraderID: "exchange_other-user_claw402_222", + Symbol: "LITUSDT", + Side: "LONG", + Quantity: 3, + EntryPrice: 10, + EntryTime: now - 7000, + ExitPrice: 12, + ExitTime: now - 6000, + RealizedPnL: 6, + Status: "CLOSED", + CreatedAt: now - 7000, + UpdatedAt: now - 6000, + CloseReason: "sync", + ExchangeType: "hyperliquid", + }, + } + for _, row := range rows { + if err := db.Create(row).Error; err != nil { + t.Fatalf("create position: %v", err) + } + } + + got, err := positions.GetClosedPositionsByTraderFilters( + []string{"current-trader"}, + []string{"%_user-123_claw402_%"}, + 100, + ) + if err != nil { + t.Fatalf("get closed positions: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected current + same-user legacy positions, got %d", len(got)) + } + + stats, err := positions.GetFullStatsByTraderFilters( + []string{"current-trader"}, + []string{"%_user-123_claw402_%"}, + ) + if err != nil { + t.Fatalf("get stats: %v", err) + } + if stats.TotalTrades != 2 || stats.TotalPnL != -1 { + t.Fatalf("unexpected stats: trades=%d pnl=%.2f", stats.TotalTrades, stats.TotalPnL) + } +} diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 1f164dc7..3df018fe 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -1545,7 +1545,7 @@ export function StrategyStudioPage() { resolveOneClickExchange(), ]) - const created = await api.createTrader({ + const traderRequest = { name: traderName, ai_model_id: model.id, exchange_id: exchange.id, @@ -1553,17 +1553,27 @@ export function StrategyStudioPage() { scan_interval_minutes: 15, is_cross_margin: true, show_in_competition: true, - }) - - if (created.startup_warning) { - notify.warning(created.startup_warning) } - await api.startTrader(created.trader_id) - notify.success(`${traderName} created and started`) + const existingTraders = await api.getTraders(true) + const existingAutopilot = existingTraders.find( + (trader) => trader.trader_name === traderName + ) + const autopilot = existingAutopilot + ? await api.updateTrader(existingAutopilot.trader_id, traderRequest) + : await api.createTrader(traderRequest) + + if (autopilot.startup_warning) { + notify.warning(autopilot.startup_warning) + } + + if (!autopilot.is_running) { + await api.startTrader(autopilot.trader_id) + } + notify.success(`${traderName} started`) setHasChanges(false) await loadStrategies(selectedStrategy.id) - navigate(buildDashboardPath(created.trader_id)) + navigate(buildDashboardPath(autopilot.trader_id)) } catch (err) { notify.error( err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'