fix: reconcile local positions against the live exchange book — stop lost PnL

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.
This commit is contained in:
tinkle-community
2026-07-10 20:04:57 +09:00
parent 7a66d048f3
commit 21407030ea
7 changed files with 283 additions and 86 deletions

View File

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