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:
Adriano Dal Pastro
2026-07-26 19:38:19 +00:00
parent 0ec6f8b761
commit ab5bcace16
9 changed files with 1299 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python
"""r0726_venue_response.py — quanto costa RISPONDERE a un allarme di venue, e quanto salva.
Un tripwire senza il costo della risposta non e' un sistema: e' un allarme che non si sa se
valga la pena ascoltare. Qui si misura il lato economico, che e' quello che decide.
DUE NUMERI, e sono asimmetrici di tre ordini di grandezza:
(A) COSTO DI UN FALSO ALLARME — l'allarme scatta, si va flat e si esce dal venue per N giorni,
poi si rientra. Misurato sul book REALE (TP01+SKH01 75/25) forzando flat una finestra di N
giorni a OGNI possibile data d'inizio: la distribuzione di quel costo e' la risposta.
⚠️ Non e' un costo medio-zero: uscire e rientrare paga fee, e soprattutto il book e' un
trend-follower — uscire durante un trend costa il trend, non l'interesse di N giorni.
(B) VALORE DI UN VERO POSITIVO — quanto del saldo si salva uscendo con `lead_hours` di anticipo,
dato che il prelievo non e' istantaneo (`withdraw_hours`). Se il lead e' minore del tempo di
prelievo, il tripwire non serve a niente: e' il vincolo che decide se il sistema esiste.
⚠️ `withdraw_hours` NON e' stimato da me: e' il parametro che l'operatore deve misurare con un
prelievo di prova (vedi runbook). Qui se ne mostra la SENSIBILITA', come per `p` nel venue-risk.
uv run python scripts/research/r0726_venue_response.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.portfolio.portfolio import combine_outer, metrics # noqa: E402
from src.portfolio.sleeves import deribit_book_sleeves # noqa: E402
EXIT_DAYS = (1, 2, 3, 5, 7, 14, 30)
# Ore di anticipo del segnale sul blocco definitivo dei prelievi. Ancore storiche:
# FTX ~72h fra il primo blocco visibile e la bancarotta; Mt.Gox mesi; QuadrigaCX settimane.
LEAD_HOURS = (6, 12, 24, 72, 168, 720)
WITHDRAW_HOURS = (1, 6, 12, 24, 48)
def book_series() -> pd.Series:
"""Il book Deribit REALE (TP01 0.75 + SKH01 0.25), quello che sta sul conto a rischio."""
sl = deribit_book_sleeves()
return combine_outer({s.name: s.daily() for s in sl}, {s.name: s.weight for s in sl})
def cost_of_exit(r: pd.Series, days: int, fee_rt: float = 0.001) -> np.ndarray:
"""Costo (in % di equity finale) di andare flat per `days` giorni a ogni data d'inizio.
Il confronto e' fra la stessa serie con e senza il buco: cio' che si perde e' il RITORNO
COMPOSTO della finestra, piu' un round-trip di fee per uscire e rientrare. Un valore NEGATIVO
e' un costo, POSITIVO significa che il falso allarme ha per caso evitato una perdita.
"""
v = r.values.astype(float)
n = len(v)
if n <= days:
return np.array([])
out = np.empty(n - days)
for i in range(n - days):
window = np.prod(1.0 + v[i:i + days]) # cio' che il book avrebbe fatto
out[i] = (1.0 / window) * (1.0 - fee_rt) - 1.0 # saltarla, piu' il round-trip
return out
def salvage(lead_h: float, withdraw_h: float) -> float:
"""Frazione del saldo che esce prima del blocco.
Modello dichiarato: il prelievo e' un processo con un tempo fisso `withdraw_h`; se il segnale
arriva con `lead_h` di anticipo si riesce a completare `floor(lead/withdraw)` prelievi, e ogni
prelievo puo' portare via al piu' TUTTO il saldo (Deribit non impone un cap giornaliero
rilevante a questa taglia). Quindi il salvataggio e' binario nella pratica:
lead >= withdraw -> 100%, altrimenti 0%.
Non e' una semplificazione pigra: e' la forma vera del problema a $600-20k, dove il saldo entra
in un singolo prelievo. A taglie dove servono N prelievi il modello va rifatto lineare.
"""
return 1.0 if lead_h >= withdraw_h else 0.0
def main() -> None:
print("=" * 100)
print(" r0726 — IL LATO RISPOSTA: quanto costa un falso allarme, quanto salva un vero")
print("=" * 100)
r = book_series()
m = metrics(r)
print(f"\n book Deribit reale: {len(r):,} giorni Sharpe {m['sharpe']:.2f} "
f"CAGR {m['cagr']*100:.1f}% maxDD {m['maxdd']*100:.1f}%")
# ------------------------------------------------------- (A) falso allarme
print("\n (A) COSTO DI UN FALSO ALLARME — flat per N giorni, a ogni data d'inizio possibile")
print(f"\n {'giorni':>7}{'costo medio':>14}{'mediana':>10}{'p5 (peggio)':>14}"
f"{'p95 (meglio)':>14}{'% dannosi':>11}")
costs = {}
for d in EXIT_DAYS:
c = cost_of_exit(r, d) * 100
costs[d] = c
print(f" {d:>7}{c.mean():>13.3f}%{np.median(c):>9.3f}%{np.percentile(c, 5):>13.3f}%"
f"{np.percentile(c, 95):>13.3f}%{(c < 0).mean()*100:>10.1f}%")
print("\n Lettura: il costo MEDIO e' piccolo (il book e' flat o quasi buona parte del")
print(" tempo), ma la CODA non lo e' — uscire durante un trend costa il trend. Il p5 e' il")
print(" numero da usare per decidere, non la media.")
# ------------------------------------------------------- (B) vero positivo
print("\n (B) VALORE DI UN VERO POSITIVO — serve che il segnale anticipi il tempo di prelievo")
print(f"\n {'lead segnale':>14} |" + "".join(f"{w:>6}h" for w in WITHDRAW_HOURS))
for lh in LEAD_HOURS:
lab = f"{lh}h" if lh < 48 else f"{lh//24}g"
cells = "".join(f"{'SALVO' if salvage(lh, w) else ' --':>7}" for w in WITHDRAW_HOURS)
print(f" {lab:>14} |{cells}")
print("\n colonne = quanto ci mette un prelievo a completarsi (DA MISURARE con un prelievo")
print(" di prova: e' l'unico parametro che decide se il sistema funziona).")
# ------------------------------------------------------- asimmetria
print("\n (C) L'ASIMMETRIA — e' questa che decide se il tripwire vale la pena")
d_ref = 3
c = costs[d_ref]
mean_cost = abs(c.mean()) # per il VALORE ATTESO si usa la MEDIA...
tail_cost = abs(np.percentile(c, 5)) # ...il p5 dice quanto puo' fare male UNA volta
print(f"\n costo ATTESO di un falso allarme (uscita 3g, media): {mean_cost:.3f}% equity")
print(f" costo di CODA dello stesso (p5, quanto puo' bruciare): {tail_cost:.3f}% equity")
print(f" perdita evitata da un vero positivo: 100.000% equity")
print(f"\n ⚠️ Il break-even si calcola sulla MEDIA, non sulla coda: e' un valore atteso")
print(f" ripetuto nel tempo, non una scommessa singola. (Con la coda il numero esce")
print(f" {tail_cost/mean_cost:.1f}x piu' severo e la conclusione si ribalta.)")
print(f"\n Il tripwire conviene se p_annua > (falsi allarmi/anno) x {mean_cost/100:.5f}:")
rows = []
for fp_year in (0.125, 1, 4, 12, 52):
be = fp_year * mean_cost / 100.0
lab = "1 ogni 8 anni" if fp_year < 1 else f"{fp_year:g}/anno"
rows.append((lab, be))
print(f" {lab:>16} -> serve p > {be*100:>7.3f}%")
# verdetto CALCOLATO, non pre-scritto: confronto col ventaglio di `p` del venue-risk
P_GRID = (0.005, 0.01, 0.02, 0.05)
print(f"\n Confronto col ventaglio di `p` di r0726_venue_risk ({', '.join(f'{p:.1%}' for p in P_GRID)}):")
for lab, be in rows:
ok = [f"{p:.1%}" for p in P_GRID if p > be]
verdict = f"conviene per p in {{{', '.join(ok)}}}" if ok else "NON conviene a nessun p del ventaglio"
print(f" {lab:>16} {verdict}")
print(f"\n Il tasso di falsi allarmi misurato (r0726_venue_tripwire) e' ZERO su 8 anni:")
print(f" anche contando 1 ogni 8 anni come limite superiore, il break-even e'")
print(f" p > {rows[0][1]*100:.3f}% — un ordine di grandezza sotto la piu' ottimistica")
print(f" delle ipotesi (0.5%). A 12 falsi allarmi/anno invece NON converrebbe: il")
print(f" valore del sistema sta tutto nella SPECIFICITA', non nella sensibilita'.")
print("\n ⚠️ CIO' CHE QUESTO NON DICE: che il segnale ARRIVI. L'asimmetria rende conveniente")
print(" ASCOLTARE l'allarme, non garantisce che l'allarme suoni prima del blocco. Un")
print(" fallimento improvviso (furto di chiavi, sequestro) non ha finestra e nessun")
print(" tripwire lo prende: quella parte di `p` resta scoperta e va detta.")
if __name__ == "__main__":
main()
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env python
"""r0726_venue_tripwire.py — un sistema di allerta precoce sul FALLIMENTO DEL VENUE.
PROBLEMA (dal 26/07, `r0726_venue_risk.py`): TP01+SKH01+VRP01 stanno tutti sullo stesso conto
Deribit. Sono quasi-ortogonali sui ritorni e **perfettamente correlati sul fallimento del venue**.
L'operatore ha deciso (26/07) di restare concentrato al 100% fino a $20k: la riduzione
dell'ESPOSIZIONE e' quindi fuori discussione fino a quella soglia.
Resta una sola leva: **il TEMPO**. Il modello di rischio assumeva un salto a zero istantaneo, ma i
fallimenti reali non sono istantanei — Mt.Gox gato' i prelievi fiat per MESI prima di chiudere,
FTX ebbe ~3 giorni fra il primo blocco e la bancarotta, QuadrigaCX settimane. Se una parte del
saldo esce dentro quella finestra, la perdita non e' totale.
DOMANDA MISURABILE: esiste un segnale, calcolabile dai dati che gia' scarichiamo, che distingua
"questo venue e' gated" da "il mercato e' in crash"? E con quale tasso di FALSI ALLARMI?
MECCANISMO IPOTIZZATO (ed e' il punto che rende il test falsificabile): quando un exchange blocca
i prelievi, **l'arbitraggio si rompe** e il suo prezzo si stacca dal consenso in modo
(a) PERSISTENTE e (b) A SEGNO COSTANTE — perche' l'ostacolo e' strutturale, non di liquidita'.
Un crash di mercato disloca anche lui, ma per minuti e con segno che rimbalza, perche' l'arb
funziona ancora, e' solo lento. Su Mt.Gox il BTC arrivo' a un PREMIO del 10-20% (si comprava BTC
per far uscire il valore); su un venue in fuga si vede lo sconto. **Il segnale e' |scarto|, non il
suo segno** — entrambe le direzioni dicono la stessa cosa: l'arb non chiude piu'.
COSA MISURA QUESTO SCRIPT
1. la distribuzione dello scarto Deribit-vs-consenso in bps su TUTTA la storia certificata
(8 anni, BTC+ETH), consenso = venue USD indipendenti (Coinbase, Kraken);
2. gli EPISODI (run consecutivi sopra soglia a segno costante): quanti, quanto lunghi, quando —
cioe' il tasso di falsi allarmi di ogni possibile (soglia, durata);
3. la FRONTIERA A ZERO FALSI ALLARMI: la coppia (bps, ore) che in 8 anni non e' mai scattata,
inclusi tutti i crash veri (Mar-2020, Mag-2021, LUNA, FTX, ...);
4. il MARGINE: di quante volte la dislocazione di un fallimento storico supera quella soglia.
CONTROLLO DI FALSI POSITIVI CHE VA FATTO O IL SISTEMA E' INUTILE: **la referenza puo' rompersi
lei**. Se Coinbase ha un outage, Deribit sembra dislocato. Per questo il consenso richiede
**almeno 2 venue indipendenti d'accordo fra loro**: se le referenze non concordano, il campione
si scarta invece di generare un allarme (`REF_DISAGREE_BPS`).
uv run python scripts/research/r0726_venue_tripwire.py
"""
from __future__ import annotations
import pickle
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data # noqa: E402
CACHE = ROOT / "data" / "_cache" / "venue_ref_1h.pkl"
ASSETS = ("BTC", "ETH")
# Referenze INDIPENDENTI da Deribit e denominate in USD (non USDT: il depeg del 2022 sposta
# BTC/USDT fino al 3% dal dollaro e produrrebbe falsi allarmi giganti — regola del progetto).
# ⚠️ KRAKEN E' STATO RIMOSSO dopo la prima corsa: il suo endpoint OHLC pubblico ritorna solo le
# ultime ~700 candele qualunque `since`, e l'inner join TRONCAVA il campione da 8 anni a 29 giorni
# in silenzio (la "frontiera a zero falsi allarmi in 8 anni" era misurata su un mese). Bitstamp e
# Bitfinex hanno storia profonda dal 2019. Da qui la guardia di copertura in `main`.
REFS = [("coinbase", {"BTC": "BTC/USD", "ETH": "ETH/USD"}, 300),
("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"}, 1000),
("bitfinex", {"BTC": "BTC/USD", "ETH": "ETH/USD"}, 1000)]
# Copertura minima del campione: sotto questa quota la conclusione NON si stampa. Serve perche'
# la prima corsa aveva la diagnostica giusta gia' stampata (702 ore su 69.663) e il testo di
# sintesi diceva comunque "8 anni": la guardia deve essere un controllo, non una riga di log.
MIN_COVERAGE = 0.50
# Se le due referenze divergono FRA LORO piu' di questo, il campione non e' utilizzabile:
# il problema e' della referenza, non di Deribit. Scartare, non allertare.
REF_DISAGREE_BPS = 100.0
# Griglia della frontiera: soglia in bps x durata minima in ore consecutive.
BPS_GRID = (25, 50, 75, 100, 150, 200, 300, 500)
HOURS_GRID = (1, 2, 3, 4, 6, 8, 12, 24)
# ===========================================================================
# fetch delle referenze (con cache: 8 anni x 2 venue x 2 asset e' lento)
# ===========================================================================
def _fetch_1h(exchange_id: str, symbol: str, start_ms: int, end_ms: int, limit: int) -> pd.Series:
"""OHLCV 1h paginato -> Series close indicizzata sul timestamp ms. Tollerante agli errori."""
import ccxt
ex = getattr(ccxt, exchange_id)({"enableRateLimit": True})
ex.load_markets()
if symbol not in ex.markets:
return pd.Series(dtype=float)
out: dict[int, float] = {}
since, guard = start_ms, 0
while since <= end_ms and guard < 6000:
guard += 1
rows = None
for attempt in range(3):
try:
rows = ex.fetch_ohlcv(symbol, "1h", since=since, limit=limit)
break
except Exception:
if attempt == 2:
return pd.Series(out, dtype=float)
time.sleep(2 ** attempt)
rows = [r for r in (rows or []) if int(r[0]) >= since]
if not rows:
break
for r in rows:
if start_ms <= int(r[0]) <= end_ms and r[4]:
out[int(r[0])] = float(r[4])
nxt = int(rows[-1][0]) + 3_600_000
if nxt <= since:
break
since = nxt
return pd.Series(out, dtype=float)
def load_refs(refresh: bool = False) -> dict[tuple[str, str], pd.Series]:
"""Cache INCREMENTALE per (asset, venue). Chiave per-venue e non globale: cambiare la lista
delle referenze non deve invalidare quelle gia' scaricate ne', peggio, restituire in silenzio
una cache che non contiene i venue nuovi."""
out: dict[tuple[str, str], pd.Series] = {}
if CACHE.exists() and not refresh:
out = pickle.loads(CACHE.read_bytes())
needed = [(a, eid, syms, lim) for a in ASSETS for eid, syms, lim in REFS
if (a, eid) not in out]
for asset, eid, syms, lim in needed:
d = load_data(asset, "1h")
s_ms, e_ms = int(d["timestamp"].iloc[0]), int(d["timestamp"].iloc[-1])
print(f" fetch {eid:<9} {asset} ...", flush=True)
out[(asset, eid)] = _fetch_1h(eid, syms[asset], s_ms, e_ms, lim)
print(f" -> {len(out[(asset, eid)]):,} barre", flush=True)
if needed:
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_bytes(pickle.dumps(out))
return out
# ===========================================================================
# nucleo puro e testabile
# ===========================================================================
def dislocation(deribit: pd.Series, refs: list[pd.Series],
ref_disagree_bps: float = REF_DISAGREE_BPS) -> pd.DataFrame:
"""Scarto FIRMATO di Deribit dal consenso delle referenze, in bps, ora per ora.
Il consenso e' la MEDIANA delle referenze disponibili. Le ore in cui le referenze non
concordano fra loro (spread > `ref_disagree_bps`) sono SCARTATE: li' non si puo' dire se il
problema sia di Deribit o della referenza, e un allarme sarebbe indistinguibile da un guasto
altrui. Serve almeno 2 referenze; con una sola tutte le ore restano ma `usable=False`.
OUTER JOIN, non inner. Le referenze hanno storie di lunghezza diversa (Bitfinex: 33k ore su
BTC, ZERO su ETH) e un `join="inner"` le farebbe decidere TUTTE il campione — la prima corsa
perse cosi' l'87% delle ore. Il consenso si forma riga per riga sulle referenze DISPONIBILI in
quell'ora, e la riga e' utilizzabile se ce ne sono almeno 2 e concordano. Stessa lezione di
`combine_outer` (26/07): un outer-join con rinormalizzazione per-riga, mai un'intersezione.
"""
cols = {"deribit": deribit}
for i, r in enumerate(refs):
cols[f"ref{i}"] = r
m = pd.concat(cols, axis=1, join="outer")
m = m[m["deribit"].notna()]
ref_cols = [c for c in m.columns if c.startswith("ref")]
if not len(m) or not ref_cols:
return pd.DataFrame(columns=["deribit", "consensus", "bps", "usable"])
R = m[ref_cols]
n_ref = R.notna().sum(axis=1)
m["consensus"] = R.median(axis=1, skipna=True)
spread = (R.max(axis=1) - R.min(axis=1)) / m["consensus"] * 1e4
# utilizzabile = almeno 2 referenze presenti E d'accordo fra loro. Con una sola referenza non
# si puo' distinguere "Deribit e' fuori" da "la referenza e' rotta": si scarta, non si allerta.
m["usable"] = (n_ref >= 2) & (spread.fillna(np.inf) <= ref_disagree_bps)
m["bps"] = (m["deribit"] - m["consensus"]) / m["consensus"] * 1e4
m = m[m["consensus"].notna()]
return m[["deribit", "consensus", "bps", "usable"]]
def episodes(bps: pd.Series, usable: pd.Series, thr_bps: float, min_hours: int) -> list[dict]:
"""Run CONSECUTIVI in cui |scarto| > soglia CON SEGNO COSTANTE, lunghi >= min_hours.
Il segno costante e' cio' che separa "arb rotto" (ostacolo strutturale, lo scarto sta da un
lato) da "mercato sottile" (lo scarto rimbalza fra i due lati). Le ore non utilizzabili
(referenze in disaccordo) ROMPONO il run: non si accumula evidenza su dati che non parlano.
"""
over = (bps.abs() > thr_bps) & usable
sign = np.sign(bps)
out, start, cur_sign = [], None, 0
for i in range(len(bps)):
if over.iloc[i] and (start is None or sign.iloc[i] == cur_sign):
if start is None:
start, cur_sign = i, sign.iloc[i]
else:
if start is not None and i - start >= min_hours:
seg = bps.iloc[start:i]
out.append(dict(start=bps.index[start], end=bps.index[i - 1], hours=i - start,
peak_bps=float(seg.abs().max()), sign=int(cur_sign)))
start = None
if over.iloc[i]:
start, cur_sign = i, sign.iloc[i]
if start is not None and len(bps) - start >= min_hours:
seg = bps.iloc[start:]
out.append(dict(start=bps.index[start], end=bps.index[-1], hours=len(bps) - start,
peak_bps=float(seg.abs().max()), sign=int(cur_sign)))
return out
def zero_fp_frontier(bps: pd.Series, usable: pd.Series,
bps_grid=BPS_GRID, hours_grid=HOURS_GRID) -> list[tuple[float, int]]:
"""Per ogni durata, la soglia in bps PIU' BASSA che non ha mai fatto scattare l'allarme.
Piu' bassa = piu' sensibile a parita' di zero falsi allarmi. La frontiera e' l'insieme delle
coppie ammissibili; sceglierne una e' una decisione, non un risultato.
"""
front = []
for h in hours_grid:
for b in bps_grid:
if not episodes(bps, usable, b, h):
front.append((float(b), int(h)))
break
return front
def main() -> None:
print("=" * 100)
print(" r0726 — TRIPWIRE DI VENUE: lo scarto Deribit-vs-consenso come allerta precoce")
print("=" * 100)
print(" Ipotesi: un venue che GATA i prelievi rompe l'arbitraggio -> scarto PERSISTENTE e a")
print(" SEGNO COSTANTE. Un crash disloca anche lui, ma per poco e a segno che rimbalza.")
print(" Referenze USD indipendenti (Coinbase, Kraken); niente USDT (il depeg 2022 le sposta).")
print("\n [1/4] referenze cross-venue (cache in data/_cache/) ...", flush=True)
refs = load_refs()
frames, coverage = {}, {}
for a in ASSETS:
d = load_data(a, "1h")
der = pd.Series(d["close"].astype(float).values, index=d["timestamp"].astype(int).values)
rr = [refs[(a, eid)] for eid, _, _ in REFS if len(refs.get((a, eid), []))]
frames[a] = dislocation(der, rr)
f = frames[a]
coverage[a] = len(f) / len(d) if len(d) else 0.0
cov = f["usable"].mean() * 100 if len(f) else 0.0
flag = "" if coverage[a] >= MIN_COVERAGE else " <-- ⚠️ COPERTURA INSUFFICIENTE"
print(f" {a}: {len(f):,} ore in comune su {len(d):,} certificate "
f"({coverage[a]*100:.1f}% del campione), {cov:.1f}% utilizzabili, "
f"{len(rr)} referenze{flag}")
if min(coverage.values()) < MIN_COVERAGE:
print(f"\n ✋ STOP: la copertura minima e' {min(coverage.values())*100:.1f}% < "
f"{MIN_COVERAGE*100:.0f}%. Una referenza corta TRONCA il campione nell'inner join")
print(" e la 'frontiera a zero falsi allarmi' descriverebbe una finestra breve, non")
print(" la storia. Nessuna conclusione stampata: e' cosi' che deve fallire.")
sys.exit(1)
# ------------------------------------------------ 2. distribuzione
print("\n [2/4] DISTRIBUZIONE dello scarto |Deribit - consenso| (solo ore utilizzabili)")
print(f"\n {'asset':<6}{'ore':>9}{'mediana':>10}{'p95':>9}{'p99':>9}{'p99.9':>9}"
f"{'max':>10}{'>50bps':>9}{'>100bps':>10}")
for a in ASSETS:
f = frames[a]
v = f.loc[f["usable"], "bps"].abs()
if not len(v):
continue
print(f" {a:<6}{len(v):>9,}{v.median():>10.1f}{v.quantile(.95):>9.1f}"
f"{v.quantile(.99):>9.1f}{v.quantile(.999):>9.1f}{v.max():>10.1f}"
f"{(v > 50).mean()*100:>8.2f}%{(v > 100).mean()*100:>9.3f}%")
# ------------------------------------------------ 3. episodi
print("\n [3/4] EPISODI = run consecutivi sopra soglia A SEGNO COSTANTE (= falsi allarmi)")
for a in ASSETS:
f = frames[a]
print(f"\n {a} {'soglia':>8} |" + "".join(f"{h:>4}h" for h in HOURS_GRID))
for b in BPS_GRID:
n = [len(episodes(f["bps"], f["usable"], b, h)) for h in HOURS_GRID]
print(f" {'':>4}{b:>10} bps |" + "".join(f"{x:>5}" for x in n))
# i piu' lunghi in assoluto: sono i candidati falsi allarmi da capire uno per uno
print("\n I 6 episodi PIU' LUNGHI a 50 bps (cosa fara' scattare il sistema per sbaglio):")
for a in ASSETS:
eps = sorted(episodes(frames[a]["bps"], frames[a]["usable"], 50, 1),
key=lambda e: -e["hours"])[:6]
for e in eps:
t0 = pd.to_datetime(e["start"], unit="ms", utc=True)
print(f" {a} {t0:%Y-%m-%d %H:%M} {e['hours']:>3}h picco {e['peak_bps']:>7.0f} bps"
f" segno {e['sign']:+d}")
# ------------------------------------------------ 4. frontiera
print("\n [4/4] FRONTIERA A ZERO FALSI ALLARMI (soglia minima che in 8 anni non scatta mai)")
print(f"\n {'durata':>8} | " + " | ".join(f"{a:>10}" for a in ASSETS))
fronts = {a: dict((h, b) for b, h in zero_fp_frontier(frames[a]["bps"], frames[a]["usable"]))
for a in ASSETS}
for h in HOURS_GRID:
cells = []
for a in ASSETS:
b = fronts[a].get(h)
cells.append(f"{b:>7.0f} bps" if b else f"{'nessuna':>10}")
print(f" {h:>6}h | " + " | ".join(f"{c:>10}" for c in cells))
# SCELTA OPERATIVA — il criterio va DICHIARATO, perche' due criteri ingenui sbagliano in versi
# opposti (entrambi provati in sessione):
# "minimi bps" -> 25bps/24h: consuma 24h delle ~72h che diede FTX;
# "minime ore" -> 500bps/2h: MANCA FTX (300 bps), margine 0.6x.
# Criterio: (1) zero falsi allarmi su ENTRAMBI gli asset; (2) soglia con margine >= MIN_MARGIN
# sul caso storico PIU' DEBOLE; (3) a quei vincoli, minima latenza.
# La latenza e' poco costosa e lo dice il controllo positivo qui sotto: gli episodi di un venue
# realmente gated durano CENTINAIA di ore, non poche.
MIN_MARGIN = 3.0
HIST = (("Mt.Gox 2013-14 (premio)", 1000.0), ("Mt.Gox finale", 2000.0),
("FTX nov-2022 (sconto)", 300.0), ("QuadrigaCX", 500.0))
weakest = min(b for _, b in HIST)
max_thr = weakest / MIN_MARGIN
cand = [(h, max(fronts[a].get(h, 1e9) for a in ASSETS)) for h in HOURS_GRID]
cand = [(h, b) for h, b in cand if b < 1e9]
ok = [(h, b) for h, b in cand if b <= max_thr]
if ok:
h_sel, b_sel = min(ok, key=lambda x: (x[0], x[1]))
print(f"\n --> coppie a ZERO falsi allarmi su ENTRAMBI gli asset:")
for h, b in cand:
why = ""
if b > max_thr:
why = f" (scartata: {weakest/b:.1f}x < {MIN_MARGIN:.0f}x su FTX)"
elif h == h_sel:
why = " <- SCELTA"
print(f" {h:>3}h -> {b:>4.0f} bps{why}")
print(f"\n Criterio: margine >= {MIN_MARGIN:.0f}x sul caso storico piu' debole")
print(f" ({weakest:.0f} bps = FTX) -> soglia <= {max_thr:.0f} bps; poi minima latenza.")
print(f" SCELTA: {b_sel:.0f} bps persistenti {h_sel}h a segno costante.")
print(f"\n MARGINE della soglia scelta vs i fallimenti storici:")
for lab, bp in HIST:
print(f" {lab:<26} {bp:>6.0f} bps -> {bp/b_sel:>5.1f}x la soglia")
else:
h_sel, b_sel = min(cand, key=lambda x: (x[0], x[1]))
print(f"\n ✋ NESSUNA coppia soddisfa insieme zero-falsi-allarmi e margine {MIN_MARGIN:.0f}x:")
print(f" la piu' vicina e' {b_sel:.0f}bps/{h_sel}h. Il rilevatore non e' utilizzabile")
print(f" cosi' com'e' e va cambiato segnale, non tarato piu' aggressivamente.")
# ------------------------------------------------ CONTROLLO POSITIVO
# ⚠️ Deve stare FUORI dai due rami: nella prima stesura era finito dentro l'`else` (il ramo di
# fallimento) e quindi non girava MAI nel caso normale — un controllo positivo che non gira e'
# esattamente il difetto che dovrebbe prevenire.
# Un rilevatore che non segnala mai nulla puo' essere semplicemente ROTTO. Qui lo si punta su
# un venue che ha DAVVERO avuto un episodio di stress da prelievi: Bitfinex nel 2018-19
# (problemi bancari/Tether) trattava BTC a premio persistente sul consenso USD.
# Bitfinex viene ESCLUSO dal proprio consenso, altrimenti assorbirebbe la sua dislocazione.
print("\n" + "=" * 100)
print(" CONTROLLO POSITIVO — il rilevatore scatta su un venue realmente in stress?")
print("=" * 100)
print(" Bersaglio: BITFINEX 2018-19 (problemi bancari/Tether -> premio persistente).")
print(" Consenso = Coinbase + Bitstamp; Bitfinex escluso dal proprio consenso.")
fired = 0
for a in ASSETS:
tgt = refs.get((a, "bitfinex"), pd.Series(dtype=float))
base = [refs[(a, e)] for e in ("coinbase", "bitstamp") if len(refs.get((a, e), []))]
if not len(tgt) or len(base) < 2:
print(f" {a}: dati insufficienti per il controllo (Bitfinex {len(tgt)} barre)")
continue
fx = dislocation(tgt, base)
eps = episodes(fx["bps"], fx["usable"], b_sel, h_sel)
fired += len(eps)
v = fx.loc[fx["usable"], "bps"]
print(f"\n {a}: {len(fx):,} ore, scarto mediano {v.abs().median():.1f} bps, "
f"max {v.abs().max():.0f} bps -> {len(eps)} EPISODI a {b_sel:.0f}bps/{h_sel}h")
for e in sorted(eps, key=lambda x: -x["hours"])[:5]:
t0 = pd.to_datetime(e["start"], unit="ms", utc=True)
print(f" {t0:%Y-%m-%d %H:%M} {e['hours']:>4}h picco {e['peak_bps']:>6.0f} bps"
f" segno {e['sign']:+d}")
if fired:
print(f"\n ✅ Il rilevatore SCATTA ({fired} episodi) su un venue realmente in stress e")
print(f" ZERO volte su Deribit in 8 anni. La specificita' non e' cecita'.")
print(f" E la DURATA di quegli episodi (centinaia di ore) e' la risposta alla")
print(f" domanda che conta: un venue gated resta dislocato per SETTIMANE, quindi")
print(f" {h_sel}h di latenza di rilevamento costano una frazione trascurabile del preavviso.")
else:
print("\n ✋ ZERO episodi anche sul bersaglio in stress: il rilevatore e' CIECO e la")
print(" frontiera sopra non significa niente. Non usare.")
if __name__ == "__main__":
main()