live: diagnosi del guasto venue, disaster-SL scoperto, isolamento per asset, cron :47

Nato da "stato trades" durante una manutenzione Deribit (system_maintenance 11051,
08:57-09:20 UTC). Contati i log invece che ricordarli: 1.499 giri dal 23/06, 3 con
manutenzione, 5 traceback duri — e 4 dei 5 sono il NOSTRO gateway, non il venue.
Le release Deribit escono il martedi' alle 09:00 UTC: il cron al :07 ci cadeva dentro
per costruzione (21/07, 11/08, 18/08, 25/08).

DIAGNOSI (src/live/venue_probe.py, nuovo)
Sonda l'API PUBBLICA Deribit senza gateway e senza credenziali, e classifica il guasto:
VENUE_MANUTENZIONE / VENUE_GIU / GATEWAY / IGNOTO. Prima ogni causa stampava la stessa
riga ("conto non leggibile (offline)") con nota di diagnosi CABLATA — P4 violata, e il
18/08 il codice 11051 era gia' dentro il processo senza arrivare a chi decideva la
gravita'. Parte solo dopo un guasto: sul percorso sano costa zero. P1 rispettata: la
firma 11051 si importa da venue_watch.is_maintenance, non si ridichiara.

P9: la manutenzione dentro lo slot declassa il titolo a info, ma solo su EVIDENZA della
sonda (mai sull'orologio) e solo dentro la durata annunciata — oltre, RIALZA. Un gateway
rotto di martedi' mattina resta al massimo.

DISASTER-SL SCOPERTO (src/live/execution.py)
ensure_disaster_sl cancella i bracket incoerenti PRIMA di ripiazzarne uno: fra le due
chiamate la posizione e' senza stop. Se il ripiazzamento sollevava, l'eccezione risaliva
a main() e il guasto peggiore aveva la faccia di un errore qualunque. Ora: due tentativi,
poi stato `naked`, distinto da `place-failed` (P5). Non e' "non sono riuscito a
proteggere": e' "ho tolto la protezione e non sono riuscito a rimetterla" -> allarme
massimo, mai declassato. La SEQUENZA non e' stata invertita: piazza-poi-cancella sembra
piu' sicuro ma "sembra" non basta con soldi veri senza misurarlo.

ISOLAMENTO PER ASSET (scripts/live/book_execute.py)
Il 21/07 un 502 dentro ensure_disaster_sl su BTC ha ucciso il giro intero: nel log ETH
non compare — ne' ribilanciato ne' verificato nella protezione. Ora un asset che esplode
non ferma il ciclo, e il giro degradato esce con codice 2.

CRON :07 -> :47
Il vincolo vero non era ":07" ma "fuori dai ~26s del minuto tondo" (rate-limit per-IP
auto-saturato dal collettore catena, misura del 30/07). Il :47 lo soddisfa e in piu' sta
fuori dallo slot di release. PREVISIONE DICHIARATA (M12): 3 delle 4 finestre osservate
sono rientrate entro l'ora -> il :47 ne avrebbe scavalcate 3 su 4; se martedi' prossimo
becca comunque la manutenzione, la previsione e' sbagliata.

WATERMARK AVVELENATO DAI TEST (tests/conftest.py, nuovo)
Trovato addosso: lanciando la suite, data/live/equity_seen.json passava a $5.000 e il giro
successivo mandava un allarme Telegram FALSO ("USCITA DI FONDI -86,6%"). Non cosmetico:
cap_fallback = min(cap_config, watermark x frac) sarebbe passato da $334 a $2.500/asset,
~7,5x di leva su un conto da $668, sul ramo eq_fallback che allerta e NON blocca — cioe'
i test potevano armare il pericolo che il watermark esiste per impedire. Riparato con una
fixture AUTOUSE, non per-test: chiedere a ogni autore di ricordarsene ha gia' perso il
26/07 e il 21/08. Resta esposto lo stesso errore su trades.db e book_executions.jsonl.

BLOCCATO, NON RINVIATO
Il fallback diretto ai privati Deribit richiede chiavi API create dall'operatore: in
locale esiste solo CERBERO_TOKEN. Il gateway resta un punto singolo di guasto non
aggirabile (CLAUDE.md 5.11). La sonda dice di chi e' il guasto, non lo aggira.

Test: 20 nuovi, nessuno tocca la rete; controllo positivo fatto (5 su 7 falliscono contro
il codice vecchio). Suite 730 passati, 1 fallito — quello gia' noto di 5.9 (deriva dati).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9UyJLHzR7EJzxR3iQ3RN1
This commit is contained in:
Adriano Dal Pastro
2026-08-25 10:27:36 +00:00
parent 2751a7efd0
commit 426735448e
12 changed files with 1136 additions and 47 deletions
+98 -33
View File
@@ -33,6 +33,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
from src.live.book import book_report
from src.live.execution import DeribitTrader
from src.live.notifier import notify
from src.live.venue_probe import diagnose, errori_dal_report
CONFIG = PROJECT_ROOT / "config" / "live.json"
LOG_DIR = PROJECT_ROOT / "data" / "live"
@@ -131,18 +132,35 @@ def _run():
# `online` e' falso quando il mark di BTC non viene da mainnet: la ragione sta in
# `mark_src` ("fallback close (<Eccezione>)") ma non veniva MAI stampata, quindi
# l'allerta diceva solo "conto offline" — vero e inutile. Stesso buco del feed SKH.
#
# DAL 2026-08-25 la riga dice anche DI CHI E' IL GUASTO (P4: *cosa* e *perche'*). Fino a
# ieri "conto offline" copriva due cause con azioni opposte: Deribit in manutenzione
# (attesa, rientra da sola, azione nessuna) e il nostro gateway rotto (4 dei 5 traceback
# in 63 giorni, ed e' l'unico pezzo riparabile). Vedi src/live/venue_probe.py.
srcs = "; ".join(f"{a['asset']}: {a.get('mark_src')}" for a in r["assets"])
d = diagnose(errori_dal_report(r))
print(f" conto non leggibile (offline) -> stop, non eseguo a cieco.\n mark: {srcs}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco",
"mark": srcs[:300]})
# P9: un allarme MASSIMO speso per un evento ATTESO e' un allarme che non verra'
# letto il giorno che e' vero. `atteso` declassa la gravita', NON registra di meno.
notify(f"{d.gravita} BOOK LIVE — conto offline ({d.verdetto})",
{"nota": "salto l'esecuzione, non opero a cieco",
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"mark": srcs[:200], "prova": d.prova[:200]})
return
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
d = diagnose(errori_dal_report(r))
print(f" 🛑 POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify("🛑 BOOK LIVE — posizione non leggibile", {"error": r["pos_error"],
"nota": "salto l'esecuzione, non opero a cieco"})
notify(f"{d.gravita} BOOK LIVE — posizione non leggibile ({d.verdetto})",
{"error": r["pos_error"], "nota": "salto l'esecuzione, non opero a cieco",
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"prova": d.prova[:200]})
return
stale_days = _data_age_days(r.get("last_data"))
@@ -189,6 +207,13 @@ def _run():
trader = DeribitTrader() if do_execute else None
actions = []
# ISOLAMENTO PER ASSET (2026-08-25). Prima, un'eccezione dentro il corpo del ciclo risaliva
# fino a `main()` e uccideva il GIRO INTERO: il 2026-07-21 alle 09:00 UTC un 502 dentro
# `ensure_disaster_sl` su BTC ha fatto si' che ETH non venisse nemmeno guardato — niente
# ribilancio e, soprattutto, **nessuna verifica della sua protezione**. Un guasto su un asset
# non deve togliere la rete di sicurezza all'altro.
falliti: list[str] = []
scoperti: list[str] = []
for a in r["assets"]:
asset, inst = a["asset"], a["instrument"]
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
@@ -206,37 +231,66 @@ def _run():
print(f" {asset:<3} TP {a['tp_frac']:+.3f} · SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} "
f"| pos ${cur:+,.0f} -> {act}")
if do_execute and order is not None:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
newpos = trader.position_usd(inst)
for f in fills:
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
det if f.verified else {**det, "notes": f.notes})
print(f" reconcile: pos ${newpos:,.0f}")
if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
if ds.get("state") == "placed":
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4)})
elif ds.get("state") == "place-failed":
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
try:
if do_execute and order is not None:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
newpos = trader.position_usd(inst)
for f in fills:
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
det if f.verified else {**det, "notes": f.notes})
print(f" reconcile: pos ${newpos:,.0f}")
if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
stato = ds.get("state")
print(f" disaster-SL: {stato}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
if stato == "placed":
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4)})
elif stato == "place-failed":
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
elif stato == "naked":
# NON e' "non sono riuscito a proteggere": e' "HO TOLTO la protezione e non
# sono riuscito a rimetterla". Posizione aperta senza stop on-book -> gravita'
# massima, e MAI declassata da P9: una posizione scoperta non e' mai attesa.
scoperti.append(asset)
print(f" 🚨 {asset}: POSIZIONE SCOPERTA — {ds.get('notes')}")
notify("🚨 BOOK — POSIZIONE SCOPERTA (disaster-SL rimosso e non ripiazzato)",
{"asset": asset, "stop_voluto": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4),
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
"notes": str(ds.get("notes"))[:300]})
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
falliti.append(asset)
act = f"{act} [FALLITO]"
print(f" 🛑 {asset}: giro fallito, PASSO ALL'ASSET SUCCESSIVO -> {type(e).__name__}: {e}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify(f"{d.gravita} BOOK — asset {asset} fallito ({d.verdetto})",
{"asset": asset, "error": f"{type(e).__name__}: {e}"[:200],
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"nota": "gli altri asset del book sono stati comunque elaborati"})
actions.append(act)
print()
if scoperti:
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
if falliti:
print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}")
if not do_execute:
print(" => DRY-RUN: nessun ordine inviato." +
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
@@ -244,13 +298,24 @@ def _run():
print(" => Nessuna azione: conto gia' al target netto del book.")
else:
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
if scoperti or falliti:
sys.exit(2)
def main():
try:
_run()
except Exception as e:
notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"})
# Anche l'ultima rete porta la DIAGNOSI, non solo il tipo d'errore: e' l'allerta che
# arriva quando tutto il resto non ha funzionato, ed e' proprio li' che serve sapere se
# riaprire il gateway o aspettare che rientri il venue (P4).
d = diagnose([f"{type(e).__name__}: {e}"])
notify(f"{d.gravita} BOOK LIVE — ERRORE ({d.verdetto})",
{"error": f"{type(e).__name__}: {e}"[:250], "perche": d.perche,
"atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no"})
raise
+6
View File
@@ -60,7 +60,13 @@ def reconcile() -> None:
t = d.trade_history(ins, limit=100)
print(f" venue {ins:<20}: {len(t)} trade visibili (l'endpoint TRONCA: non e' un backfill)")
except Exception as e:
# "NON LETTO" resta (P5: *non vedo* non e' *zero*), ma da solo non diceva DI CHI e' il
# guasto: il 2026-08-25 questa riga stampava `HTTPError` mentre Deribit era in
# manutenzione annunciata, indistinguibile da un gateway rotto. Ora porta il perche' (P4).
from src.live.venue_probe import diagnose
d = diagnose([f"{type(e).__name__}: {e}"])
print(f" venue: NON LETTO ({type(e).__name__}) — non e' 'zero trade', e' 'non misurato'")
print(f" diagnosi: {d.riga()}")
def report() -> None: