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
+34 -5
View File
@@ -197,7 +197,25 @@ class DeribitTrader(DeribitRead):
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente):
- flat -> cancella eventuali bracket orfani;
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione;
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap)."""
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
ripiazzamento sollevava, quell'eccezione risaliva fino a `main()` e il giro moriva —
cioe' il guasto peggiore (posizione SCOPERTA) si presentava con la stessa faccia di un
errore qualunque, e per giunta **saltava l'altro asset**. E' successo davvero il
2026-07-21 alle 09:00 UTC: 502 su `get_positions` dentro `ensure_disaster_sl` su BTC,
ed ETH non e' stato nemmeno guardato.
Ora: un secondo tentativo immediato, e se anche quello fallisce si ritorna lo stato
**`naked`** — distinto da `place-failed` (P5: *distinguere guasti diversi anche quando
l'azione e' la stessa*), perche' qui non e' "non sono riuscito a mettere la protezione":
e' "**ho tolto la protezione e non sono riuscito a rimetterla**". Il chiamante lo
escala al massimo.
NB non si inverte l'ordine in *piazza-poi-cancella*: due STOP reduce_only contemporanei
sono probabilmente innocui (il secondo diventa no-op a posizione chiusa), ma "probabilmente"
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
misurarlo. Riparato il silenzio, non toccata la sequenza.
"""
pos = self.position_usd(instrument)
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
if abs(pos) < FLAT_USD:
@@ -215,10 +233,21 @@ class DeribitTrader(DeribitRead):
if want_amount and abs(amt - want_amount) < want_amount * 0.1 and stp > 0 \
and abs(stp - want_stop) / want_stop < 0.05:
return {"state": "ok", "stop": stp, "amount": amt}
cancellati = 0
for o in brackets: # incoerente o multipli -> ricostruisci UN bracket
self.cancel_order(o.get("order_id"))
f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount, want_stop,
label=DISASTER_LABEL)
return {"state": "placed" if f.verified else "place-failed", "stop": want_stop,
"amount": want_amount, "notes": f.notes}
cancellati += 1
tentativi: list[str] = []
for _ in range(2): # la posizione e' scoperta da qui: si riprova subito
try:
f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount,
want_stop, label=DISASTER_LABEL)
return {"state": "placed" if f.verified else "place-failed", "stop": want_stop,
"amount": want_amount, "cancelled": cancellati, "notes": f.notes}
except Exception as e: # noqa: BLE001 — si registra il motivo (P3), non si ingoia
tentativi.append(f"{type(e).__name__}: {e}")
return {"state": "naked", "stop": want_stop, "amount": want_amount,
"cancelled": cancellati,
"notes": (f"bracket cancellati ({cancellati}) e ripiazzamento fallito 2 volte: "
+ " | ".join(tentativi))}
# trade_history / open_orders ereditati da DeribitRead (read-only)