Files
PythagorasGoal/scripts/analysis/reconcile_account.py
T
Adriano Dal Pastro 612f2bfced feat(live): reconcile resting + orphan single-leg + circuit-breaker venue-lock + FEED_BOOK_GAP
Codice della tornata v1.1.27/28 (gia' in produzione, mai committato):
- reconcile_account: estensione ordini RESTING (FILLED_UNBOOKED/MISSING/STALE,
  caso MR02_BTC: TP fillato di notte scoperto ore dopo) + expected_resting in books
- strategy_worker: orphan_legs su REAL_CLOSE_PARTIAL anche single-leg, persistito
- execution: circuit-breaker su venue-lock admin (stop ordini dopo errori ripetuti)
- runner/hourly_report: alert FEED_BOOK_GAP + timestamp closed trades
- cerbero_client: get_open_orders (merge all + trigger_all)
Test: 12 nuovi, suite completa 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:29:02 +00:00

186 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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
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 main():
client = CerberoClient()
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"]
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("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest)
else f"⚠️ DRIFT PERSISTENTE (pos={len(bad)} resting={len(bad_rest)})")
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)
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)