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)
+192
View File
@@ -0,0 +1,192 @@
"""Diagnosi di un guasto sul percorso d'esecuzione: e' il VENUE, o e' il NOSTRO gateway?
PERCHE' ESISTE (2026-08-25). Fino a oggi un guasto sul percorso Deribit produceva sempre lo
stesso messaggio — «conto non leggibile (offline)» — a prescindere dalla causa. Ma le cause sono
due, e vogliono azioni OPPOSTE:
* **Deribit in MANUTENZIONE.** Evento ATTESO: le release Deribit escono il martedi' alle 09:00
UTC, annunciate 15-30 minuti. Rientra da sola, l'azione giusta e' NESSUNA. Misurato sul nostro
log: 4 episodi in 1.499 giri (0,27%), tutti di martedi' fra le 09:00 e le 09:07 —
2026-07-21, 2026-08-11, 2026-08-18, 2026-08-25.
* **Il NOSTRO gateway** (`cerbero-mcp.tielogic.xyz`) rotto. Sono **4 dei 5 traceback** in 63
giorni: un 404 su `get_positions`, un 502, due `ReadTimeout`. E' l'unico pezzo della catena
che possiamo riparare, ed e' quello che ha fallito di piu'.
Stessa riga di log per due guasti che vogliono due azioni diverse: e' la **regola P4** violata
(*un'allerta risponde a DUE domande: «cosa» — decide — e «perche'» — ripara*), con l'aggravante
che la nota di diagnosi era CABLATA ("offline"): peggio di nessuna nota, perche' si legge come
una misura invece che come un'ipotesi.
REGOLA P1 — *un sorvegliante deve DERIVARE il proprio bersaglio dal codice sorvegliato, mai
ridichiararlo.* Questo modulo **non ridichiara** la firma della manutenzione Deribit: la IMPORTA
da `venue_watch.is_maintenance`, dove vive ed e' gia' testata. Un secondo elenco di codici
d'errore, libero di divergere dal primo, e' il difetto piu' ricorrente del progetto (5 occorrenze).
COME DISTINGUE. Interroga l'API **pubblica** di Deribit in diretta: niente gateway, niente
credenziali (`public/test` e' tokenless). Se il venue risponde, il guasto e' nostro; se risponde
col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per un'altra ragione;
se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5).
La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente.
⚠️ **LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e'
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di
`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero'
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario,
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare.
REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra
di meno. L'episodio finisce nel log e nella notifica comunque.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime, timezone
import requests
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
# --- verdetti -----------------------------------------------------------------------------
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot
V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione
V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
# Endpoint PUBBLICO e tokenless. Sovrascrivibile per i test: nessun test tocca la rete.
DERIBIT_PUBLIC_URL = os.environ.get(
"DERIBIT_PUBLIC_URL", "https://www.deribit.com/api/v2/public/test")
PROBE_TIMEOUT = 8.0
# --- slot di release Deribit --------------------------------------------------------------
# TARATURA DICHIARATA, e va etichettata con la sua CONFIGURAZIONE (P7), non solo con la finestra:
# 4 episodi osservati sul log di `cron_book` fra il 2026-06-23 e il 2026-08-25, tutti di martedi'
# fra le 09:00 e le 09:07 UTC, piu' la conferma documentale del supporto Deribit (release del
# 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes").
# ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica
# dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta
# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola —
# decide la sonda, e la finestra al massimo declassa.
RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
RELEASE_START_MIN = 9 * 60 # 09:00 UTC
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
@dataclass(frozen=True)
class Diagnosi:
"""Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9)."""
verdetto: str
perche: str
atteso: bool
prova: str
@property
def gravita(self) -> str:
"""Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9)."""
if self.atteso:
return ""
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
@property
def riparabile_da_noi(self) -> bool:
"""True solo quando il guasto e' nel pezzo che possediamo."""
return self.verdetto == V_GATEWAY
def riga(self) -> str:
"""Una riga per il log: verdetto + perche'. Mai solo il verdetto."""
atteso = " [ATTESO: slot di release]" if self.atteso else ""
return f"{self.verdetto}{atteso}{self.perche}"
def in_release_window(now: datetime | None = None) -> bool:
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo.
"""
now = now or datetime.now(timezone.utc)
now = now.astimezone(timezone.utc)
if now.weekday() != RELEASE_WEEKDAY:
return False
minuti = now.hour * 60 + now.minute
return RELEASE_START_MIN <= minuti < RELEASE_START_MIN + RELEASE_LEN_MIN
def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]:
"""Interroga l'API pubblica Deribit **senza gateway e senza credenziali**.
Ritorna `(raggiungibile, grezzo)`:
* `True` — il venue risponde e serve (`"result"` nel corpo);
* `False` — il venue risponde ma rifiuta (manutenzione 11051, 5xx, rate-limit...);
* `None` — la sonda non e' arrivata (rete locale, DNS): **non vedo**, non "va bene".
Il grezzo si porta dietro apposta: e' la PROVA che finisce nell'allerta, ed e' cio' che
distingue una misura da una presunzione. Stessa scelta di `venue_watch.platform_status()`.
"""
try:
r = requests.get(DERIBIT_PUBLIC_URL, timeout=timeout)
grezzo = (r.text or "")[:400]
return (r.status_code == 200 and '"result"' in grezzo), f"HTTP {r.status_code} {grezzo}"
except Exception as e: # noqa: BLE001 — la ragione si REGISTRA, non si ingoia (P3)
return None, f"{type(e).__name__}: {e}"
def diagnose(errori_osservati: list[str] | None = None,
now: datetime | None = None,
sonda=None) -> Diagnosi:
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
`errori_osservati` sono i motivi raccolti dallo strato che ha fallito (mark_src, errori del
feed SKH, pos_error...). Vengono guardati PRIMA della sonda perche' sono contemporanei al
guasto, mentre la sonda parte dopo: il 18/08 il codice 11051 era gia' li' dentro, nello
stesso identico minuto, e non arrivava a chi decideva la gravita' dell'allarme.
"""
errori = [e for e in (errori_osservati or []) if e]
sonda = sonda or probe_public
if is_maintenance(errori):
raggiungibile, grezzo = None, "(sonda non interrogata: 11051 gia' negli errori osservati)"
verdetto = V_MANUTENZIONE
perche = "Deribit dichiara manutenzione (codice 11051) negli errori raccolti sul posto"
else:
raggiungibile, grezzo = sonda()
if raggiungibile is None:
verdetto = V_IGNOTO
perche = ("la sonda pubblica non e' partita: non distinguo venue e gateway "
"(rete locale?)")
elif is_maintenance([grezzo]):
verdetto = V_MANUTENZIONE
perche = "l'API pubblica Deribit dichiara manutenzione (codice 11051)"
elif raggiungibile:
verdetto = V_GATEWAY
perche = ("l'API pubblica Deribit risponde: il venue sta bene, il guasto e' nel "
"NOSTRO gateway (cerbero-mcp) — e' il pezzo riparabile")
else:
verdetto = V_VENUE_GIU
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
atteso = verdetto == V_MANUTENZIONE and in_release_window(now)
prova = "; ".join([*errori[:3], grezzo])[:500]
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
def errori_dal_report(r: dict) -> list[str]:
"""Raccoglie i motivi gia' presenti nel report del book — li DERIVA, non li reinventa (P1).
Sono le stringhe che lo strato book ha gia' misurato mentre falliva: la sorgente del mark per
asset, gli errori del feed SKH, la lettura della posizione, il fallback di equity.
"""
out: list[str] = []
for a in r.get("assets") or []:
if a.get("mark_src"):
out.append(f"{a.get('asset')}: {a['mark_src']}")
for a, e in sorted((r.get("skh_feed_errors") or {}).items()):
out.append(f"{a}: {e}")
for chiave in ("pos_error", "eq_fallback", "skh_error"):
if r.get(chiave):
out.append(f"{chiave}: {r[chiave]}")
return out