From 21407030ea6025bf556063016965ac7a1ed72352 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 10 Jul 2026 20:04:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20reconcile=20local=20positions=20against?= =?UTF-8?q?=20the=20live=20exchange=20book=20=E2=80=94=20stop=20lost=20PnL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the dashboard under-reporting: any missed or unmatched fill (position flip, sync gap, liquidation) left a 'zombie' OPEN row. Every later close on that symbol landed as a partial close against the zombie, so the row never reached CLOSED and its realized PnL never entered the closed-trade statistics — Edge Profile, realized P/L, win rate, and the AI's own track-record context all silently under-reported. Fix: after each Hyperliquid order sync, reconcile local OPEN rows against the exchange's live book (core perps + xyz dex), scoped by exchange account so rows left by prior autopilot incarnations (each relaunch mints a fresh trader_id sharing one exchange) are healed too. Rows the exchange no longer holds are closed with their accumulated PnL; oversized rows are trimmed to the live quantity. Live run confirmed: 9 zombie rows closed across incarnations, book now matches the exchange exactly. Also removes the header language switcher (desktop + mobile) and the login/setup LanguageSwitcher — the product UI is English-only. New store methods GetOpenPositionsByExchange + ReconcileOpenPositionsWithLive with cross-incarnation test coverage. --- store/position.go | 21 +++++ store/position_reconcile.go | 111 +++++++++++++++++++++++ store/position_reconcile_test.go | 113 ++++++++++++++++++++++++ trader/hyperliquid/order_sync.go | 34 +++++++ web/src/components/auth/LoginPage.tsx | 2 - web/src/components/common/HeaderBar.tsx | 86 +----------------- web/src/components/modals/SetupPage.tsx | 2 - 7 files changed, 283 insertions(+), 86 deletions(-) create mode 100644 store/position_reconcile.go create mode 100644 store/position_reconcile_test.go diff --git a/store/position.go b/store/position.go index a9c5650f..bd37f4e3 100644 --- a/store/position.go +++ b/store/position.go @@ -347,6 +347,27 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er return positions, nil } +// GetOpenPositionsByExchange returns every OPEN row on an exchange account, +// across all trader IDs. An exchange account is shared by every "NOFX +// Autopilot" relaunch (each relaunch mints a fresh trader_id), so reconciling +// must be scoped to the exchange — not the current trader_id — or rows left by +// prior incarnations become permanent orphans that never close. +func (s *PositionStore) GetOpenPositionsByExchange(exchangeID string) ([]*TraderPosition, error) { + var positions []*TraderPosition + err := s.db.Where("exchange_id = ? AND status = ?", exchangeID, "OPEN"). + Order("entry_time DESC"). + Find(&positions).Error + if err != nil { + return nil, fmt.Errorf("failed to query open positions by exchange: %w", err) + } + for _, pos := range positions { + if pos.EntryQuantity == 0 { + pos.EntryQuantity = pos.Quantity + } + } + return positions, nil +} + // GetOpenPositionBySymbol gets open position for specified symbol and direction func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) { var pos TraderPosition diff --git a/store/position_reconcile.go b/store/position_reconcile.go new file mode 100644 index 00000000..4d2d39c3 --- /dev/null +++ b/store/position_reconcile.go @@ -0,0 +1,111 @@ +package store + +import ( + "fmt" + "sort" + "strings" + "time" + + "nofx/logger" +) + +const reconcileQuantityTolerance = 0.0001 + +// LivePositionKey builds the map key used by ReconcileOpenPositionsWithLive. +func LivePositionKey(symbol, side string) string { + return strings.ToUpper(strings.TrimSpace(symbol)) + "|" + strings.ToUpper(strings.TrimSpace(side)) +} + +// ReconcileOpenPositionsWithLive force-closes local OPEN position rows that the +// exchange no longer holds, and trims rows whose quantity exceeds what is live. +// +// Why this exists: missed or unmatched fills (position flips, liquidations, +// sync gaps) leave "zombie" OPEN rows behind. Every later close on the same +// symbol+side then lands as a partial close against the zombie, so the row +// never reaches CLOSED and its realized PnL never enters the closed-trade +// statistics — the dashboard, the Edge Profile and the AI's own track-record +// context all silently under-report. Reconciling against the exchange's live +// book is the self-healing fix: local OPEN rows must always be a subset of +// what the exchange actually holds. +// +// liveQty maps LivePositionKey(symbol, side) → live quantity on the exchange. +// Rows are matched newest-first so the freshest row survives as the live +// position's bookkeeping and older duplicates get closed. +// +// Scope is by exchange account (all trader IDs), so rows left by prior +// autopilot incarnations on the same exchange are reconciled too. +func (s *PositionStore) ReconcileOpenPositionsWithLive(exchangeID string, liveQty map[string]float64) (int, error) { + openRows, err := s.GetOpenPositionsByExchange(exchangeID) + if err != nil { + return 0, fmt.Errorf("failed to list open positions: %w", err) + } + if len(openRows) == 0 { + return 0, nil + } + + // Copy so we can consume quantities without mutating the caller's map. + remaining := make(map[string]float64, len(liveQty)) + for k, v := range liveQty { + remaining[strings.ToUpper(k)] = v + } + + // Newest first: the most recent row keeps representing the live position. + sort.Slice(openRows, func(i, j int) bool { + return openRows[i].EntryTime > openRows[j].EntryTime + }) + + nowMs := time.Now().UTC().UnixMilli() + closed := 0 + + for _, row := range openRows { + key := LivePositionKey(row.Symbol, row.Side) + live := remaining[key] + + if live > reconcileQuantityTolerance { + // The exchange still holds (part of) this key — this row survives. + if row.Quantity > live+reconcileQuantityTolerance { + // Trim the row down to what is actually live so the next real + // close matches sizes and can fully close it. No PnL is + // fabricated: the trimmed residue is stale bookkeeping, not a + // real fill. + trim := row.Quantity - live + exitPrice := row.ExitPrice + if exitPrice <= 0 { + exitPrice = row.EntryPrice + } + if err := s.ReducePositionQuantity(row.ID, trim, exitPrice, 0, 0); err != nil { + logger.Infof(" ⚠️ Reconcile: failed to trim position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err) + } else { + logger.Infof(" 🧹 Reconcile: trimmed %s %s row %d by %.6f to match live %.6f", row.Symbol, row.Side, row.ID, trim, live) + } + remaining[key] = 0 + } else { + remaining[key] = live - row.Quantity + } + continue + } + + // Nothing (left) on the exchange for this key — the row is a zombie. + // Close it with whatever it accumulated; exit info falls back to the + // last known bookkeeping on the row. + exitPrice := row.ExitPrice + if exitPrice <= 0 { + exitPrice = row.EntryPrice + } + exitTime := row.UpdatedAt + if exitTime <= 0 { + exitTime = nowMs + } + if err := s.ClosePositionFully(row.ID, exitPrice, row.ExitOrderID, exitTime, row.RealizedPnL, row.Fee, "reconcile"); err != nil { + logger.Infof(" ⚠️ Reconcile: failed to close zombie position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err) + continue + } + closed++ + logger.Infof(" 🧹 Reconcile: closed zombie %s %s row %d (qty %.6f, accumulated PnL %.2f) — not held on exchange", row.Symbol, row.Side, row.ID, row.Quantity, row.RealizedPnL) + } + + if closed > 0 { + logger.Infof("✅ Position reconcile: closed %d zombie row(s) on exchange %s", closed, exchangeID) + } + return closed, nil +} diff --git a/store/position_reconcile_test.go b/store/position_reconcile_test.go new file mode 100644 index 00000000..d317e144 --- /dev/null +++ b/store/position_reconcile_test.go @@ -0,0 +1,113 @@ +package store + +import ( + "testing" + "time" +) + +func newReconcileTestStore(t *testing.T) *Store { + t.Helper() + st, err := New(t.TempDir() + "/nofx.db") + if err != nil { + t.Fatalf("store.New failed: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func openRow(t *testing.T, st *Store, traderID, exchangeID, symbol, side string, qty, pnl float64, entryMs int64) int64 { + t.Helper() + pos := &TraderPosition{ + TraderID: traderID, + ExchangeID: exchangeID, + ExchangeType: "hyperliquid", + Symbol: symbol, + Side: side, + Quantity: qty, + EntryQuantity: qty, + EntryPrice: 100, + EntryTime: entryMs, + RealizedPnL: pnl, + Status: "OPEN", + Source: "sync", + CreatedAt: entryMs, + UpdatedAt: entryMs, + } + if err := st.Position().CreateOpenPosition(pos); err != nil { + t.Fatalf("create open position: %v", err) + } + return pos.ID +} + +func TestReconcileClosesZombiesKeepsLiveAndTrims(t *testing.T) { + st := newReconcileTestStore(t) + const exch = "ex-hl" + base := time.Now().Add(-48 * time.Hour).UnixMilli() + + // Zombie under the CURRENT trader id: exchange holds nothing for DRAM. + zombieID := openRow(t, st, "trader-now", exch, "xyz:DRAM", "LONG", 6.8, -20.34, base) + + // Zombie left by a PRIOR autopilot incarnation on the SAME exchange — + // this is the case a per-trader-id reconcile would miss. + legacyID := openRow(t, st, "trader-old", exch, "SOLUSDT", "SHORT", 6.94, -3.5, base+500) + + // Duplicates for SP500 (different incarnations): newest survives trimmed, + // older closes. + oldSP := openRow(t, st, "trader-old", exch, "xyz:SP500", "LONG", 0.07, -1.5, base+1000) + newSP := openRow(t, st, "trader-now", exch, "xyz:SP500", "LONG", 0.124, 2.5, base+2000) + + // Healthy row exactly matching live — untouched. + healthy := openRow(t, st, "trader-now", exch, "BTCUSDT", "LONG", 0.01, 0, base+3000) + + // Row on a DIFFERENT exchange account — must be out of scope. + otherExch := openRow(t, st, "trader-now", "ex-other", "ETHUSDT", "LONG", 2.0, 0, base+4000) + + live := map[string]float64{ + LivePositionKey("xyz:SP500", "long"): 0.057, + LivePositionKey("BTCUSDT", "long"): 0.01, + } + + closed, err := st.Position().ReconcileOpenPositionsWithLive(exch, live) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if closed != 3 { + t.Fatalf("expected 3 zombies closed (DRAM + legacy SOL + old SP500), got %d", closed) + } + + get := func(id int64) *TraderPosition { + t.Helper() + var pos TraderPosition + if err := st.Position().db.First(&pos, id).Error; err != nil { + t.Fatalf("load position %d: %v", id, err) + } + return &pos + } + + if dram := get(zombieID); dram.Status != "CLOSED" || dram.RealizedPnL != -20.34 || dram.CloseReason != "reconcile" { + t.Fatalf("DRAM zombie should close via reconcile keeping PnL, got %+v", dram) + } + if sol := get(legacyID); sol.Status != "CLOSED" { + t.Fatalf("legacy-incarnation SOL zombie on same exchange should close, got %+v", sol) + } + if sp := get(oldSP); sp.Status != "CLOSED" { + t.Fatalf("older duplicate SP500 row should close, got %+v", sp) + } + if sp := get(newSP); sp.Status != "OPEN" || sp.Quantity > 0.0571 || sp.Quantity < 0.0569 { + t.Fatalf("newest SP500 row should stay open trimmed to live 0.057, got status=%s qty=%v", sp.Status, sp.Quantity) + } + if btc := get(healthy); btc.Status != "OPEN" || btc.Quantity != 0.01 { + t.Fatalf("healthy row must be untouched, got %+v", btc) + } + if eth := get(otherExch); eth.Status != "OPEN" { + t.Fatalf("row on a different exchange must be out of scope, got %+v", eth) + } +} + +func TestReconcileNoOpenRowsIsNoop(t *testing.T) { + st := newReconcileTestStore(t) + closed, err := st.Position().ReconcileOpenPositionsWithLive("ex-empty", map[string]float64{}) + if err != nil || closed != 0 { + t.Fatalf("expected clean noop, got closed=%d err=%v", closed, err) + } +} diff --git a/trader/hyperliquid/order_sync.go b/trader/hyperliquid/order_sync.go index 803f4957..eff95452 100644 --- a/trader/hyperliquid/order_sync.go +++ b/trader/hyperliquid/order_sync.go @@ -133,9 +133,43 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI } logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount) + + // Reconcile local OPEN rows against the exchange's live book. Without + // this, any missed/unmatched fill leaves a zombie OPEN row that swallows + // every later close as a "partial close" — its realized PnL then never + // reaches the closed-trade statistics. Scoped by exchange account so rows + // left by prior autopilot incarnations are healed too. + if err := t.reconcilePositions(exchangeID, positionStore); err != nil { + logger.Infof("⚠️ Position reconcile skipped: %v", err) + } + return nil } +// reconcilePositions builds the live (symbol, side) → quantity map from the +// exchange (core perps + xyz dex) and lets the store close/trim any local +// OPEN rows on this exchange account the exchange no longer backs. +func (t *HyperliquidTrader) reconcilePositions(exchangeID string, positionStore *store.PositionStore) error { + livePositions, err := t.GetPositions() + if err != nil { + return fmt.Errorf("failed to get live positions: %w", err) + } + + liveQty := make(map[string]float64, len(livePositions)) + for _, pos := range livePositions { + symbol, _ := pos["symbol"].(string) + side, _ := pos["side"].(string) + qty, _ := pos["positionAmt"].(float64) + if symbol == "" || qty <= 0 { + continue + } + liveQty[store.LivePositionKey(market.Normalize(symbol), side)] += qty + } + + _, err = positionStore.ReconcileOpenPositionsWithLive(exchangeID, liveQty) + return err +} + // StartOrderSync starts background order sync task func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration, stop <-chan struct{}) { syncloop.Run(stop, interval, "Hyperliquid", func() error { diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index e471782d..93157d64 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -6,7 +6,6 @@ import { useAuth } from '../../contexts/AuthContext' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' import { DeepVoidBackground } from '../common/DeepVoidBackground' -import { LanguageSwitcher } from '../common/LanguageSwitcher' export function LoginPage() { const { language } = useLanguage() @@ -64,7 +63,6 @@ export function LoginPage() { return ( - {/* Self-contained centering grid — works regardless of parent flex setup */}
diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx index 34461d82..fee25b83 100644 --- a/web/src/components/common/HeaderBar.tsx +++ b/web/src/components/common/HeaderBar.tsx @@ -30,8 +30,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, currentPage, - language = 'zh' as Language, - onLanguageChange, + language = 'en' as Language, user, onLogout, onPageChange, @@ -40,12 +39,10 @@ export default function HeaderBar({ const navigate = useNavigate() const location = useLocation() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [userDropdownOpen, setUserDropdownOpen] = useState(false) const [userMode, setUserModeState] = useState( () => getUserMode() ?? 'advanced' ) - const dropdownRef = useRef(null) const userDropdownRef = useRef(null) const resolvedCurrentPage = currentPage ?? getCurrentPageForPath(location.pathname) @@ -63,12 +60,6 @@ export default function HeaderBar({ // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setLanguageDropdownOpen(false) - } if ( userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node) @@ -357,56 +348,7 @@ export default function HeaderBar({ ) )} - {/* Language Toggle - Always at the rightmost */} -
- - - {languageDropdownOpen && ( -
- - - -
- )} -
+ {/* Language switcher removed — the product UI is English-only. */} @@ -619,28 +561,8 @@ export default function HeaderBar({ ))} - {/* Account / Lang */} -
- {/* Lang Switcher */} -
- {['zh', 'en', 'id'].map((lang) => ( - - ))} -
- + {/* Account (language switcher removed — English-only UI) */} +
{/* Auth Actions */} {isLoggedIn && user ? (