"""Reconciler READ-ONLY conto Deribit vs libri dei worker (primo passo verso il position-manager, audit 2026-06-11: il conto era short 0.027 ETH oltre i libri e nessuno se n'era accorto per ore). Confronta, per ogni strumento USDC: atteso = Σ quote reali dei worker (status.json persistiti: fade/DIP/SH single-leg + pairs a 2 gambe) + Σ gambe ORFANE registrate (orphan_legs: posizioni che il conto ha ancora ma i libri hanno chiuso — drift SPIEGATO) reale = get_positions(currency=USDC) (size USD / mark = amount in coin) Drift oltre tolleranza (1.5×step del contratto) -> tabella + alert Telegram `ACCOUNT_DRIFT` (con --telegram). Anti-race: se al primo passaggio c'e' drift, rilegge libri+conto dopo qualche secondo e segnala solo se persiste (un worker poteva essere a meta' di un open/close). Nessun ordine, nessuna modifica di stato: SOLO lettura. uv run python scripts/analysis/reconcile_account.py # stampa uv run python scripts/analysis/reconcile_account.py --telegram # + alert se drift """ from __future__ import annotations import json import sys import time from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from src.live.cerbero_client import CerberoClient from src.live.execution import contract_spec from src.live.books import real_books, account_net, expected_resting, PAPER # fonte UNICA dei libri (usata # anche dal guard del netting) RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto STALE_REAL_MIN = 15 # un worker vivo aggiorna status.json ~ogni poll (60s); # real_in_position con mtime oltre questo = NON gestito def compute_drift(client: CerberoClient | None = None) -> list[dict]: client = client or CerberoClient() books, orphans = real_books() acct = account_net(client) rows = [] for inst in sorted(set(books) | set(orphans) | set(acct)): exp = books.get(inst, 0.0) + orphans.get(inst, 0.0) real = acct.get(inst, 0.0) step = contract_spec(inst).get("step", 0.001) tol = TOL_STEPS * step rows.append(dict(inst=inst, books=books.get(inst, 0.0), orphans=orphans.get(inst, 0.0), exp=exp, real=real, drift=real - exp, tol=tol, ok=abs(real - exp) <= tol)) return rows def _book_orders(client: CerberoClient) -> dict[str, dict]: """Ordini aperti sul conto, merge type='all' + 'trigger_all' per order_id (Deribit puo' omettere i trigger untriggered da 'all').""" orders: dict[str, dict] = {} for typ in ("all", "trigger_all"): for o in client.get_open_orders(currency="USDC", type=typ) or []: oid = str(o.get("order_id") or "") if oid: orders[oid] = o return orders def _resting_filled(client: CerberoClient, instrument: str, order_id: str) -> float: """Amount fillato di un ordine resting dal trade history (fonte autorevole).""" try: return sum(float(t.get("amount", 0) or 0) for t in client.get_trade_history(limit=100, instrument_name=instrument) if str(t.get("order_id")) == str(order_id)) except Exception: return 0.0 def compute_resting_drift(client: CerberoClient | None = None) -> list[dict]: """Reconcile degli ordini RESTING (estensione 2026-06-12, dopo il caso MR02_BTC: TP resting fillato sul book di notte + disaster-SL sparito, scoperti solo al close sim ore dopo). Tre classi di anomalia: FILLED_UNBOOKED l'ordine atteso non e' in book e ha fill nel trade history mentre il worker si crede ancora in posizione (il caso MR02) MISSING l'ordine atteso non e' in book e non ha fill (cancellato da altri/exchange; per il DSL triggered il fill ha un order_id NUOVO -> qui appare MISSING e il drift posizioni completa) STALE ordine in book con label di un nostro worker ma NON atteso dai libri (worker flat/morto: fillerebbe a sorpresa) """ client = client or CerberoClient() expected = expected_resting() book = _book_orders(client) rows: list[dict] = [] for e in expected: if e["order_id"] in book: rows.append({**e, "status": "OK"}) continue filled = _resting_filled(client, e["instrument"], e["order_id"]) rows.append({**e, "status": "FILLED_UNBOOKED" if filled > 0 else "MISSING", "filled": filled}) exp_ids = {e["order_id"] for e in expected} workers = {p.name for p in PAPER.glob("*") if p.is_dir()} for oid, o in book.items(): if oid not in exp_ids and str(o.get("label") or "") in workers: rows.append(dict(worker=o.get("label"), instrument=o.get("instrument"), order_id=oid, kind=o.get("order_type"), status="STALE")) return rows def compute_stale_real_positions(max_age_min: int = STALE_REAL_MIN) -> list[dict]: """Worker che dichiarano una posizione REALE ma il cui status.json e' fermo da oltre max_age_min minuti = posizione NON gestita da nessun runner (caso MR02_BTC 1h del 2026-06-12: ritirato dallo swap a 15m mentre short reale, con TP perso nel netting -> short nudo su disaster-SL). Il drift di compute_drift NON lo vede: lo status fermo conta ancora come 'libro' -> conto == libri. Discriminante robusto e venue-agnostico: la STALENESS (un worker vivo scrive a ogni poll), cattura ritirati-da-swap, crashati, worker rimossi dal config.""" now = time.time() out: list[dict] = [] for sp in sorted(PAPER.glob("*/status.json")): try: st = json.loads(sp.read_text()) except Exception: continue if not st.get("real_in_position"): continue age_min = (now - sp.stat().st_mtime) / 60.0 if age_min > max_age_min: out.append(dict(worker=sp.parent.name, age_min=round(age_min, 1), real_amount=st.get("real_amount") or st.get("real_amount_a"), real_side=st.get("real_side") or st.get("real_side_a", ""))) return out def main(): client = CerberoClient() rows = compute_drift(client) resting = compute_resting_drift(client) stale = compute_stale_real_positions() bad = [r for r in rows if not r["ok"]] bad_rest = [r for r in resting if r["status"] != "OK"] if bad or bad_rest: # anti-race: un worker poteva essere a meta' open/close -> ricontrolla print(f"anomalie (pos={len(bad)} resting={len(bad_rest)}): " f"ricontrollo fra {RECHECK_SLEEP}s (anti-race)...") time.sleep(RECHECK_SLEEP) rows = compute_drift(client) resting = compute_resting_drift(client) bad = [r for r in rows if not r["ok"]] bad_rest = [r for r in resting if r["status"] != "OK"] print(f"{'strumento':<22}{'libri':>10}{'orfani':>9}{'atteso':>10}{'conto':>10}{'drift':>10} esito") for r in rows: print(f"{r['inst']:<22}{r['books']:>10.4f}{r['orphans']:>9.4f}{r['exp']:>10.4f}" f"{r['real']:>10.4f}{r['drift']:>+10.4f} {'OK' if r['ok'] else '⚠️ DRIFT'}") if not rows: print("(nessuna posizione attesa ne' reale)") print(f"\n{'resting':<45}{'kind':>6} {'order_id':<22} stato") for r in resting: print(f"{r['worker']:<45}{r['kind']:>6} {r['order_id']:<22} " f"{'OK' if r['status'] == 'OK' else '⚠️ ' + r['status']}") if not resting: print("(nessun ordine resting atteso ne' in book)") print(f"\n{'worker stantio (pos. reale non gestita)':<45}{'amount':>10}{'eta_min':>9}") for r in stale: print(f"{r['worker']:<45}{(r['real_amount'] or 0):>10}{r['age_min']:>9.0f} ⚠️ STALE_REAL") if not stale: print("(nessuna posizione reale stantia)") print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest or stale) else f"⚠️ ANOMALIE (drift_pos={len(bad)} resting={len(bad_rest)} stale={len(stale)})") if stale and "--telegram" in sys.argv: from src.live.telegram_notifier import notify_event notify_event("STALE_REAL_POSITION", { "worker": [{k: r.get(k) for k in ("worker", "real_amount", "real_side", "age_min")} for r in stale], "note": ("posizione REALE con status.json fermo da >%d min = worker non " "gestito (ritirato da uno swap di timeframe / crashato): la " "posizione e' nuda sul conto, flattare a mano. Caso MR02_BTC 1h " "del 2026-06-12." % STALE_REAL_MIN)}) print("[telegram] alert STALE_REAL_POSITION inviato") if (bad or bad_rest) and "--telegram" in sys.argv: from src.live.telegram_notifier import notify_event if bad: notify_event("ACCOUNT_DRIFT", { "strumenti": {r["inst"]: {"atteso": round(r["exp"], 5), "conto": round(r["real"], 5), "drift": round(r["drift"], 5)} for r in bad}, "note": ("conto != libri worker oltre tolleranza (drift NON spiegato dagli " "orfani registrati): verificare close cappati/gambe respinte — " "vedi docs/diary/2026-06-11-system-audit.md")}) print("[telegram] alert ACCOUNT_DRIFT inviato") if bad_rest: notify_event("RESTING_DRIFT", { "ordini": [{k: r.get(k) for k in ("worker", "kind", "order_id", "status", "filled")} for r in bad_rest], "note": ("FILLED_UNBOOKED = resting fillato col worker ancora in posizione " "(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; " "STALE = in book senza libro corrispondente")}) print("[telegram] alert RESTING_DRIFT inviato") return bool(bad or bad_rest or stale) if __name__ == "__main__": # Il run delle 11:40 del 2026-06-12 e' morto in silenzio su un 502 (Deribit/gateway # giu') -> il guardiano che non suona e' indistinguibile dal "tutto ok". Su errore: # alert RECONCILE_FAIL (con --telegram) + exit 2. try: sys.exit(1 if main() else 0) except Exception as exc: print(f"RECONCILE_FAIL: {exc}") if "--telegram" in sys.argv: try: from src.live.telegram_notifier import notify_event notify_event("RECONCILE_FAIL", { "errore": str(exc)[:200], "note": "reconciler NON eseguito: conto non verificato in quest'ora"}) except Exception: pass sys.exit(2)