diff --git a/docs/diary/2026-08-19-venue-watch-disciplina-allarmi.md b/docs/diary/2026-08-19-venue-watch-disciplina-allarmi.md new file mode 100644 index 0000000..3701a30 --- /dev/null +++ b/docs/diary/2026-08-19-venue-watch-disciplina-allarmi.md @@ -0,0 +1,154 @@ +# 2026-08-19 — Quattro 🚨 per una manutenzione annunciata, e cosa c'era dietro + +**Richiesta:** *"su Adp_VPS mi sono arrivati questi messaggi"* — quattro allarmi VENUE WATCH del +18/08, incollati senza domanda. La domanda vera era: e' successo qualcosa di grave? + +**No.** Ma il modo in cui l'ho scoperto ha rivelato tre difetti, e uno riguarda gli ordini. + +**Toccati:** `src/live/venue_watch.py`, `src/live/deribit.py` (tabella `_CONTRACT` + `check_specs`), +`scripts/live/venue_watch.py`. **Test:** `tests/test_venue_watch.py` 23 → **35**; suite intera +**618 verdi**. **Book, pesi, `config/live.json`, THRESHOLD_BPS, PERSIST_HOURS: INVARIATI.** + +--- + +## 0. Cosa era successo davvero + +Manutenzione Deribit **annunciata il 14/08 per il 18/08 alle 09:00 UTC**, downtime dichiarato +**15–30 minuti**. Il book si e' astenuto ai giri delle 09:07 e 10:07 UTC — `conto non leggibile +(offline) -> stop, non eseguo a cieco` — e alle 11:07 eseguiva di nuovo. **Il sistema ha fatto la +cosa giusta.** + +I 572 minuti di "feed SKH stantio" non erano una deriva nascosta: con la coda 5m non attaccabile +il book e' ripiegato sul feed certificato, che e' di suo vecchio di ~9,5 ore. Prima (08:07) e dopo +(11:07) diceva `fresco (0 min)`. + +Ma `venue_watch` ha visto la piattaforma bloccata fino alle **14:07 UTC**: la manutenzione ha +sforato l'annuncio **di ore, non di minuti**. E ha mandato quattro 🚨 identici, **tutti dopo che +il book aveva gia' ripreso**. + +## 1. Perche' quattro messaggi uguali: l'allarme non aveva memoria + +Il rilevatore di dislocazione e' tarato con cura — 100 bps, 4 ore a segno costante, «zero falsi +allarmi in 8 anni», controllo positivo su Bitfinex 2018-19 — e la macchina a stati `step()` +allerta **una volta per streak** e poi tace. + +Accanto, il blocco piattaforma era un `if` secco senza stato: + +```python +if obs["platform_locked"]: + report["alerts"].append("Deribit public/status: PIATTAFORMA BLOCCATA (locked=true)") +``` + +Nessuna persistenza, nessuna deduplica, nessuna nozione di manutenzione. Un rilevatore disciplinato +con accanto uno che grida al lupo ogni ora, **e con lo stesso titolo**: *«PRIMO PASSO: prelievo di +prova»*. Il costo non e' il fastidio: e' che un allarme massimo speso per un evento atteso e' +un allarme che non verra' letto il giorno che e' vero — l'unico giorno che conta. + +Ora il lock passa da `lock_step()`, pura e testata, con gli stessi livelli degli altri stati: + +| condizione | esito | +|---|---| +| bloccata + manutenzione dichiarata, entro `MAINT_GRACE_HOURS` (2) | ⚠️ **una volta**, «attesa, non allarme» | +| manutenzione che **sfora** la tolleranza | 🚨 **una volta**, «ha SFORATO 2h» | +| bloccata **senza** manutenzione dichiarata | 🚨 subito, una volta | +| rientrata | avviso di rientro, una volta | +| `public/status` **illeggibile** | **niente**: le ore restano dove sono | + +Rigiocando il 18/08: quattro messaggi identici diventano **⚠️ → 🚨 (ha sforato) → rientrato**. Tre +messaggi che dicono tre cose diverse. + +⚠️ L'ultima riga della tabella e' quella che mi premeva: dichiarare «e' rientrato» perche' non si +e' riusciti a *guardare* sarebbe la bugia peggiore possibile in questo file. `locked is None` non +azzera niente e non annuncia niente. + +⚠️ E la tolleranza non e' un'assoluzione: la manutenzione declassa l'allarme **solo dentro la +finestra**, e oltre lo **rialza**. Il 18/08 e' la ragione per cui la regola non poteva essere +«se e' manutenzione, stai zitto»: Deribit aveva annunciato mezz'ora ed e' rimasta bloccata per ore. +Una manutenzione che dura sei volte l'annuncio **e' di nuovo una notizia.** + +## 2. Il messaggio dichiarava un valore che non aveva letto + +Il parser accetta qualunque valore diverso da `false`/`none` — Deribit risponde anche `partial` — +e il testo era cablato: `(locked=true)`. Il runbook al passo 2 manda a controllare *proprio quel +campo*: mandarci qualcuno con in testa la stringa sbagliata e' peggio che non dirgliela. + +Adesso `platform_status()` restituisce il valore grezzo, il messaggio lo stampa e lo **stato lo +salva**. Del 18/08 non e' piu' ricostruibile quale fosse: nessuno lo registrava. + +## 3. Il difetto che poteva costare: le specifiche contratto + +Cercando altro, il confronto con gli annunci Deribit ha trovato una cosa che nessun allarme +sorvegliava. Il 18/08 dopo le 09:00 UTC Deribit ha aggiornato **tick, contract size e minimum +order size dei perpetual lineari USDC** (annuncio del 14/08). La tabella `_CONTRACT` di +`deribit.py`, che e' quella con cui si costruiscono gli ordini, era **cablata a mano** e non se +n'e' accorta: + +| | dichiarato | venue dal 18/08 | +|---|---|---| +| BTC tick | 0.5 | **0.1** | +| ETH tick | 0.05 | **0.01** | +| ETH min/step | 0.001 | **0.0001** | + +Nessun ordine e' stato rifiutato, e la ragione **non e' merito nostro**: i cambi erano *riduzioni*, +e un valore piu' grosso resta conforme. Il costo effettivo era di granularita' — su ETH +l'incremento minimo restava **$1.92** invece di **$0.19** su un conto da $597 (0,32% contro 0,03%). + +⚠️ Il giorno che Deribit **alza** un minimo, la stessa cecita' fa **rifiutare gli ordini**, e non +c'e' niente che lo dica prima. + +**Scelta di disegno: la tabella dichiarata resta l'autorita' per costruire un ordine; il venue +diventa il controllore, non la fonte.** Prendere i valori dall'API dentro il percorso di +esecuzione avrebbe reso l'ordine dipendente da come ha risposto una GET — cioe' non ricostruibile +dopo. Invece `check_specs()` gira **nel venue_watch orario** (sola lettura, fuori dal percorso +ordini) e confronta; la tabella si aggiorna a mano, di proposito, in git. + +Il confronto dichiara la **direzione**, che e' l'unica cosa che serve per decidere se correre: + +- `granularita'` — dichiarato piu' grosso: conforme, si perde precisione → ⚠️, si aggiorna con calma; +- `rifiuto` — dichiarato piu' fine: **il venue rifiuta** → 🚨, si corregge subito. + +⚠️ Uno strumento **non letto** non e' una divergenza e non viene contato come «combacia»: finisce +in `non_letti`. Silenzio e uguaglianza non sono la stessa cosa, e solo una delle due e' rassicurante. + +Tabella aggiornata ai valori veri e verificata: `check_specs()` risponde `divergenze: []`, +`non_letti: []`. Il floor `min_order_usd = $5` del book e' indipendente, quindi lo step piu' fine +**non** apre la porta a ordini da 19 centesimi. + +## 4. Il difetto piu' lento: la referenza non e' piu' indipendente + +`venue_watch` misura Deribit contro un consenso di referenze USD. Erano **due**: Coinbase e +Bitstamp. Ma le comunicazioni del 12–18/08 dicono «Deribit **by Coinbase**», mettono il **Coinbase +Index** come riferimento per una parte dei perpetual lineari, e instradano lo spot Deribit su +**Coinbase Exchange**. + +Il rilevatore esiste per rispondere a «Deribit sta scollando dal mondo?». Se una delle due +referenze e' **la casa madre**, uno shock di Coinbase muove Deribit e la referenza *insieme*, e lo +scarto in bps resta piccolo **proprio nell'ora in cui dovrebbe aprirsi**. Con due referenze, il +consenso indipendente si riduceva di fatto a Bitstamp. + +Aggiunta **Kraken** come terza. BTC ed ETH restano sul Deribit Index (hanno opzioni quotate), quindi +oggi la contaminazione e' di *proprieta'*, non ancora di *calcolo*: si interviene finche' e' teorica. + +⚠️ **Onesta' sulla taratura.** «Zero falsi allarmi in 8 anni» e' stato misurato con l'insieme di +referenze di allora. `THRESHOLD_BPS` e `PERSIST_HOURS` non sono stati toccati, ma il consenso ora +si calcola su tre serie invece di due, e **quel numero non e' stato ri-misurato**. Quello che si +puo' affermare leggendo il codice e' la *direzione*: con tre referenze il consenso e' la **mediana** +(robusta a un outlier) invece della media di due, e lo spread max-min si allarga → si va piu' +spesso in **BLIND**, che e' lo stato morbido. Cioe' il modo in cui questa modifica puo' sbagliare +e' **«allerta di meno»**, non «grida al lupo». Per il numero vero si rilancia +`scripts/research/r0726_venue_tripwire.py` con la terza serie. + +Misurato subito dopo: BTC +3.0 bps, ETH +2.5 bps, **3 referenze**, spread fra referenze 1.5 e 0.7 +bps. Le tre concordano strettamente: nessuna deriva verso BLIND nella pratica. + +## 5. Cosa NON e' stato fatto, e resta aperto + +- **Il Rulebook del 12/08 non lo sorveglia nessuno.** Introduce ADL e perdita socializzata, + *emergency powers in qualifying circumstances*, la disciplina dei conti dormienti, e la + precisazione che Deribit ha *«limited administrative authority, rather than direct control»* + sugli asset custoditi da terzi. Per un sistema il cui runbook dice «l'azione giusta e' + prelevare», questa e' materia di rischio-venue pura — e il watcher guarda solo i prezzi. +- **La taratura a tre referenze non e' ri-misurata** (vedi §4). +- **Il verso della finestra di manutenzione e' euristico**: `MAINT_GRACE_HOURS = 2` non legge gli + annunci, li **presume**. Un feed degli annunci Deribit renderebbe la tolleranza esatta invece + che ragionevole. diff --git a/scripts/live/venue_watch.py b/scripts/live/venue_watch.py index d25132a..f01fe11 100644 --- a/scripts/live/venue_watch.py +++ b/scripts/live/venue_watch.py @@ -15,6 +15,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) +from src.live.deribit import check_specs # noqa: E402 from src.live.notifier import notify # noqa: E402 from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402 run_once) @@ -30,7 +31,10 @@ def main() -> int: print("=" * 78) print(f" soglia: {THRESHOLD_BPS:.0f} bps persistenti {PERSIST_HOURS}h a segno costante") lock = rep["platform_locked"] - print(f" public/status locked: {lock if lock is not None else 'non leggibile'}") + print(f" public/status locked: {rep.get('locked_raw') or 'non leggibile'}" + + (f" (da {rep['lock_hours']}h" + + (", manutenzione dichiarata" if rep.get("maintenance") else "") + ")" + if lock else "")) for a, lvl in rep["levels"].items(): d = rep["detail"][a] bps = f"{d['bps']:+7.1f} bps" if d["bps"] is not None else " n/d " @@ -38,14 +42,45 @@ def main() -> int: f"spread {d['ref_spread_bps']:.1f} bps)" if d["ref_spread_bps"] is not None else f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze)") + # --- specifiche contratto: la tabella dichiarata si e' scostata dal venue? + # ⚠️ Gira qui e non nel book: e' un controllo di SOLA LETTURA e non deve stare sul percorso + # che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e + # niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni. + spec = check_specs() + if spec["divergenze"]: + rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"] + righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / " + f"venue {d['venue']} -> {d['rischio']}" + for d in spec["divergenze"]} + if rifiuto: + righe["azione"] = ("il dichiarato e' PIU' FINE del venue: gli ordini verranno " + "RIFIUTATI. Correggi _CONTRACT in src/live/deribit.py adesso.") + notify("🚨 SPEC CONTRATTO — Deribit rifiutera' gli ordini", righe) + else: + righe["azione"] = ("il dichiarato e' piu' GROSSO del venue: gli ordini restano " + "conformi, si perde solo granularita'. Aggiorna _CONTRACT " + "in src/live/deribit.py.") + notify("⚠️ SPEC CONTRATTO — la tabella dichiarata e' vecchia", righe) + for k, v in righe.items(): + print(f" SPEC: {k}: {v}") + if rep["alerts"]: # ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve # sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py). - notify("🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova", - {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])}) + # ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di + # prova, anche per un blocco da manutenzione annunciata: il 18/08 sono usciti quattro + # messaggi identici che chiedevano di prelevare, tutti dopo che il book aveva gia' + # ripreso. Un allarme massimo speso per un evento atteso e' un allarme che non verra' + # letto il giorno che e' vero — ed e' l'unico giorno che conta. + if rep.get("severity") == "warn": + titolo = "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione" + else: + titolo = ("🚨 VENUE WATCH — possibile stress su Deribit. " + "PRIMO PASSO: prelievo di prova") + notify(titolo, {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])}) for a in rep["alerts"]: print(f" ALERT: {a}") - return 2 + return 2 if rep.get("severity") == "alert" else 0 return 0 diff --git a/src/live/deribit.py b/src/live/deribit.py index 0af29a8..e4f751d 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -11,6 +11,7 @@ in USD notional, step verificato su Deribit: BTC $10, ETH $1). """ from __future__ import annotations +import json import os from decimal import Decimal from pathlib import Path @@ -24,12 +25,87 @@ TIMEOUT = 15 # Inverse perp: amount = USD notional, step in USD, settle = base-coin (BTC/ETH). # Linear USDC perp: amount = base-coin (BTC/ETH), step in base-coin, settle = USDC (margine USDC). # NB il conto reale e' USDC -> gli strumenti ESEGUIBILI sono i LINEARI _USDC-PERPETUAL. +# ⚠️ TABELLA DICHIARATA, ed e' lei l'autorita' per costruire un ordine — non l'API. +# L'esecuzione dev'essere deterministica, in git e leggibile offline: un ordine che cambia forma +# perche' una GET ha risposto diverso e' un ordine che non si puo' ricostruire dopo. Il venue fa +# il CONTROLLORE, non la fonte: `check_specs()` gira ogni ora e allerta quando questa tabella si +# scosta dal vero, e allora la si aggiorna qui a mano, di proposito. +# +# ⚠️ VERIFICATI contro public/get_instrument il 2026-08-19. Deribit ha cambiato le specifiche dei +# perpetual lineari USDC il 18/08 alle 09:00 UTC (annuncio del 14/08) e questa tabella non se n'era +# accorta: tick BTC 0.5->0.1, tick ETH 0.05->0.01, min/step ETH 0.001->0.0001. Nessun ordine e' +# stato rifiutato perche' i cambi erano RIDUZIONI e un valore piu' grosso resta conforme — cioe' +# e' andata bene per la direzione del cambiamento, non perche' ce ne fossimo accorti. Il costo +# misurato era di granularita': su ETH l'incremento minimo restava $1.92 invece di $0.19 su un +# conto da $597 (0.32% contro 0.03%). Il giorno che Deribit ALZA un minimo la stessa cecita' fa +# rifiutare gli ordini: per quello adesso c'e' check_specs(). _CONTRACT = { "BTC-PERPETUAL": {"min": 10.0, "step": 10.0, "tick": 0.5, "settle": "BTC"}, "ETH-PERPETUAL": {"min": 1.0, "step": 1.0, "tick": 0.05, "settle": "ETH"}, - "BTC_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC", "linear": True}, - "ETH_USDC-PERPETUAL": {"min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC", "linear": True}, + "BTC_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.1, "settle": "USDC", "linear": True}, + "ETH_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.01, "settle": "USDC", "linear": True}, } + +# nome nostro -> campo di public/get_instrument +_SPEC_FIELDS = (("tick", "tick_size"), ("min", "min_trade_amount"), ("step", "contract_size")) + + +def compare_specs(declared: dict, live: dict) -> list[dict]: + """PURA, niente rete. Divergenze fra la tabella dichiarata e le specifiche del venue. + + Ogni voce porta la DIREZIONE, che e' l'informazione che serve per decidere se correre: + * `rischio: "granularita'"` — il dichiarato e' piu' GROSSO del vero. L'ordine resta conforme + (un multiplo del tick e' un tick valido), si perde solo precisione. Si aggiorna con calma. + * `rischio: "rifiuto"` — il dichiarato e' piu' FINE del vero. Il venue RIFIUTA l'ordine. + Va corretto prima del prossimo giro. + Uno strumento assente dal `live` NON e' una divergenza: puo' essere la rete. Il silenzio non + va confuso con l'uguaglianza, e infatti `check_specs()` dichiara a parte cosa non ha letto. + """ + fuori = [] + for strumento, atteso in declared.items(): + vero = live.get(strumento) + if not vero: + continue + for nostro, loro in _SPEC_FIELDS: + a, v = atteso.get(nostro), vero.get(loro) + if a is None or v is None: + continue + a, v = float(a), float(v) + if abs(a - v) <= 1e-12 * max(1.0, abs(v)): + continue + fuori.append({"strumento": strumento, "campo": nostro, "dichiarato": a, "venue": v, + "rischio": "granularita'" if a > v else "rifiuto"}) + return fuori + + +def fetch_live_specs(instruments: list[str] | None = None) -> tuple[dict, list[str]]: + """public/get_instrument per ogni strumento. RETE. Ritorna (specifiche, non_letti). + + ⚠️ Non solleva mai: un controllo che non parte non deve poter fermare il cron del book. Cio' + che non ha letto lo DICHIARA invece di ometterlo, perche' "non l'ho guardato" e "e' uguale" + sono due cose diverse e solo una delle due e' rassicurante. + """ + import urllib.request + fuori, non_letti = {}, [] + for strumento in (instruments or list(_CONTRACT)): + try: + url = ("https://www.deribit.com/api/v2/public/get_instrument" + f"?instrument_name={strumento}") + with urllib.request.urlopen(url, timeout=15) as r: + res = json.loads(r.read()).get("result") or {} + if res.get("tick_size") is None: + non_letti.append(strumento) + else: + fuori[strumento] = res + except Exception as e: # noqa: BLE001 + non_letti.append(f"{strumento} ({type(e).__name__})") + return fuori, non_letti + + +def check_specs() -> dict: + """Confronta la tabella dichiarata col venue. Sola lettura, mai solleva.""" + live, non_letti = fetch_live_specs() + return {"divergenze": compare_specs(_CONTRACT, live), "non_letti": non_letti} # Il conto reale e' USDC -> mappiamo gli asset sui perp LINEARI USDC (gli unici eseguibili qui). INSTRUMENT = {"BTC": "BTC_USDC-PERPETUAL", "ETH": "ETH_USDC-PERPETUAL"} DISASTER_LABEL = "tp01-disaster" # label dei bracket disaster-SL (per ritrovarli/gestirli/mostrarli) diff --git a/src/live/venue_watch.py b/src/live/venue_watch.py index 9cca94f..a23e8f6 100644 --- a/src/live/venue_watch.py +++ b/src/live/venue_watch.py @@ -60,12 +60,36 @@ THRESHOLD_BPS = 100.0 # scarto minimo per contare come anomalia PERSIST_HOURS = 4 # ore consecutive a segno costante prima di allertare REF_DISAGREE_BPS = 100.0 # oltre questo le referenze litigano fra loro -> BLIND BLIND_ALERT_HOURS = 12 # "non vedo" per tanto tempo e' anch'esso una notizia +# Una manutenzione ANNUNCIATA e' attesa: vale un avviso morbido, non il runbook del prelievo. +# Ma il 18/08 Deribit aveva annunciato 15-30 minuti e la piattaforma e' rimasta bloccata per ore: +# quindi la manutenzione declassa l'allarme SOLO dentro una finestra di tolleranza, e oltre quella +# RIALZA — perche' una manutenzione che dura sei volte l'annuncio e' di nuovo una notizia. +# 2 osservazioni orarie: sopra ogni finestra annunciata plausibile, sotto l'evento vero di ieri. +MAINT_GRACE_HOURS = 2 ASSETS = ("BTC", "ETH") # Referenze USD indipendenti da Deribit. NIENTE USDT: il depeg 2022 sposta BTC/USDT fino al 3% # dal dollaro e produrrebbe falsi allarmi giganti (regola del progetto sul DATO). +# ⚠️⚠️ KRAKEN AGGIUNTA IL 2026-08-19, e la ragione non e' la robustezza generica: **Coinbase ha +# comprato Deribit**. Le comunicazioni del 12-18/08 parlano di "Deribit by Coinbase", del Coinbase +# Index come riferimento per una parte dei perpetual lineari, e dello spot Deribit instradato su +# Coinbase Exchange. Questo rilevatore misura "Deribit sta scollando dal mondo?" contro un consenso +# indipendente: se una delle due referenze e' la CASA MADRE, uno shock di Coinbase muove Deribit e +# la referenza INSIEME, e lo scarto in bps resta piccolo proprio nell'ora in cui dovrebbe aprirsi. +# Con due sole referenze, il consenso indipendente si riduceva di fatto a Bitstamp. +# NB: BTC ed ETH restano sul Deribit Index (hanno opzioni quotate), quindi la contaminazione oggi +# e' di proprieta', non ancora di calcolo. Si aggiunge finche' la cosa e' teorica. +# ⚠️ ONESTA' SULLA TARATURA: "zero falsi allarmi in 8 anni" e' stato misurato con l'insieme di +# referenze di allora. THRESHOLD_BPS e PERSIST_HOURS non sono stati toccati, ma il CONSENSO si +# calcola su tre serie invece che due, e quel numero non e' stato ri-misurato. La direzione pero' +# e' verificabile a mente sul codice: con 3 referenze il consenso e' la MEDIANA (robusta a un +# outlier) invece della media di due, e lo spread max-min si allarga -> si va piu' spesso in BLIND, +# che e' lo stato MORBIDO. Cioe' il modo in cui questa modifica puo' sbagliare e' "allerta di +# meno", non "grida al lupo". Se un giorno serve il numero vero, si rilancia +# scripts/research/r0726_venue_tripwire.py con la terza serie. REF_VENUES = [("coinbase", {"BTC": "BTC/USD", "ETH": "ETH/USD"}), - ("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"})] + ("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"}), + ("kraken", {"BTC": "BTC/USD", "ETH": "ETH/USD"})] DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"} @@ -82,15 +106,59 @@ class AssetState: alerted: bool = False # gia' allertato per QUESTO streak (evita spam orario) +@dataclass +class LockState: + """Stato del blocco piattaforma. Prima non esisteva: l'allarme era un `if` secco senza + memoria, quindi ripartiva a ogni giro dell'ora. Il 18/08 sono usciti quattro 🚨 identici + con scritto 'PRIMO PASSO: prelievo di prova' per una manutenzione annunciata — e tutti e + quattro DOPO che il book aveva gia' ripreso a eseguire.""" + hours: int = 0 + alerted_soft: bool = False + alerted_hard: bool = False + + @dataclass class WatchState: assets: dict[str, AssetState] = field(default_factory=dict) + lock: LockState = field(default_factory=LockState) last_ts: int = 0 def get(self, a: str) -> AssetState: return self.assets.setdefault(a, AssetState()) +def lock_step(st: LockState, locked: bool | None, maintenance: bool, + grace: int = MAINT_GRACE_HOURS) -> tuple[LockState, str]: + """Avanza lo stato del lock di UNA osservazione oraria. PURA. + + Livelli: "OK" | "MAINT" (avviso morbido) | "ALERT" (runbook prelievo) | "RIENTRATO" | "MUTO". + "MUTO" = la condizione dura ma e' gia' stata detta: si tace invece di ripetersi ogni ora. + + ⚠️ `locked is None` (public/status illeggibile) NON e' un rientro: le ore restano dove sono e + non si annuncia niente. Dichiarare "e' rientrato" perche' non si e' riusciti a guardare + sarebbe la bugia peggiore di tutte in questo file. + """ + new = LockState(**asdict(st)) + if locked is None: + return new, "MUTO" if st.hours else "OK" + if not locked: + if st.hours: + return LockState(), "RIENTRATO" + return LockState(), "OK" + new.hours = st.hours + 1 + if maintenance and new.hours <= grace: + if st.alerted_soft: + return new, "MUTO" + new.alerted_soft = True + return new, "MAINT" + # niente manutenzione dichiarata -> un blocco inspiegato e' subito il caso serio; + # manutenzione oltre la tolleranza -> ha sforato, e lo sforamento e' esso stesso la notizia. + if st.alerted_hard: + return new, "MUTO" + new.alerted_soft, new.alerted_hard = True, True + return new, "ALERT" + + def dislocation_bps(deribit: float, refs: list[float]) -> tuple[float | None, int, float | None]: """(scarto firmato in bps, n referenze, spread fra referenze in bps). @@ -151,6 +219,7 @@ def load_state(path: Path = STATE_PATH) -> WatchState: try: raw = json.loads(path.read_text()) return WatchState(assets={k: AssetState(**v) for k, v in raw.get("assets", {}).items()}, + lock=LockState(**raw.get("lock", {})), last_ts=int(raw.get("last_ts", 0))) except Exception: return WatchState() # stato illeggibile -> si riparte pulito, mai un crash del cron @@ -159,40 +228,74 @@ def load_state(path: Path = STATE_PATH) -> WatchState: def save_state(st: WatchState, path: Path = STATE_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps( - {"assets": {k: asdict(v) for k, v in st.assets.items()}, "last_ts": st.last_ts}, indent=2)) + {"assets": {k: asdict(v) for k, v in st.assets.items()}, + "lock": asdict(st.lock), "last_ts": st.last_ts}, indent=2)) -def platform_locked() -> bool | None: - """`public/status` di Deribit: True se la piattaforma e' bloccata (halt). None se non leggibile. - Segnale DIRETTO e gratuito, indipendente dal prezzo.""" +def platform_status() -> tuple[bool | None, str]: + """`public/status` di Deribit: (bloccata?, valore GREZZO). None se non leggibile. + + ⚠️ Il valore grezzo si porta dietro apposta: Deribit risponde anche `"partial"`, e fino al + 2026-08-19 il messaggio diceva comunque «locked=true» — cioe' DICHIARAVA un valore che non + aveva letto. Il runbook al passo 2 manda a controllare proprio questo campo: mandarci qualcuno + con in testa la stringa sbagliata e' peggio che non dirgliela. E lo stato non lo salvava + nessuno, per cui il valore del 18/08 oggi non e' piu' ricostruibile. + """ try: import ccxt r = ccxt.deribit({"enableRateLimit": True}).publicGetStatus() - return str(r.get("result", {}).get("locked", "false")).lower() not in ("false", "none") + grezzo = str(r.get("result", {}).get("locked", "false")).lower() + return grezzo not in ("false", "none"), grezzo + except Exception: + return None, "non leggibile" + + +def platform_locked() -> bool | None: + """Compatibilita': solo il booleano. Il valore vero sta in `platform_status()`.""" + try: + return platform_status()[0] except Exception: return None -def _last_price(exchange_id: str, symbol: str) -> float | None: +def _last_price(exchange_id: str, symbol: str, errori: list[str] | None = None) -> float | None: + """⚠️ Raccoglie il MOTIVO del fallimento invece di inghiottirlo. E' cosi' che si distingue + «Deribit e' in manutenzione annunciata» da «Deribit non risponde e non si sa perche'»: ccxt + solleva `OnMaintenance` col codice 11051, e quell'informazione il 18/08 c'era gia' — ce + l'aveva lo strato book, nello stesso identico minuto, e non arrivava a chi decide la + gravita' dell'allarme.""" try: import ccxt ex = getattr(ccxt, exchange_id)({"enableRateLimit": True}) t = ex.fetch_ticker(symbol) p = t.get("last") or t.get("close") return float(p) if p else None - except Exception: + except Exception as e: # noqa: BLE001 + if errori is not None: + errori.append(f"{type(e).__name__}: {e}") return None +def is_maintenance(errori: list[str]) -> bool: + """PURA. La firma della manutenzione Deribit negli errori raccolti.""" + return any("onmaintenance" in e.lower() or "system_maintenance" in e.lower() or "11051" in e + for e in errori) + + def observe() -> dict: """Una osservazione: prezzo Deribit e referenze, per asset. Solo letture pubbliche.""" - out: dict = {"ts": int(time.time()), "platform_locked": platform_locked(), "assets": {}} + locked, grezzo = platform_status() + err_deribit: list[str] = [] + out: dict = {"ts": int(time.time()), "platform_locked": locked, "locked_raw": grezzo, + "assets": {}} for a in ASSETS: - der = _last_price("deribit", DERIBIT_SYMBOL[a]) + der = _last_price("deribit", DERIBIT_SYMBOL[a], err_deribit) refs = [_last_price(eid, syms[a]) for eid, syms in REF_VENUES] refs = [r for r in refs if r] bps, n, spread = (None, 0, None) if der is None else dislocation_bps(der, refs) out["assets"][a] = dict(deribit=der, n_refs=n, ref_spread_bps=spread, bps=bps) + out["maintenance"] = is_maintenance(err_deribit) + out["deribit_errors"] = err_deribit return out @@ -201,8 +304,9 @@ def run_once(state_path: Path = STATE_PATH) -> dict: L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita.""" st = load_state(state_path) obs = observe() - report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"], "levels": {}, - "detail": obs["assets"], "alerts": []} + report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"], + "locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False), + "levels": {}, "detail": obs["assets"], "alerts": [], "severity": None} for a in ASSETS: o = obs["assets"][a] new, level = step(st.get(a), o["bps"]) @@ -216,8 +320,30 @@ def run_once(state_path: Path = STATE_PATH) -> dict: report["alerts"].append( f"{a}: consenso NON misurabile da {new.blind_hours}h " f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})") - if obs["platform_locked"]: - report["alerts"].append("Deribit public/status: PIATTAFORMA BLOCCATA (locked=true)") + if report["levels"] and any(l == "ALERT" for l in report["levels"].values()): + report["severity"] = "alert" + + # --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati + lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False)) + st.lock = lock_new + report["lock_level"], report["lock_hours"] = lock_lvl, lock_new.hours + grezzo = obs.get("locked_raw") or "?" + if lock_lvl == "MAINT": + report["alerts"].append( + f"Deribit bloccata (locked={grezzo}) e dichiara MANUTENZIONE: attesa, non allarme. " + f"Il book si astiene da solo. Se supera {MAINT_GRACE_HOURS}h te lo ridico piu' forte.") + report["severity"] = report["severity"] or "warn" + elif lock_lvl == "ALERT": + motivo = (f"la manutenzione ha SFORATO {MAINT_GRACE_HOURS}h" if obs.get("maintenance") + else "nessuna manutenzione dichiarata") + report["alerts"].append( + f"Deribit public/status: PIATTAFORMA BLOCCATA (locked={grezzo}) da {lock_new.hours}h " + f"— {motivo}") + report["severity"] = "alert" + elif lock_lvl == "RIENTRATO": + report["alerts"].append(f"Deribit public/status: rientrata (locked={grezzo})") + report["severity"] = report["severity"] or "warn" + st.last_ts = obs["ts"] save_state(st, state_path) return report diff --git a/tests/test_venue_watch.py b/tests/test_venue_watch.py index ecca17a..58b6c8f 100644 --- a/tests/test_venue_watch.py +++ b/tests/test_venue_watch.py @@ -243,3 +243,114 @@ def test_il_watch_e_cablato_nel_cron_orario(): assert sh.index("venue_watch.py") < sh.index("book_execute.py"), ( "il watch deve girare PRIMA dell'esecuzione: se Deribit e' in stress l'allarme deve " "partire anche quando book_execute fallisce per la stessa ragione") + + +# =========================================================================== +# Disciplina del blocco piattaforma (2026-08-19). Il 18/08 sono usciti QUATTRO +# 🚨 identici con "PRIMO PASSO: prelievo di prova" per una manutenzione +# annunciata, e tutti e quattro DOPO che il book aveva gia' ripreso a eseguire. +# =========================================================================== +from src.live.venue_watch import LockState, is_maintenance, lock_step # noqa: E402 + + +def _replay(sequenza, grace=2): + st, out = LockState(), [] + for locked, maint in sequenza: + st, lvl = lock_step(st, locked, maint, grace=grace) + out.append(lvl) + return out + + +def test_manutenzione_breve_e_un_avviso_morbido_non_il_runbook_del_prelievo(): + assert _replay([(True, True), (False, False)]) == ["MAINT", "RIENTRATO"] + + +def test_non_si_ripete_ogni_ora_mentre_la_condizione_dura(): + """Il difetto vero del 18/08: nessuna memoria, quindi un messaggio identico ogni giro.""" + livelli = _replay([(True, True)] * 5 + [(False, False)]) + assert livelli.count("MAINT") == 1 + assert livelli.count("ALERT") == 1 + assert livelli == ["MAINT", "MUTO", "ALERT", "MUTO", "MUTO", "RIENTRATO"] + + +def test_la_manutenzione_che_sfora_RIALZA_invece_di_restare_morbida(): + """Deribit aveva annunciato 15-30 minuti e la piattaforma e' rimasta bloccata per ore: + una manutenzione che dura sei volte l'annuncio torna a essere una notizia.""" + assert _replay([(True, True)] * 3)[-1] == "ALERT" + + +def test_un_blocco_senza_manutenzione_dichiarata_e_subito_il_caso_serio(): + assert _replay([(True, False)])[0] == "ALERT" + + +def test_status_illeggibile_non_e_un_rientro(): + """Dichiarare 'e' rientrato' perche' non si e' riusciti a guardare sarebbe la bugia peggiore.""" + st = LockState() + st, _ = lock_step(st, True, False) + prima = st.hours + st, lvl = lock_step(st, None, False) + assert lvl == "MUTO" and st.hours == prima + + +def test_dopo_un_rientro_un_nuovo_blocco_riallerta(): + """L'ammutolimento vale per QUESTO episodio, non per sempre.""" + livelli = _replay([(True, False), (False, False), (True, False)]) + assert livelli == ["ALERT", "RIENTRATO", "ALERT"] + + +def test_riconosce_la_firma_della_manutenzione_deribit(): + assert is_maintenance(['OnMaintenance: deribit {"error":{"message":"system_maintenance",' + '"code":11051}}']) + assert not is_maintenance(["HTTPError: 502 Bad Gateway"]) + assert not is_maintenance([]) + + +def test_lo_stato_del_lock_sopravvive_al_giro_successivo(tmp_path): + """Senza persistenza la memoria si perde a ogni cron e si ricomincia a gridare.""" + from src.live.venue_watch import WatchState, load_state, save_state + p = tmp_path / "s.json" + st = WatchState() + st.lock, _ = lock_step(st.lock, True, True) + save_state(st, p) + assert load_state(p).lock.hours == 1 and load_state(p).lock.alerted_soft + + +# =========================================================================== +# Specifiche contratto vs venue (2026-08-19) +# =========================================================================== +from src.live.deribit import compare_specs # noqa: E402 + +_VERO = {"ETH_USDC-PERPETUAL": {"tick_size": 0.01, "min_trade_amount": 0.0001, + "contract_size": 0.0001}} + + +def test_dichiarato_piu_grosso_e_solo_granularita(): + """La situazione del 18/08: conforme, si perde precisione. Non e' un'emergenza.""" + d = compare_specs({"ETH_USDC-PERPETUAL": {"tick": 0.05, "min": 0.001, "step": 0.001}}, _VERO) + assert d and all(x["rischio"] == "granularita'" for x in d) + + +def test_dichiarato_piu_fine_significa_ordini_RIFIUTATI(): + """L'altra direzione, quella che costa: il venue alza un minimo e noi non lo sappiamo.""" + d = compare_specs({"ETH_USDC-PERPETUAL": {"tick": 0.001, "min": 1e-5, "step": 1e-5}}, _VERO) + assert d and all(x["rischio"] == "rifiuto" for x in d) + + +def test_uno_strumento_non_letto_non_e_una_divergenza(): + """Silenzio e uguaglianza non sono la stessa cosa: cio' che non si e' letto va dichiarato + a parte (`non_letti`), non fatto passare per 'combacia'.""" + assert compare_specs({"X": {"tick": 1.0}}, {}) == [] + + +def test_la_tabella_dichiarata_combacia_col_venue_oggi(): + """Verificato a mano il 19/08. Se questo rompe, o Deribit ha cambiato le specifiche o + qualcuno ha toccato _CONTRACT: in entrambi i casi va guardato, non silenziato.""" + from src.live.deribit import _CONTRACT + assert compare_specs(_CONTRACT, { + "BTC_USDC-PERPETUAL": {"tick_size": 0.1, "min_trade_amount": 0.0001, + "contract_size": 0.0001}, + "ETH_USDC-PERPETUAL": {"tick_size": 0.01, "min_trade_amount": 0.0001, + "contract_size": 0.0001}, + "BTC-PERPETUAL": {"tick_size": 0.5, "min_trade_amount": 10.0, "contract_size": 10.0}, + "ETH-PERPETUAL": {"tick_size": 0.05, "min_trade_amount": 1.0, "contract_size": 1.0}, + }) == []