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:
@@ -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()
|
||||
Reference in New Issue
Block a user