mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 02:14:35 +08:00
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:
113
store/position_reconcile_test.go
Normal file
113
store/position_reconcile_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user