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

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