venue_watch: disciplina degli allarmi, specifiche contratto controllate, terza referenza
Il 18/08 sono usciti quattro 🚨 identici con 'PRIMO PASSO: prelievo di prova' per una manutenzione Deribit annunciata, e tutti e quattro DOPO che il book aveva gia' ripreso a eseguire. Il book si era comportato bene (due giri di astensione, 'non eseguo a cieco'); il difetto era negli allarmi. 1. Il blocco piattaforma era un if secco senza memoria, accanto a un rilevatore tarato con cura che allerta una volta per streak. Ora passa da lock_step(), pura e testata: manutenzione entro tolleranza = un solo avviso morbido, che sfora = un solo 🚨 ('ha SFORATO'), blocco inspiegato = 🚨 subito, rientro annunciato una volta. public/status illeggibile NON e' un rientro. 2. Il messaggio diceva 'locked=true' cablato mentre il parser accetta anche 'partial': dichiarava un valore che non aveva letto, e il runbook manda a controllare proprio quel campo. Ora stampa e salva il valore grezzo. 3. Deribit ha cambiato tick e size dei perpetual lineari USDC il 18/08 e la tabella _CONTRACT, cablata a mano, non se n'era accorta. Nessun ordine rifiutato solo perche' i cambi erano riduzioni: e' andata bene per la direzione, non perche' ce ne fossimo accorti. Tabella aggiornata ai valori verificati e aggiunto check_specs(), che gira nel venue_watch orario (fuori dal percorso ordini) e dichiara la direzione: 'granularita'' = conforme, 'rifiuto' = il venue rifiuta. La tabella dichiarata resta l'autorita' per costruire un ordine, il venue e' il controllore. 4. Aggiunta Kraken come terza referenza: Coinbase ha comprato Deribit, e una referenza che e' la casa madre non misura piu' se Deribit scolla dal mondo. THRESHOLD_BPS e PERSIST_HOURS invariati, ma il consenso ora e' su tre serie e quel numero non e' ri-misurato: dichiarato nel codice e nel diario. La direzione dell'errore e' 'allerta di meno', non 'grida al lupo'. Test 23 -> 35 su venue_watch, suite intera 618 verdi. Book, pesi e config invariati; dry-run del book verificato. Diario: docs/diary/2026-08-19-*. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+140
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user