merge: venue watch — tripwire di fallimento exchange (100bps/4h, zero falsi allarmi in 8 anni)

This commit is contained in:
Adriano Dal Pastro
2026-07-26 19:38:19 +00:00
9 changed files with 1299 additions and 0 deletions
+1
View File
@@ -31,6 +31,7 @@ data/funds_watch.json
# dati regime (DVOL/funding/feature cache, rigenerabili)
data/regime/
data/venue_watch/
_disp_scratch/
data/regime/dispersion_features.parquet
+53
View File
@@ -936,6 +936,59 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
presa su un asse solo va ricontrollata sugli altri prima di considerarla stabile; (d) quando una
raccomandazione viene respinta con motivo, si registra **cosa e' stato accettato in cambio**
altrimenti la stessa analisi la ripropone fra tre mesi come se fosse nuova.
-**VENUE WATCH — tripwire di fallimento exchange, CABLATO LIVE (2026-07-26).** Risposta alla
domanda *"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**. Script `r0726_venue_tripwire.py` (segnale) + `r0726_venue_response.py`
(costo della risposta); produzione `src/live/venue_watch.py` + `scripts/live/venue_watch.py` in
`cron_book.sh`; test `tests/test_venue_watch.py` (23); diario `2026-07-26-venue-tripwire.md`.
**Book, pesi, config INVARIATI.**
(1) **Il segnale:** un venue che gata i prelievi **rompe l'arbitraggio** → il suo prezzo si stacca
dal consenso e ci RESTA. Il segnale e' **|scarto|, non il segno** (Mt.Gox andava a *premio*, un
venue in fuga a *sconto*: dicono la stessa cosa). Consenso = venue **USD indipendenti**
(Coinbase, Bitstamp); mai USDT (depeg 2022 → falsi allarmi giganti). Deribit sta a **3 bps** dal
consenso in mediana su 8 anni (65.043 ore BTC + 64.541 ETH) — fondo di rumore bassissimo, ed e'
cio' che rende possibile una soglia con margine.
(2) **Taratura CONGELATA = 100 bps persistenti 4h a segno costante.** Criterio **dichiarato
prima**, perche' i due ovvi sbagliano in versi opposti (provati entrambi): *minimi bps* → 25bps/24h
consuma 24 delle ~72h che diede FTX; *minime ore* → 500bps/2h **manca FTX** (margine 0.6x).
Regola adottata: (a) zero falsi allarmi su entrambi gli asset in 8 anni; (b) margine ≥3x sul caso
storico **piu' debole** (FTX ~300bps) → soglia ≤100bps; (c) a quei vincoli, minima latenza.
Margine finale **3x FTX / 5x QuadrigaCX / 10-20x Mt.Gox**, con **zero falsi allarmi** inclusi
crash COVID 2020-03, maggio 2021, LUNA e novembre 2022.
(3) ✅ **CONTROLLO POSITIVO superato** (obbligatorio: un rilevatore tarato per non segnalare e'
indistinguibile da uno rotto). Puntato su **Bitfinex 2018-19** (problemi bancari/Tether):
**22 episodi**, il piu' lungo **2.324 ore consecutive** a +447bps di picco, altri a 1.151h/+663bps
e 496h/+1136bps. **22 scatti dove il problema c'era, 0 su Deribit in 8 anni.** E la DURATA
risponde alla domanda vera: un venue gated resta dislocato per **settimane** → 4h di latenza
costano una frazione trascurabile del preavviso.
(4) **Economia della risposta:** costo ATTESO di un falso allarme (flat 3 giorni, misurato sul
book reale a ogni data d'inizio) = **0.248%** di equity (coda p5 2.065%); guadagno di un vero
positivo = **100%**. Break-even: `p_annua > (falsi allarmi/anno) × 0.00248` → a 1 ogni 8 anni
serve **p > 0.031%**, a 12/anno servirebbe p > 2.97%. **Il valore sta nella SPECIFICITA', non
nella sensibilita'.** ⚠️ Errore mio corretto: il break-even si calcola sulla **media**, non sul
p5 (con la coda esce 8.3x piu' severo e la conclusione si ribalta).
(5) **Tre stati, e il terzo NON e' il primo:** `OK` / `ALERT` / **`BLIND`** (referenze
irraggiungibili o in disaccordo fra loro → dopo 12h e' un allarme suo). *"Non vedo" non e' "va
tutto bene"* — stessa lezione della contabilita' a 3 stati di `paper_dvolspread`. **ALLERTA, NON
BLOCCA:** l'azione a un vero positivo e' *prelevare* (manuale — una chiave API con permesso di
prelievo sarebbe essa stessa un rischio), e bloccare non protegge il saldo, che e' a rischio
anche stando flat. **Runbook pre-deciso** nel docstring del modulo (escludere guasto referenze →
`public/status`**prelievo di prova**, unica evidenza diretta → flat + prelievo totale).
(6) ⚠️ **Cio' che NON copre, e non e' un argomento per riaprire il 26/07:** un fallimento **senza
finestra** (furto di chiavi, sequestro, exit-scam notturno) non lo prende nessun tripwire; quella
parte di `p` resta scoperta e la sola difesa e' lo split. E il preavviso di 200-2.300 ore viene da
**un** caso osservato: e' un'ancora, non una distribuzione.
**REGOLE:** (a) un rilevatore tarato per non segnalare va validato su un **controllo positivo**;
(b) una soglia si sceglie con un criterio **dichiarato prima** (i criteri ovvi sbagliano in versi
opposti); (c) la persistenza richiesta **e' latenza** e va confrontata con la durata del fenomeno
da rilevare; (d) un break-even si calcola sulla **media**, non sulla coda; (e) ⚠️ **una
diagnostica STAMPATA non e' un controllo** — 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'
a video: ora c'e' una guardia che ferma lo script (2ª occorrenza in un giorno dopo GTAA01 —
**l'outer-join con referenze di lunghezza diversa e' una trappola ricorrente**); (f) un controllo
positivo finito dentro il ramo `else` **non gira mai** (trovato in sessione: era esattamente il
difetto che doveva prevenire).
- 📅 **NUOVO SCHEMA FEE DERIBIT dal 2026-08-01 — misurata la CURVA, nessuna azione oggi
(2026-07-26).** Script `r0726_fee_sensitivity.py`, test `tests/test_fee_sensitivity.py` (6),
diario `2026-07-26-fee-deribit.md`. **Book/pesi/cron/config INVARIATI.**
+182
View File
@@ -0,0 +1,182 @@
# 2026-07-26 — Un sistema di protezione dal fallimento exchange, a capitale concentrato
**Richiesta:** *"trova un sistema di protezione da fallimento exchange"* — subito dopo la decisione
di restare **100% su Deribit fino a $20k**.
Il vincolo e' quello che rende il problema interessante: **la riduzione dell'esposizione e' fuori
discussione**. Lo split di venue e' stato valutato e rifiutato con motivo (§5 del diario
`2026-07-26-venue-risk.md`). Quindi non si puo' ridurre *quanto* si perde: si puo' solo ridurre
**la probabilita' di essere ancora dentro quando succede**.
**Script:** `r0726_venue_tripwire.py` (il segnale), `r0726_venue_response.py` (il costo di
rispondere). **Produzione:** `src/live/venue_watch.py` + `scripts/live/venue_watch.py`, cablato in
`cron_book.sh`. **Test:** `tests/test_venue_watch.py` (23). **Book, pesi, config: INVARIATI.**
---
## 0. L'idea in una riga
Il modello di rischio del mattino assumeva il **salto a zero istantaneo**. I fallimenti reali non
lo sono: Mt.Gox gato' i prelievi per mesi, FTX ebbe ~72 ore, e — misurato qui — **Bitfinex nel
2018-19 resto' dislocato dal consenso per 2.324 ore consecutive**. Se dentro quella finestra il
saldo esce, la perdita non e' totale. Il sistema e' quindi un **rilevatore di finestra**, non una
riduzione di esposizione.
## 1. Il segnale, e perche' e' falsificabile
Quando un exchange gata i prelievi **l'arbitraggio si rompe**: non si puo' piu' comprare li' e
vendere altrove per chiudere lo scarto, quindi il suo prezzo si stacca dal consenso e ci resta.
Il segnale e' **|scarto|, non il 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*. Dicono la stessa cosa.
Misurato su 8 anni di feed certificato, consenso = mediana di venue **USD indipendenti** (Coinbase,
Bitstamp; mai USDT — il depeg 2022 sposta BTC/USDT fino al 3% e genererebbe falsi allarmi giganti):
```
asset ore mediana p95 p99 p99.9 max >50bps >100bps
BTC 65,043 3.1 11.4 18.0 34.4 712.9 0.06% 0.018%
ETH 64,541 3.3 13.3 22.2 44.3 453.1 0.07% 0.017%
```
Deribit sta a **3 bps** dal consenso in mediana. E' un fondo di rumore bassissimo, ed e' cio' che
rende possibile una soglia con margine.
## 2. L'ipotesi meccanica era in parte SBAGLIATA
Avevo scritto, prima di misurare, che il discriminante sarebbe stato la **costanza del segno**:
un venue gated resta da un lato, un crash rimbalza fra i due. **Falso.** Gli episodi peggiori su
Deribit sono tutti a segno costante anche loro:
```
BTC 2020-03-13 10:00 12h picco 158 bps segno -1 (crash COVID)
ETH 2022-09-14 20:00 10h picco 97 bps segno -1 (Merge)
ETH 2020-03-12 23:00 3h picco 453 bps segno -1
BTC 2021-07-01 13:00 2h picco 150 bps segno +1
```
In un cascata di liquidazioni il perp sta **sotto** lo spot per ore di fila: e' un run a segno
costante come quello di un venue gated. Il segno costante resta nel rilevatore (toglie il rumore
simmetrico), ma **non e' lui a separare i due casi**: separano **ampiezza e durata**. Ipotesi
corretta nella conclusione, sbagliata nel meccanismo.
## 3. La taratura va DICHIARATA, perche' due criteri ovvi sbagliano in versi opposti
Provati entrambi in sessione:
| criterio ingenuo | esito | perche' e' sbagliato |
|---|---|---|
| **minimi bps** | 25 bps / 24h | consuma 24 delle ~72 ore che diede FTX |
| **minime ore** | 500 bps / 2h | **manca FTX** (300 bps): margine 0.6x |
Criterio adottato, in quest'ordine: **(1)** zero falsi allarmi su entrambi gli asset in 8 anni;
**(2)** margine ≥3x sul caso storico **piu' debole** (FTX ~300 bps) → soglia ≤100 bps; **(3)** a
quei vincoli, **minima latenza**.
```
2h -> 500 bps (scartata: 0.6x < 3x su FTX)
3h -> 150 bps (scartata: 2.0x < 3x su FTX)
4h -> 100 bps <- SCELTA
6h -> 75 bps
24h -> 25 bps
```
**→ 100 bps persistenti 4 ore a segno costante.** Margine 3x su FTX, 5x su QuadrigaCX, 10-20x su
Mt.Gox. Zero falsi allarmi in 8 anni, **crash COVID / maggio 2021 / LUNA / novembre 2022 inclusi**.
## 4. Il controllo positivo — senza, "non scatta mai" non e' una buona notizia
Un rilevatore tarato per non segnalare e' indistinguibile da uno rotto. Puntato su **Bitfinex
2018-19** (problemi bancari/Tether, premio persistente), consenso Coinbase+Bitstamp con Bitfinex
escluso dal proprio consenso:
```
BTC: 33.000 ore, scarto mediano 11 bps, max 1136 bps -> 22 EPISODI a 100bps/4h
2018-12-26 2324h picco 447 bps segno +1
2018-11-08 1151h picco 663 bps segno +1
2018-10-10 496h picco 1136 bps segno +1
2019-04-25 363h picco 734 bps segno +1
```
**22 scatti su un venue realmente in stress, zero su Deribit in 8 anni.** E la *durata* di quegli
episodi risponde alla domanda che conta davvero: un venue gated resta dislocato per **settimane**,
quindi 4 ore di latenza di rilevamento costano una frazione trascurabile del preavviso.
## 5. Il lato risposta: quanto costa sbagliarsi
Un allarme senza il costo della risposta non e' un sistema. Misurato sul book Deribit reale
(TP01+SKH01 75/25) forzando flat una finestra a **ogni** data d'inizio possibile:
```
giorni costo medio mediana p5 (peggio) p95 (meglio) % dannosi
1 -0.149% -0.100% -1.093% 0.590% 76.4%
3 -0.248% -0.100% -2.065% 1.006% 67.9%
7 -0.442% -0.100% -4.072% 1.505% 62.4%
30 -1.537% -0.455% -9.236% 2.993% 57.3%
```
⚠️ **Errore mio, corretto:** avevo impostato il break-even sul **p5** (la coda). Per un valore
atteso ripetuto nel tempo si usa la **media** — con la coda il numero esce 8.3x piu' severo e la
conclusione si ribalta. Il p5 serve a sapere quanto puo' bruciare *una* volta, non a decidere.
```
Il tripwire conviene se p_annua > (falsi allarmi/anno) x 0.00248:
1 ogni 8 anni -> p > 0.031% conviene per ogni p del ventaglio
1/anno -> p > 0.248% conviene per ogni p del ventaglio
4/anno -> p > 0.991% conviene da p=1%
12/anno -> p > 2.974% conviene solo a p=5%
52/anno -> p > 12.889% NON conviene mai
```
**Il valore del sistema sta tutto nella SPECIFICITA', non nella sensibilita'.** Un tripwire che
strilla 12 volte l'anno sarebbe dannoso; questo, a zero falsi allarmi misurati su 8 anni, ha un
break-even di **p > 0.031%** — un ordine di grandezza sotto l'ipotesi piu' ottimistica del
venue-risk (0.5%).
## 6. Cosa e' stato cablato
`src/live/venue_watch.py` (nucleo puro, testabile) + `scripts/live/venue_watch.py` (runner), 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, e il terzo non e' il primo:** `OK` (misurato, sotto soglia) / `ALERT` (misurato,
sopra soglia e persistente → Telegram) / **`BLIND`** (non misurabile: referenze irraggiungibili o
in disaccordo fra loro). *"Non vedo" non e' "va tutto bene"*: dopo 12 ore di cecita' scatta un
allarme suo. Stessa lezione della contabilita' a 3 stati di `paper_dvolspread`.
**Allerta, 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.
E bloccare l'esecuzione non protegge il saldo: il saldo e' a rischio anche stando flat.
**Runbook pre-deciso** (nel docstring del modulo, per non doverlo decidere nel momento sbagliato):
1. escludere il guasto delle referenze (`n_refs`, `ref_spread_bps`);
2. `public/status` (`platform_locked`) e canali ufficiali;
3. **prelievo di prova piccolo** — l'unica evidenza DIRETTA; se non passa in tempi normali
l'allarme e' vero qualunque altra spiegazione esista;
4. se non passa: flat + prelievo totale. Sbagliarsi costa **0.248%**, aver ragione salva il 100%.
## 7. Cio' che questo sistema NON copre — e va detto
Il tripwire prezza la finestra, non la sua esistenza. **Un fallimento senza finestra non lo prende
nessuno**: furto di chiavi, sequestro, exit-scam notturno. Quella parte di `p` resta scoperta, e la
sola difesa e' quella che l'operatore ha rifiutato fino a $20k — lo split. Il sistema **riduce** il
rischio di venue, non lo elimina, e non e' un argomento per cambiare la decisione del 26/07.
Secondo limite dichiarato: il preavviso storico di 200-2300 ore viene da **un** caso osservato
(Bitfinex). E' un'ancora, non una distribuzione.
## 8. Regole trasferibili
1. **Un rilevatore tarato per non segnalare va validato su un controllo positivo**, o "non e' mai
scattato" e' indistinguibile da "e' rotto". Qui: 22 scatti su Bitfinex, zero su Deribit.
2. **Una soglia si sceglie con un criterio dichiarato PRIMA**, perche' i criteri ovvi sbagliano in
versi opposti: "minimi bps" perde preavviso, "minime ore" perde il caso da prendere.
3. **La persistenza richiesta e' latenza, e la latenza va sottratta al preavviso.** Va confrontata
con la durata del fenomeno da rilevare, non scelta in astratto.
4. **Un break-even si calcola sulla media, non sulla coda.** La coda dice quanto fa male una volta;
la media dice se conviene ripeterlo.
5. **Un outer-join con una referenza corta e' una trappola** (2ª volta oggi, dopo GTAA01): la
prima corsa tronco' il campione da 8 anni a 29 giorni *in silenzio*, e la diagnostica di
copertura era gia' stampata. **Una diagnostica stampata non e' un controllo** — ora c'e' una
guardia che ferma lo script.
6. **Un controllo positivo dentro il ramo `else` non gira mai.** Trovato in sessione: era finito
nel ramo di fallimento, cioe' esattamente il difetto che doveva prevenire.
+4
View File
@@ -9,6 +9,10 @@ cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
{
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_book ====="
# Tripwire di venue PRIMA dell'esecuzione: se Deribit e' in stress l'allarme deve
# partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2
# = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py.
uv run python scripts/live/venue_watch.py --quiet || true
uv run python scripts/live/book_execute.py --execute
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_book.log 2>&1
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python
"""venue_watch.py — runner ORARIO del tripwire di venue. Allerta su Telegram, NON blocca nulla.
Vedi `src/live/venue_watch.py` per la taratura (100 bps / 4h a segno costante, zero falsi allarmi
su 8 anni, controllo positivo su Bitfinex 2018-19) e per il RUNBOOK a un allarme.
uv run python scripts/live/venue_watch.py # un giro, stampa il report
uv run python scripts/live/venue_watch.py --quiet # solo in caso di allarme (per il cron)
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.notifier import notify # noqa: E402
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
run_once)
def main() -> int:
quiet = "--quiet" in sys.argv
rep = run_once()
if not quiet:
print("=" * 78)
print(" VENUE WATCH — scarto Deribit vs consenso USD indipendente")
print("=" * 78)
print(f" soglia: {THRESHOLD_BPS:.0f} bps persistenti {PERSIST_HOURS}h a segno costante")
lock = rep["platform_locked"]
print(f" public/status locked: {lock if lock is not None else 'non leggibile'}")
for a, lvl in rep["levels"].items():
d = rep["detail"][a]
bps = f"{d['bps']:+7.1f} bps" if d["bps"] is not None else " n/d "
print(f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze, "
f"spread {d['ref_spread_bps']:.1f} bps)" if d["ref_spread_bps"] is not None
else f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze)")
if rep["alerts"]:
# ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
# sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
notify("🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova",
{f"alert {i+1}": a for i, a in enumerate(rep["alerts"])})
for a in rep["alerts"]:
print(f" ALERT: {a}")
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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()
+223
View File
@@ -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
+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")