"""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