From ab5bcace169ab8a5cb1fd58c9ada69d20e95e437 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 26 Jul 2026 19:38:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20VENUE=20WATCH=20=E2=80=94=20tripwire=20?= =?UTF-8?q?di=20fallimento=20exchange,=20cablato=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + CLAUDE.md | 53 ++++ docs/diary/2026-07-26-venue-tripwire.md | 182 +++++++++++ scripts/cron_book.sh | 4 + scripts/live/venue_watch.py | 53 ++++ scripts/research/r0726_venue_response.py | 158 ++++++++++ scripts/research/r0726_venue_tripwire.py | 380 +++++++++++++++++++++++ src/live/venue_watch.py | 223 +++++++++++++ tests/test_venue_watch.py | 245 +++++++++++++++ 9 files changed, 1299 insertions(+) create mode 100644 docs/diary/2026-07-26-venue-tripwire.md create mode 100644 scripts/live/venue_watch.py create mode 100644 scripts/research/r0726_venue_response.py create mode 100644 scripts/research/r0726_venue_tripwire.py create mode 100644 src/live/venue_watch.py create mode 100644 tests/test_venue_watch.py diff --git a/.gitignore b/.gitignore index 17497f9..e7a7f35 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 680b0b0..c64241c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.** diff --git a/docs/diary/2026-07-26-venue-tripwire.md b/docs/diary/2026-07-26-venue-tripwire.md new file mode 100644 index 0000000..9edb2b9 --- /dev/null +++ b/docs/diary/2026-07-26-venue-tripwire.md @@ -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. diff --git a/scripts/cron_book.sh b/scripts/cron_book.sh index 8f5b0bc..f164768 100755 --- a/scripts/cron_book.sh +++ b/scripts/cron_book.sh @@ -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 diff --git a/scripts/live/venue_watch.py b/scripts/live/venue_watch.py new file mode 100644 index 0000000..d25132a --- /dev/null +++ b/scripts/live/venue_watch.py @@ -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()) diff --git a/scripts/research/r0726_venue_response.py b/scripts/research/r0726_venue_response.py new file mode 100644 index 0000000..e1c13dc --- /dev/null +++ b/scripts/research/r0726_venue_response.py @@ -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() diff --git a/scripts/research/r0726_venue_tripwire.py b/scripts/research/r0726_venue_tripwire.py new file mode 100644 index 0000000..ffc0bdc --- /dev/null +++ b/scripts/research/r0726_venue_tripwire.py @@ -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() diff --git a/src/live/venue_watch.py b/src/live/venue_watch.py new file mode 100644 index 0000000..9cca94f --- /dev/null +++ b/src/live/venue_watch.py @@ -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 diff --git a/tests/test_venue_watch.py b/tests/test_venue_watch.py new file mode 100644 index 0000000..ecca17a --- /dev/null +++ b/tests/test_venue_watch.py @@ -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")