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
+245
View File
@@ -0,0 +1,245 @@
"""Test del tripwire di venue (src/live/venue_watch.py + scripts/research/r0726_venue_tripwire.py).
Il test che conta di piu' e' `test_controllo_positivo_*`: un rilevatore che non segnala mai nulla
e' indistinguibile da uno rotto, e questo qui e' TARATO per non segnalare (zero falsi allarmi su
8 anni). Senza un controllo positivo, "non e' mai scattato" non e' una buona notizia.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402
THRESHOLD_BPS, AssetState, WatchState,
dislocation_bps, load_state, save_state, step)
# ===========================================================================
# consenso: quando NON si puo' misurare
# ===========================================================================
def test_una_sola_referenza_non_e_un_consenso():
"""Con una referenza sola non si distingue 'Deribit e' fuori' da 'la referenza e' rotta'."""
bps, n, spread = dislocation_bps(100.0, [100.0])
assert bps is None and n == 1
def test_referenze_in_disaccordo_producono_non_misurabile():
"""Se le referenze litigano fra loro il problema e' loro: scartare, non allertare."""
bps, n, spread = dislocation_bps(100.0, [100.0, 103.0]) # 300 bps di spread
assert bps is None
assert spread > 100.0
def test_consenso_normale_da_scarto_piccolo():
bps, n, spread = dislocation_bps(100.05, [100.0, 100.02])
assert n == 2
assert 0 < bps < 10
def test_lo_scarto_e_firmato_in_entrambe_le_direzioni():
"""Premio e sconto sono ENTRAMBI segnale: Mt.Gox andava a premio, un venue in fuga a sconto."""
up, _, _ = dislocation_bps(101.0, [100.0, 100.0])
dn, _, _ = dislocation_bps(99.0, [100.0, 100.0])
assert up > 0 and dn < 0
assert abs(up + dn) < abs(up) * 0.05 # simmetrici a meno del secondo ordine
# ===========================================================================
# macchina a stati
# ===========================================================================
def test_sotto_soglia_resta_ok():
st, lvl = step(AssetState(), THRESHOLD_BPS - 1)
assert lvl == "OK" and st.streak_hours == 0
def test_servono_persist_ore_consecutive_per_allertare():
st = AssetState()
for i in range(PERSIST_HOURS - 1):
st, lvl = step(st, THRESHOLD_BPS + 50)
assert lvl == "WATCH", f"ora {i+1}: allarme troppo presto"
st, lvl = step(st, THRESHOLD_BPS + 50)
assert lvl == "ALERT"
def test_il_cambio_di_segno_azzera_lo_streak():
"""E' il cuore della discriminazione: uno scarto che rimbalza non e' un arbitraggio rotto."""
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, -(THRESHOLD_BPS + 50)) # stesso modulo, segno opposto
assert lvl == "WATCH" and st.streak_hours == 1
def test_un_rientro_sotto_soglia_azzera_lo_streak():
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, 5.0)
assert lvl == "OK" and st.streak_hours == 0
def test_non_rispamma_ogni_ora_durante_lo_stesso_streak():
st = AssetState()
levels = []
for _ in range(PERSIST_HOURS + 5):
st, lvl = step(st, THRESHOLD_BPS + 50)
levels.append(lvl)
assert levels.count("ALERT") == 1, f"allarmi ripetuti: {levels}"
def test_dopo_un_alert_un_nuovo_streak_di_segno_opposto_riallerta():
"""Il de-spam non deve nascondere un evento NUOVO."""
st = AssetState()
for _ in range(PERSIST_HOURS):
st, _ = step(st, THRESHOLD_BPS + 50)
st, _ = step(st, 0.0) # rientro
levels = []
for _ in range(PERSIST_HOURS):
st, lvl = step(st, -(THRESHOLD_BPS + 50))
levels.append(lvl)
assert levels[-1] == "ALERT"
# ===========================================================================
# lo stato BLIND: "non vedo" non e' "va bene"
# ===========================================================================
def test_dati_mancanti_azzerano_lo_streak_non_lo_accumulano():
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, None)
assert st.streak_hours == 0, "evidenza accumulata su dati che non parlano"
def test_cecita_prolungata_diventa_essa_stessa_un_allarme():
st = AssetState()
for i in range(BLIND_ALERT_HOURS - 1):
st, lvl = step(st, None)
assert lvl == "OK"
st, lvl = step(st, None)
assert lvl == "BLIND"
def test_una_misura_valida_azzera_il_contatore_di_cecita():
st = AssetState()
for _ in range(BLIND_ALERT_HOURS):
st, _ = step(st, None)
st, lvl = step(st, 1.0)
assert st.blind_hours == 0 and lvl == "OK"
# ===========================================================================
# persistenza
# ===========================================================================
def test_stato_salvato_e_riletto(tmp_path):
p = tmp_path / "state.json"
ws = WatchState(assets={"BTC": AssetState(streak_hours=3, sign=-1)}, last_ts=123)
save_state(ws, p)
back = load_state(p)
assert back.assets["BTC"].streak_hours == 3
assert back.assets["BTC"].sign == -1
assert back.last_ts == 123
def test_stato_corrotto_non_fa_crashare_il_cron(tmp_path):
"""Un JSON rotto non deve rompere il giro orario del book."""
p = tmp_path / "state.json"
p.write_text("{ questo non e' json")
st = load_state(p)
assert isinstance(st, WatchState) and st.last_ts == 0
# ===========================================================================
# nucleo di ricerca: episodi e outer-join
# ===========================================================================
def _tw():
import r0726_venue_tripwire as T
return T
def test_il_consenso_usa_outer_join_non_intersezione():
"""Il bug della prima corsa: una referenza corta troncava il campione da 8 anni a 29 giorni."""
T = _tw()
d = pd.Series([100.0] * 10, index=range(10))
lunga = pd.Series([100.0] * 10, index=range(10))
corta = pd.Series([100.0] * 3, index=range(3))
f = T.dislocation(d, [lunga, corta])
assert len(f) == 10, "inner join: la referenza corta decide il campione"
def test_episodi_richiedono_segno_costante():
T = _tw()
idx = range(10)
alternato = pd.Series([200.0, -200.0] * 5, index=idx)
usable = pd.Series([True] * 10, index=idx)
assert T.episodes(alternato, usable, 100, 3) == []
costante = pd.Series([200.0] * 10, index=idx)
assert len(T.episodes(costante, usable, 100, 3)) == 1
def test_le_ore_non_utilizzabili_rompono_il_run():
T = _tw()
idx = range(10)
bps = pd.Series([200.0] * 10, index=idx)
usable = pd.Series([True, True, False, True, True, True, True, True, True, True], index=idx)
eps = T.episodes(bps, usable, 100, 6)
assert len(eps) == 1 and eps[0]["hours"] == 7, "un buco di dati non deve saldare due run"
# ===========================================================================
# CONTROLLI POSITIVI — senza questi "non scatta mai" non e' una buona notizia
# ===========================================================================
def test_controllo_positivo_firma_di_venue_gated():
"""Dislocazione grande, persistente e a segno costante = deve scattare."""
T = _tw()
n = 200
idx = range(n)
bps = pd.Series([450.0] * n, index=idx) # premio tipo Bitfinex 2018
usable = pd.Series([True] * n, index=idx)
eps = T.episodes(bps, usable, THRESHOLD_BPS, PERSIST_HOURS)
assert len(eps) == 1 and eps[0]["hours"] == n and eps[0]["sign"] == 1
def test_controllo_positivo_la_macchina_a_stati_scatta_sulla_stessa_firma():
"""Lo stesso episodio, passato dalla macchina a stati di produzione, deve produrre ALERT."""
st = AssetState()
out = []
for _ in range(24):
st, lvl = step(st, 450.0)
out.append(lvl)
assert "ALERT" in out
def test_controllo_negativo_un_crash_breve_non_scatta():
"""Marzo 2020 su Deribit: 12h a 158 bps di picco ma sotto i 100 bps per meno di 4h di fila
alla soglia scelta. Qui la versione stilizzata: uno spike di 3 ore non deve allertare."""
st = AssetState()
out = []
for bps in (150.0, 150.0, 150.0, 10.0, 5.0):
st, lvl = step(st, bps)
out.append(lvl)
assert "ALERT" not in out, f"spike breve ha allertato: {out}"
def test_la_taratura_congelata_non_cambia_per_sbaglio():
"""La taratura VALE 'zero falsi allarmi su 8 anni': se qualcuno la cambia, il claim decade
e va rimisurato con r0726_venue_tripwire.py."""
assert THRESHOLD_BPS == 100.0
assert PERSIST_HOURS == 4
def test_il_watch_e_cablato_nel_cron_orario():
sh = (ROOT / "scripts" / "cron_book.sh").read_text()
assert "venue_watch.py" in sh
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")