Sync manual closes into position history

This commit is contained in:
tinkle-community
2026-06-28 12:17:45 +08:00
parent c4e79d9579
commit eba28bcf0e
6 changed files with 256 additions and 31 deletions

View File

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

View File

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