feat: VENUE WATCH — tripwire di fallimento exchange, cablato live
Risposta a "trova un sistema di protezione da fallimento exchange" SOTTO IL VINCOLO della decisione appena presa (100% Deribit fino a $20k). Se non si puo' ridurre l'ESPOSIZIONE, l'unica leva e' il TEMPO: il modello di rischio del mattino assumeva il salto a zero istantaneo, ma i fallimenti reali non lo sono (Mt.Gox mesi, FTX ~72h, e misurato qui: Bitfinex 2018-19 dislocato per 2.324 ore consecutive). SEGNALE: un venue che gata i prelievi rompe l'ARBITRAGGIO -> il prezzo si stacca dal consenso e ci resta. E' |scarto|, non il segno (Mt.Gox a premio, un venue in fuga a sconto: stessa cosa). Consenso = venue USD indipendenti (Coinbase, Bitstamp), mai USDT. Deribit sta a 3 bps dal consenso in mediana su 8 anni (65.043 ore BTC + 64.541 ETH). TARATURA CONGELATA: 100 bps persistenti 4h a segno costante. Criterio DICHIARATO PRIMA, perche' i due ovvi sbagliano in versi opposti (provati entrambi): "minimi bps" -> 25/24h consuma 24 delle ~72h di FTX; "minime ore" -> 500/2h MANCA FTX (margine 0.6x). Regola: zero falsi allarmi in 8 anni + margine >=3x sul caso storico piu' debole -> soglia <=100bps -> poi minima latenza. Margine 3x FTX / 5x Quadriga / 10-20x Mt.Gox, zero falsi allarmi con crash COVID, maggio 2021, LUNA e novembre 2022 inclusi. CONTROLLO POSITIVO SUPERATO (un rilevatore tarato per non segnalare e' indistinguibile da uno rotto): puntato su Bitfinex 2018-19 scatta 22 volte, episodio piu' lungo 2.324h a +447bps. 22 dove il problema c'era, 0 su Deribit. E la durata risponde alla domanda vera: un venue gated resta dislocato per settimane, quindi 4h di latenza sono trascurabili. ECONOMIA: falso allarme = 0.248% atteso (flat 3g misurato sul book reale a ogni data d'inizio); vero positivo = 100% salvato. Break-even p > (falsi/anno) x 0.00248: a 1 ogni 8 anni serve p > 0.031%. Il valore sta nella SPECIFICITA', non nella sensibilita'. CABLATO: src/live/venue_watch.py (nucleo puro) + scripts/live/venue_watch.py, in cron_book.sh PRIMA di book_execute (se Deribit e' in stress l'allarme deve partire anche quando l'esecuzione fallisce per la stessa ragione). Tre stati OK/ALERT/BLIND — "non vedo" non e' "va bene". ALLERTA, NON BLOCCA: l'azione e' prelevare (manuale; una chiave con permesso di prelievo sarebbe essa stessa un rischio) e bloccare non protegge un saldo che e' a rischio anche stando flat. Runbook pre-deciso nel docstring. NON COPRE, e non e' un argomento per riaprire il 26/07: un fallimento SENZA finestra (furto chiavi, sequestro, exit-scam) non lo prende nessun tripwire. ERRORI CATTURATI IN SESSIONE: - break-even calcolato sul p5 invece che sulla media (8.3x piu' severo, conclusione ribaltata); - ipotesi meccanica sbagliata: credevo che i crash dislocassero a segno ALTERNATO. Falso, sono a segno costante anche loro (perp sotto spot per ore in cascata). A separare sono ampiezza e durata, non il segno; - la prima corsa tronco' il campione da 8 anni a 29 GIORNI per un inner-join con Kraken (che serve solo ~700 candele) e la copertura era gia' stampata a video: una diagnostica stampata NON e' un controllo. Ora c'e' una guardia che ferma lo script. 2a occorrenza in un giorno dopo GTAA01; - il controllo positivo era finito dentro il ramo `else` -> non girava mai, cioe' esattamente il difetto che doveva prevenire. Book, pesi, config INVARIATI. 359 test verdi (+23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user