"""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 PAPER = PROJECT_ROOT / "data" / "portfolio_paper" RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto def _inst(asset: str) -> str: return f"{asset}_USDC-PERPETUAL" def expected_books() -> tuple[dict[str, float], dict[str, float]]: """(atteso per strumento dai libri, quota orfana registrata per strumento). Amount firmato in base-coin: buy=+, sell=-.""" books: dict[str, float] = {} orphans: dict[str, float] = {} for sp in sorted(PAPER.glob("*/status.json")): st = json.loads(sp.read_text()) wid = sp.parent.name parts = wid.split("__") if st.get("real_in_position"): if "real_amount_a" in st and st.get("real_amount_a"): # pairs: asset da worker_id (…__ETH_BTC__1h) a, b = parts[1].split("_") for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]), (b, st["real_side_b"], st["real_amount_b"])): sgn = 1 if side == "buy" else -1 books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt elif st.get("real_amount"): # single-leg: fade/DIP01/SH01 sgn = 1 if st.get("real_side") == "buy" else -1 books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"] for o in st.get("orphan_legs", []): # gamba che il libro ha chiuso ma il conto ha ancora (entry_side = verso # in cui la posizione e' rimasta aperta sul conto) sgn = 1 if o.get("entry_side") == "buy" else -1 orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"]) return books, orphans def account_positions(client: CerberoClient) -> dict[str, float]: """Posizioni reali per strumento, amount firmato in base-coin.""" out: dict[str, float] = {} for p in client.get_positions(currency="USDC") or []: inst = p.get("instrument") size = float(p.get("size") or 0) mark = float(p.get("mark_price") or 0) if not inst or not size or not mark: continue amt = size / mark out[inst] = amt if p.get("direction") == "long" else -amt return out def compute_drift() -> list[dict]: client = CerberoClient() books, orphans = expected_books() acct = account_positions(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 main(): rows = compute_drift() bad = [r for r in rows if not r["ok"]] if bad: # anti-race: un worker poteva essere a meta' open/close -> ricontrolla print(f"drift su {len(bad)} strumenti: ricontrollo fra {RECHECK_SLEEP}s (anti-race)...") time.sleep(RECHECK_SLEEP) rows = compute_drift() bad = [r for r in rows if not r["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("\nESITO:", "OK — conto allineato ai libri" if not bad else f"⚠️ DRIFT PERSISTENTE su {len(bad)} strumenti") if bad and "--telegram" in sys.argv: from src.live.telegram_notifier import notify_event 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") return bool(bad) if __name__ == "__main__": sys.exit(1 if main() else 0)