85043bf2d3
Caso MR02_BTC 1h (2026-06-13): ritirato dallo swap a 15m mentre short reale -> short nudo (TP perso nel netting), reconciler cieco perche' lo status fermo contava ancora come libro. compute_stale_real_positions: worker con real_in_position e status.json fermo >15min = non gestito -> alert STALE_REAL_POSITION. Discriminante = staleness (venue-agnostico: cattura ritirati-da-swap/crashati). Gira dal cron host :40 (no rebuild). 3 test nuovi, suite 132 passed. Orfano chiuso a mano (testnet, +0.85 netto). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""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)
|