From 22b77668f168e53cec4b36e2631a46179eb2e46c Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 11 Jun 2026 20:43:17 +0000 Subject: [PATCH] feat(ops): reconciler read-only conto vs libri worker (primo passo verso il position-manager) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/analysis/reconcile_account.py: per strumento USDC confronta l'atteso (somma quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs registrati = drift spiegato) col conto reale (get_positions, size/mark -> coin). Tolleranza 1.5x step contratto, anti-race (ricontrollo a 10s), alert Telegram ACCOUNT_DRIFT con --telegram. SOLO lettura, gira dall'host (cron orario :40, nessun deploy). Al primo run: vero positivo su BTC (libro MR02 short 0.0028 vs conto flat — TP fillato dallo spike reale, si riconcilia alla chiusura sim). Co-Authored-By: Claude Fable 5 --- docs/diary/2026-06-11-system-audit.md | 10 ++ scripts/analysis/reconcile_account.py | 136 ++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 scripts/analysis/reconcile_account.py diff --git a/docs/diary/2026-06-11-system-audit.md b/docs/diary/2026-06-11-system-audit.md index 9f05b5c..db0c2e7 100644 --- a/docs/diary/2026-06-11-system-audit.md +++ b/docs/diary/2026-06-11-system-audit.md @@ -56,6 +56,16 @@ orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni in 2 minuti) — accettabile su testnet, NON per capitale vero. Raccomandazione: C ora, A prima di passare a capitale reale. +**Primo passo verso A — FATTO (sera stessa): reconciler read-only.** +`scripts/analysis/reconcile_account.py`: per ogni strumento USDC confronta +atteso (Σ quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs +registrati = drift SPIEGATO) vs conto reale (`get_positions`, size/mark → coin), +tolleranza 1.5×step, anti-race (ricontrollo a 10s prima di segnalare). In crontab +host ORARIO (:40) con alert Telegram `ACCOUNT_DRIFT`. Al primo run ha beccato un +vero positivo: BTC libro short 0.0028 vs conto flat (il TP di MR02_BTC fillato +dallo spike reale delle 12:22Z, che il worker riconcilierà alla chiusura sim) — +esattamente la classe di divergenza che prima restava invisibile per ore. + ## Altri esiti della tornata - **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate diff --git a/scripts/analysis/reconcile_account.py b/scripts/analysis/reconcile_account.py new file mode 100644 index 0000000..d7f4f86 --- /dev/null +++ b/scripts/analysis/reconcile_account.py @@ -0,0 +1,136 @@ +"""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)