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>
This commit is contained in:
Adriano Dal Pastro
2026-06-12 20:29:02 +00:00
parent a2d581691a
commit 612f2bfced
11 changed files with 628 additions and 25 deletions
+114 -18
View File
@@ -29,7 +29,8 @@ 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 # fonte UNICA dei libri (usata
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
@@ -53,15 +54,78 @@ def compute_drift(client: CerberoClient | None = None) -> list[dict]:
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():
rows = compute_drift()
client = CerberoClient()
rows = compute_drift(client)
resting = compute_resting_drift(client)
bad = [r for r in rows if not r["ok"]]
if bad:
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"drift su {len(bad)} strumenti: ricontrollo fra {RECHECK_SLEEP}s (anti-race)...")
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()
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:
@@ -69,21 +133,53 @@ def main():
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:
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
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 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__":
sys.exit(1 if main() else 0)
# 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)
+14 -4
View File
@@ -96,8 +96,14 @@ def lossguard_section() -> str:
return "\n".join(L)
# Epoca v1.1.26 (deploy 2026-06-11 ~21:40 UTC): gate TP_PHANTOM attivo. I close
# precedenti includono il churn da TP fantasma dell'11-06 17:32-17:58 (~24 giri,
# win-rate inquinato) -> le accuratezze "pulite" si leggono da qui in poi.
EPOCH_V1126 = "2026-06-11T21:40:00"
def collect():
closed = [] # (sleeve, reason, net_return, pnl, win)
closed = [] # (sleeve, reason, net_return, pnl, win, ts)
open_pos = [] # dict per posizione aperta
realized = 0.0
for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))):
@@ -119,7 +125,7 @@ def collect():
# resta il sim diagnostico: sui TP fantasma da spike testnet diceva
# 26/0 mentre il reale era 11/15). Fallback nr>0 per eventi storici.
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl,
bool(ev.get("win", nr > 0))))
bool(ev.get("win", nr > 0)), ev.get("ts", "")))
if "positions" in st or "weights" in st:
continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata
if st.get("in_position"):
@@ -187,7 +193,7 @@ def build_report() -> str:
# breakdown per motivo
by_reason = defaultdict(lambda: [0, 0, 0.0]) # reason -> [win, loss, pnl]
for _, reason, _, pnl, win in closed:
for _, reason, _, pnl, win, _ in closed:
r = by_reason[reason]
r[0 if win else 1] += 1
r[2] += pnl
@@ -206,8 +212,12 @@ def build_report() -> str:
if eq is not None:
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
# 1) CHIUSI
# 1) CHIUSI — totale storico + epoca corrente (post gate TP_PHANTOM): i
# numeri pre-v1.1.26 includono il churn fantasma e non misurano la strategia
cur = [c for c in closed if c[5] >= EPOCH_V1126]
cpos = sum(1 for c in cur if c[4])
L.append(f"\n✅ <b>CHIUSI</b>: {pos} positivi / {neg} negativi (netto fee)")
L.append(f" epoca v1.1.26+ (TP_PHANTOM attivo): {cpos}/{len(cur) - cpos}")
rows = [f"{'motivo':<12}{'':>3}{'':>4}{'PnL€':>9}"]
for reason, (w, l, pnl) in sorted(by_reason.items(), key=lambda x: x[1][2]):
rows.append(f"{reason:<12}{w:>3}{l:>4}{pnl:>+9.2f}")