feat: VENUE WATCH — tripwire di fallimento exchange, cablato live
Risposta a "trova un sistema di protezione da fallimento exchange" SOTTO IL VINCOLO della decisione appena presa (100% Deribit fino a $20k). Se non si puo' ridurre l'ESPOSIZIONE, l'unica leva e' il TEMPO: il modello di rischio del mattino assumeva il salto a zero istantaneo, ma i fallimenti reali non lo sono (Mt.Gox mesi, FTX ~72h, e misurato qui: Bitfinex 2018-19 dislocato per 2.324 ore consecutive). SEGNALE: un venue che gata i prelievi rompe l'ARBITRAGGIO -> il prezzo si stacca dal consenso e ci resta. E' |scarto|, non il segno (Mt.Gox a premio, un venue in fuga a sconto: stessa cosa). Consenso = venue USD indipendenti (Coinbase, Bitstamp), mai USDT. Deribit sta a 3 bps dal consenso in mediana su 8 anni (65.043 ore BTC + 64.541 ETH). TARATURA CONGELATA: 100 bps persistenti 4h a segno costante. Criterio DICHIARATO PRIMA, perche' i due ovvi sbagliano in versi opposti (provati entrambi): "minimi bps" -> 25/24h consuma 24 delle ~72h di FTX; "minime ore" -> 500/2h MANCA FTX (margine 0.6x). Regola: zero falsi allarmi in 8 anni + margine >=3x sul caso storico piu' debole -> soglia <=100bps -> poi minima latenza. Margine 3x FTX / 5x Quadriga / 10-20x Mt.Gox, zero falsi allarmi con crash COVID, maggio 2021, LUNA e novembre 2022 inclusi. CONTROLLO POSITIVO SUPERATO (un rilevatore tarato per non segnalare e' indistinguibile da uno rotto): puntato su Bitfinex 2018-19 scatta 22 volte, episodio piu' lungo 2.324h a +447bps. 22 dove il problema c'era, 0 su Deribit. E la durata risponde alla domanda vera: un venue gated resta dislocato per settimane, quindi 4h di latenza sono trascurabili. ECONOMIA: falso allarme = 0.248% atteso (flat 3g misurato sul book reale a ogni data d'inizio); vero positivo = 100% salvato. Break-even p > (falsi/anno) x 0.00248: a 1 ogni 8 anni serve p > 0.031%. Il valore sta nella SPECIFICITA', non nella sensibilita'. CABLATO: src/live/venue_watch.py (nucleo puro) + scripts/live/venue_watch.py, in cron_book.sh PRIMA di book_execute (se Deribit e' in stress l'allarme deve partire anche quando l'esecuzione fallisce per la stessa ragione). Tre stati OK/ALERT/BLIND — "non vedo" non e' "va bene". ALLERTA, NON BLOCCA: l'azione e' prelevare (manuale; una chiave con permesso di prelievo sarebbe essa stessa un rischio) e bloccare non protegge un saldo che e' a rischio anche stando flat. Runbook pre-deciso nel docstring. NON COPRE, e non e' un argomento per riaprire il 26/07: un fallimento SENZA finestra (furto chiavi, sequestro, exit-scam) non lo prende nessun tripwire. ERRORI CATTURATI IN SESSIONE: - break-even calcolato sul p5 invece che sulla media (8.3x piu' severo, conclusione ribaltata); - ipotesi meccanica sbagliata: credevo che i crash dislocassero a segno ALTERNATO. Falso, sono a segno costante anche loro (perp sotto spot per ore in cascata). A separare sono ampiezza e durata, non il segno; - la prima corsa tronco' il campione da 8 anni a 29 GIORNI per un inner-join con Kraken (che serve solo ~700 candele) e la copertura era gia' stampata a video: una diagnostica stampata NON e' un controllo. Ora c'e' una guardia che ferma lo script. 2a occorrenza in un giorno dopo GTAA01; - il controllo positivo era finito dentro il ramo `else` -> non girava mai, cioe' esattamente il difetto che doveva prevenire. Book, pesi, config INVARIATI. 359 test verdi (+23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""VENUE WATCH — allerta precoce sul fallimento dell'exchange (Deribit).
|
||||
|
||||
PERCHE' ESISTE. TP01+SKH01+VRP01 stanno tutti sullo stesso conto Deribit: quasi-ortogonali sui
|
||||
ritorni, **perfettamente correlati sul fallimento del venue** (`r0726_venue_risk.py`). L'operatore
|
||||
ha deciso il 26/07 di restare concentrato al 100% fino a $20k, quindi ridurre l'ESPOSIZIONE e'
|
||||
fuori discussione. Resta una sola leva: **il tempo**. I fallimenti reali non sono istantanei —
|
||||
Mt.Gox gato' i prelievi per mesi, FTX ebbe ~72h, Bitfinex nel 2018-19 resto' dislocato per
|
||||
**2.324 ore consecutive**. Se il saldo esce dentro quella finestra, la perdita non e' totale.
|
||||
|
||||
IL SEGNALE (misurato in `scripts/research/r0726_venue_tripwire.py`).
|
||||
Quando un exchange gata i prelievi, **l'arbitraggio si rompe** e il suo prezzo si stacca dal
|
||||
consenso in modo persistente: non si puo' piu' comprare qui e vendere la' per chiudere lo scarto.
|
||||
Il segnale e' |scarto|, NON il suo segno: su Mt.Gox il BTC andava a PREMIO (si comprava BTC per far
|
||||
uscire valore), su un venue in fuga si vede lo sconto. Entrambi dicono la stessa cosa.
|
||||
|
||||
TARATURA (dichiarata, non tarata a occhio):
|
||||
* zero falsi allarmi su Deribit in 8 anni (65.043 ore BTC + 64.541 ETH), inclusi il crash
|
||||
COVID 2020-03, il maggio 2021, LUNA e il novembre 2022;
|
||||
* margine >= 3x sul caso storico piu' DEBOLE (FTX ~300 bps) -> soglia <= 100 bps;
|
||||
* a quei due vincoli, minima latenza -> **100 bps persistenti 4 ore a segno costante**.
|
||||
Margine: 3x su FTX, 5x su QuadrigaCX, 10-20x su Mt.Gox.
|
||||
|
||||
CONTROLLO POSITIVO (un rilevatore che non segnala mai puo' essere semplicemente rotto): puntato su
|
||||
BITFINEX 2018-19 (problemi bancari/Tether) scatta **22 volte**, episodio piu' lungo 2.324h a +447
|
||||
bps di picco. Specificita', non cecita'.
|
||||
|
||||
TRE STATI, e il terzo non e' il primo:
|
||||
OK — misurato, sotto soglia;
|
||||
ALERT — misurato, sopra soglia e persistente -> Telegram;
|
||||
BLIND — NON misurato (referenze irraggiungibili o in disaccordo fra loro). **"Non vedo" non e'
|
||||
"va tutto bene"**: se dura, e' un allarme suo (piu' morbido). Stessa lezione della
|
||||
contabilita' a 3 stati di `paper_dvolspread` (25/07).
|
||||
|
||||
PERCHE' ALLERTA E NON BLOCCA. L'azione giusta a un vero positivo e' **prelevare**, che richiede
|
||||
comunque un intervento manuale: una chiave API con permesso di prelievo sarebbe essa stessa un
|
||||
rischio (se la chiave esce, escono i fondi). Bloccare l'esecuzione invece non protegge il saldo —
|
||||
il saldo e' a rischio anche stando flat. Quindi: allerta forte, azione umana.
|
||||
|
||||
RUNBOOK a un ALERT (pre-deciso ORA per non doverlo decidere nel momento sbagliato):
|
||||
1. verificare che non sia un guasto delle referenze (il campo `n_refs` e `ref_spread_bps`);
|
||||
2. controllare `public/status` (campo `platform_locked`) e i canali ufficiali Deribit;
|
||||
3. **tentare subito un prelievo di prova piccolo**: e' l'unica evidenza DIRETTA. Se non passa in
|
||||
tempi normali, l'allarme e' vero indipendentemente da ogni altra spiegazione;
|
||||
4. se il prelievo di prova non passa: flat del book e prelievo totale. Costo atteso di sbagliarsi
|
||||
= **0.248% dell'equity** (uscita 3 giorni, misurato in `r0726_venue_response.py`), contro il
|
||||
100% che si evita se e' vero.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
STATE_PATH = PROJECT_ROOT / "data" / "venue_watch" / "state.json"
|
||||
|
||||
# --- taratura CONGELATA (vedi docstring; cambiarla invalida "zero falsi allarmi in 8 anni") ---
|
||||
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
|
||||
|
||||
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).
|
||||
REF_VENUES = [("coinbase", {"BTC": "BTC/USD", "ETH": "ETH/USD"}),
|
||||
("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"})]
|
||||
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# nucleo PURO (nessuna rete, nessun file) — e' qui che vivono i test
|
||||
# ===========================================================================
|
||||
@dataclass
|
||||
class AssetState:
|
||||
"""Stato per-asset dello streak. `sign` 0 = nessuno streak in corso."""
|
||||
streak_hours: int = 0
|
||||
sign: int = 0
|
||||
blind_hours: int = 0
|
||||
last_bps: float | None = None
|
||||
alerted: bool = False # gia' allertato per QUESTO streak (evita spam orario)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchState:
|
||||
assets: dict[str, AssetState] = field(default_factory=dict)
|
||||
last_ts: int = 0
|
||||
|
||||
def get(self, a: str) -> AssetState:
|
||||
return self.assets.setdefault(a, AssetState())
|
||||
|
||||
|
||||
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).
|
||||
|
||||
Ritorna scarto None se non c'e' un consenso utilizzabile: servono >= 2 referenze concordi.
|
||||
Con UNA sola referenza non si distingue "Deribit e' fuori" da "la referenza e' rotta" — e' il
|
||||
controllo che rende il sistema usabile invece che rumoroso.
|
||||
"""
|
||||
vals = sorted(float(x) for x in refs if x and x > 0)
|
||||
if len(vals) < 2:
|
||||
return None, len(vals), None
|
||||
n = len(vals)
|
||||
consensus = vals[n // 2] if n % 2 else 0.5 * (vals[n // 2 - 1] + vals[n // 2])
|
||||
if consensus <= 0:
|
||||
return None, n, None
|
||||
spread = (vals[-1] - vals[0]) / consensus * 1e4
|
||||
if spread > REF_DISAGREE_BPS:
|
||||
return None, n, spread
|
||||
return (deribit - consensus) / consensus * 1e4, n, spread
|
||||
|
||||
|
||||
def step(st: AssetState, bps: float | None, threshold: float = THRESHOLD_BPS,
|
||||
persist: int = PERSIST_HOURS, blind_alert: int = BLIND_ALERT_HOURS) -> tuple[AssetState, str]:
|
||||
"""Avanza lo stato di UN asset di una osservazione oraria. PURA.
|
||||
|
||||
Ritorna (nuovo stato, livello) con livello in {"OK", "BLIND", "WATCH", "ALERT"}.
|
||||
- bps None -> BLIND; lo streak si AZZERA (non si accumula evidenza su dati che
|
||||
non parlano) e si conta da quanto non si vede;
|
||||
- |bps| > soglia -> lo streak cresce se il segno e' lo stesso, altrimenti riparte;
|
||||
- streak >= persist -> ALERT (una volta sola per streak, poi resta WATCH senza rispammare).
|
||||
"""
|
||||
new = AssetState(**asdict(st))
|
||||
if bps is None:
|
||||
new.blind_hours = st.blind_hours + 1
|
||||
new.streak_hours, new.sign, new.alerted = 0, 0, False
|
||||
return new, ("BLIND" if new.blind_hours >= blind_alert else "OK")
|
||||
new.blind_hours = 0
|
||||
new.last_bps = float(bps)
|
||||
sign = 1 if bps > 0 else (-1 if bps < 0 else 0)
|
||||
if abs(bps) > threshold and sign != 0:
|
||||
new.streak_hours = st.streak_hours + 1 if sign == st.sign else 1
|
||||
new.sign = sign
|
||||
if new.streak_hours < persist:
|
||||
new.alerted = False
|
||||
return new, "WATCH"
|
||||
already = st.alerted and st.sign == sign
|
||||
new.alerted = True
|
||||
return new, ("WATCH" if already else "ALERT")
|
||||
new.streak_hours, new.sign, new.alerted = 0, 0, False
|
||||
return new, "OK"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# I/O: stato su disco, rete, report
|
||||
# ===========================================================================
|
||||
def load_state(path: Path = STATE_PATH) -> WatchState:
|
||||
if not path.exists():
|
||||
return WatchState()
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
return WatchState(assets={k: AssetState(**v) for k, v in raw.get("assets", {}).items()},
|
||||
last_ts=int(raw.get("last_ts", 0)))
|
||||
except Exception:
|
||||
return WatchState() # stato illeggibile -> si riparte pulito, mai un crash del cron
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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."""
|
||||
try:
|
||||
import ccxt
|
||||
r = ccxt.deribit({"enableRateLimit": True}).publicGetStatus()
|
||||
return str(r.get("result", {}).get("locked", "false")).lower() not in ("false", "none")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _last_price(exchange_id: str, symbol: str) -> float | None:
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
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": {}}
|
||||
for a in ASSETS:
|
||||
der = _last_price("deribit", DERIBIT_SYMBOL[a])
|
||||
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)
|
||||
return out
|
||||
|
||||
|
||||
def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
"""Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla).
|
||||
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": []}
|
||||
for a in ASSETS:
|
||||
o = obs["assets"][a]
|
||||
new, level = step(st.get(a), o["bps"])
|
||||
st.assets[a] = new
|
||||
report["levels"][a] = level
|
||||
if level == "ALERT":
|
||||
report["alerts"].append(
|
||||
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
|
||||
f"persistente da {new.streak_hours}h a segno costante")
|
||||
elif level == "BLIND":
|
||||
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)")
|
||||
st.last_ts = obs["ts"]
|
||||
save_state(st, state_path)
|
||||
return report
|
||||
Reference in New Issue
Block a user