d55eb13533
cerbero-bite viene eliminato. L'unica sua parte irreversibile e' il DATO:
una catena opzioni non si ricostruisce a posteriori (Deribit non serve book
storici, non c'e' un secondo venue). Il codice si riscrive; le ore non
raccolte no.
ASSORBITO
- scripts/live/collect_chain.py + scripts/cron_chain.sh (cron 25 * * * *):
raccolta propria, ~570 strumenti/giro, ~3 min.
- scripts/analysis/import_cb_archive.py: archivio 1.23M righe (2026-05-01+)
+ market_snapshots 17.402 righe (2026-03-26+: dealer gamma, gamma flip,
rischio liquidazioni, funding cross — dati che non abbiamo altrove).
- snapshot sqlite integrale in /opt/docker/backups/manual/ (SHA256).
NON ASSORBITO, con motivo: motore credit-spread ETH (regola "niente
short-vol da modello in deploy", conto a $52 contro minimo $720), GUI, kill
switch/dead-man/audit (abbiamo venue_watch/edge_watch/monitor_health/
fee_watch), dvol_history (fetch_dvol.py ha storia PIU' LUNGA: 2020+ contro
2026-05), decisions/positions (0 posizioni).
TRE DIFETTI DI BITE NON REPLICATI, tutti misurati il 30/07:
1. una chiamata per strumento invece di due (get_order_book?depth=3 da' gia'
quote+greche+IV+OI+book+underlying) + prefiltro OI in una chiamata sola:
551 -> ~290 chiamate per asset;
2. pacing invece di raffica. Il carico non e' mai stato il problema: 570
chiamate/ora = 0.16/s DISTRIBUITE; bite le sparava in 26s (~44/s) e si
auto-saturava il rate limit per-IP (12.186 risposte 429 in 26h, 96% al
minuto :00). Primo giro reale: 574 chiamate, 0 risposte 429. Il minuto :25
e' scelto: :00 era la raffica, :07 e' cron_book (feed 5m di SKH01).
3. quote_status esplicito {ok, no_quote, error} e book_depth NULL su errore
mai 0. "Book vuoto" e "chiamata fallita" sono cose diverse: e' per questo
che il guasto del 29/07 (50% di quote perse, 38 ore) non produsse alcun
segnale. Le righe ereditate restano 'unknown': bite non lo registrava e a
posteriori non e' ricostruibile.
Battuta di cuore in data/chain_collect/runs.jsonl anche a giro fallito,
sorvegliata da monitor_health (1h, max_age 3h): un collettore fermo non
produce niente, e il niente si legge come "nessun dato quel giorno".
Difetto trovato per strada: due formati ISO nella stessa colonna (92 righe
di backfill senza microsecondi). pd.to_datetime senza `format` ne inferisce
uno solo e manda gli altri a NaT -> il dropna a valle li toglieva in
silenzio, e la serie di contesto perdeva 5 settimane slittando dal 26/03 al
01/05. Corretto con format="ISO8601" e scarto RUMOROSO.
Book, pesi, config, strategia INVARIATI. 537 test verdi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
9.1 KiB
Python
208 lines
9.1 KiB
Python
"""CERTIFICAZIONE della catena opzioni in `data/raw/cb_chain/` (archivio bite + raccolta propria).
|
|
|
|
Storia: nato il 30/07 come `fetch_cb_chain.py` per estrarre la catena dal container cerbero-bite.
|
|
Con lo smantellamento di bite l'estrazione e' diventata un import una-tantum
|
|
(`import_cb_archive.py`) e la raccolta e' passata a `scripts/live/collect_chain.py`; qui resta
|
|
cio' che serve per sempre: **il giudizio sul dato**.
|
|
|
|
CERTIFICAZIONE — quattro difetti, ognuno con la sua diagnostica:
|
|
1. QUOTE VUOTE la riga c'e' ma la quota no. E' il difetto che il 29/07 ha portato il tasso da
|
|
~0.4% a ~50% per 38 ore senza un segnale: il CONTEGGIO RIGHE resta identico, quindi ogni
|
|
controllo di copertura basato sulle righe dice "tutto bene". Sulle righe raccolte da
|
|
`collect_chain.py` si legge anche il PERCHE' (`quote_status`: no_quote vs error); sulle righe
|
|
ereditate da bite no (`unknown`), perche' bite non lo registrava.
|
|
2. BOOK INCROCIATO bid > ask (quote incoerenti).
|
|
3. MONOTONIA a parita' di scadenza/istante il premio di una put e' non-decrescente nello
|
|
strike: e' il payoff, non un modello -> una violazione e' un difetto di dato.
|
|
4. PROFONDITA' ZERO nell'archivio bite `book_depth_top3 == 0` e' AMBIGUO by design (chiamata
|
|
fallita e book vuoto danno lo stesso valore) -> si riporta, non si ripara. Nelle righe nuove
|
|
l'ambiguita' non esiste: su errore la profondita' e' NULL.
|
|
|
|
uv run python scripts/analysis/certify_cb_chain.py
|
|
uv run python scripts/analysis/certify_cb_chain.py --db /percorso/state.sqlite # legacy
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
RAW = PROJECT_ROOT / "data" / "raw"
|
|
STORE = RAW / "cb_chain"
|
|
CONTAINER = "cerbero-bite-cerbero-bite-1" # legacy: il container non esiste piu'
|
|
DB_IN_CONTAINER = "/app/data/state.sqlite"
|
|
|
|
# --- soglie di certificazione (decise, non default) ---
|
|
HOLLOW_WARN = 0.05 # >5% di quote vuote su una finestra = DEGRADATO
|
|
HOLLOW_FAIL = 0.30 # >30% = il dato non descrive piu' il mercato
|
|
RECENT_DAYS = 7 # la finestra recente conta a parte: un guasto in corso non va diluito
|
|
# in 3 mesi di storia sana (media su tutto il campione = guasto invisibile)
|
|
|
|
|
|
def _pull_db(dest: Path) -> Path:
|
|
"""Copia il DB dal container. Il volume docker non e' leggibile senza root."""
|
|
cmd = ["docker", "cp", f"{CONTAINER}:{DB_IN_CONTAINER}", str(dest)]
|
|
subprocess.run(cmd, check=True, capture_output=True)
|
|
return dest
|
|
|
|
|
|
def read_chain(db: Path) -> pd.DataFrame:
|
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
try:
|
|
df = pd.read_sql_query(
|
|
"""select timestamp, asset, instrument_name, strike, expiry, option_type,
|
|
bid, ask, mid, iv, delta, gamma, theta, vega,
|
|
open_interest, volume_24h, book_depth_top3, source
|
|
from option_chain_snapshots""",
|
|
con,
|
|
)
|
|
finally:
|
|
con.close()
|
|
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"):
|
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
# formati ISO MISTI nella stessa colonna (con e senza microsecondi): senza `format` pandas
|
|
# ne inferisce uno solo e manda gli altri a NaT, che il dropna sotto toglierebbe in SILENZIO.
|
|
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601", errors="coerce")
|
|
df["exp"] = pd.to_datetime(df["expiry"], utc=True, format="ISO8601", errors="coerce")
|
|
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
|
|
n0 = len(df)
|
|
df = df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"])
|
|
if len(df) != n0:
|
|
print(f" ATTENZIONE: {n0 - len(df)} righe scartate (timestamp illeggibile) su {n0}")
|
|
return df
|
|
|
|
|
|
# ------------------------------------------------------------------ certificazione
|
|
|
|
def hollow_rate(df: pd.DataFrame) -> float:
|
|
"""Frazione di righe presenti ma SENZA quota. Non e' 'dati mancanti': la riga c'e'."""
|
|
if df.empty:
|
|
return float("nan")
|
|
return float(df["bid"].isna().mean())
|
|
|
|
|
|
def worst_day_hollow(df: pd.DataFrame, days: int) -> tuple[float, object]:
|
|
"""Tasso di quote vuote del GIORNO PEGGIORE nella finestra recente.
|
|
|
|
La media sulla finestra non basta: un guasto iniziato 2 giorni fa, mediato su 7, si legge
|
|
13% invece del 50% reale — la stessa diluizione che questo modulo dichiara di voler evitare
|
|
quando confronta 'ultimi 7g' con 'tutto'. Un guasto IN CORSO si misura al suo giorno peggiore.
|
|
"""
|
|
if df.empty:
|
|
return float("nan"), None
|
|
recent = df[df["ts"] >= df["ts"].max() - pd.Timedelta(days=days)]
|
|
if recent.empty:
|
|
return float("nan"), None
|
|
per_day = recent.assign(d=recent["ts"].dt.date).groupby("d")["bid"].apply(lambda s: s.isna().mean())
|
|
return float(per_day.max()), per_day.idxmax()
|
|
|
|
|
|
def crossed_rate(df: pd.DataFrame) -> float:
|
|
q = df.dropna(subset=["bid", "ask"])
|
|
if q.empty:
|
|
return float("nan")
|
|
return float((q["bid"] > q["ask"]).mean())
|
|
|
|
|
|
def monotonicity_violations(df: pd.DataFrame) -> float:
|
|
"""Frazione di coppie strike-adiacenti in cui il premio put DIMINUISCE al salire dello strike.
|
|
|
|
Su un singolo (istante, scadenza) il premio di una put e' non-decrescente nello strike: e' una
|
|
proprieta' del payoff, non del modello -> una violazione e' un difetto di dato, non un regime.
|
|
"""
|
|
puts = df[(df["option_type"] == "P")].dropna(subset=["mid"])
|
|
if puts.empty:
|
|
return float("nan")
|
|
bad = tot = 0
|
|
for _, g in puts.groupby(["ts", "asset", "exp"], sort=False):
|
|
if len(g) < 2:
|
|
continue
|
|
m = g.sort_values("strike")["mid"].to_numpy()
|
|
d = m[1:] - m[:-1]
|
|
bad += int((d < 0).sum())
|
|
tot += len(d)
|
|
return float(bad / tot) if tot else float("nan")
|
|
|
|
|
|
def certify(df: pd.DataFrame) -> list[dict]:
|
|
"""Un verdetto per asset. La finestra recente e' valutata a parte (vedi RECENT_DAYS)."""
|
|
out = []
|
|
if df.empty:
|
|
return out
|
|
now = df["ts"].max()
|
|
for asset, g in df.groupby("asset"):
|
|
rec = g[g["ts"] >= now - pd.Timedelta(days=RECENT_DAYS)]
|
|
h_all, h_rec = hollow_rate(g), hollow_rate(rec)
|
|
h_day, day_worst = worst_day_hollow(g, RECENT_DAYS)
|
|
worst = max(h_all, h_rec, h_day) # il verdetto segue il giorno peggiore, non la media
|
|
status = "OK" if worst <= HOLLOW_WARN else ("DEGRADATO" if worst <= HOLLOW_FAIL else "QUOTE-VUOTE")
|
|
out.append({
|
|
"asset": asset,
|
|
"righe": len(g),
|
|
"da": g["ts"].min(),
|
|
"a": g["ts"].max(),
|
|
"scadenze": g["exp"].nunique(),
|
|
"vuote_tutto": h_all,
|
|
f"vuote_{RECENT_DAYS}g": h_rec,
|
|
"vuote_giorno_peggiore": h_day,
|
|
"giorno_peggiore": day_worst,
|
|
"incrociate": crossed_rate(g),
|
|
"non_monotone": monotonicity_violations(g),
|
|
"depth_zero": float((g["book_depth_top3"] == 0).mean()),
|
|
"settimanali_4_10dte": int(((g["dte"] >= 4) & (g["dte"] <= 10)).sum()),
|
|
"status": status,
|
|
})
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--db", type=Path, default=None,
|
|
help="legacy: certifica direttamente uno state.sqlite di bite invece dello store")
|
|
args = ap.parse_args()
|
|
|
|
if args.db is not None:
|
|
df = read_chain(args.db)
|
|
titolo = f"catena da {args.db}"
|
|
else:
|
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
|
from cblib import load_chain
|
|
df = load_chain()
|
|
titolo = f"store {STORE}"
|
|
|
|
print("=" * 100)
|
|
print(f" CATENA OPZIONI — certificazione ({titolo})")
|
|
print("=" * 100)
|
|
rep = certify(df)
|
|
for r in rep:
|
|
print(f"\n {r['asset']} {r['righe']:,} righe {r['da']:%Y-%m-%d} -> {r['a']:%Y-%m-%d} "
|
|
f"({r['scadenze']} scadenze, {r['settimanali_4_10dte']:,} righe a 4-10 DTE)")
|
|
print(f" quote VUOTE (riga presente, bid/ask NULL): tutto {100*r['vuote_tutto']:.1f}% "
|
|
f"| ultimi {RECENT_DAYS}g {100*r[f'vuote_{RECENT_DAYS}g']:.1f}% "
|
|
f"| GIORNO PEGGIORE {100*r['vuote_giorno_peggiore']:.1f}% ({r['giorno_peggiore']})")
|
|
print(f" book incrociato {100*r['incrociate']:.2f}% | premio non monotono nello strike "
|
|
f"{100*r['non_monotone']:.2f}% | depth==0 {100*r['depth_zero']:.1f}% (ambiguo solo "
|
|
f"nell'archivio bite)")
|
|
print(f" STATUS: {r['status']}")
|
|
|
|
if "quote_status" in df.columns:
|
|
print("\n stato dichiarato della quota (solo le righe raccolte da noi lo hanno):")
|
|
for k, v in df["quote_status"].value_counts().items():
|
|
print(f" {k:9s} {v:>9,}")
|
|
if any(r["status"] != "OK" for r in rep):
|
|
print("\n ATTENZIONE: almeno un asset non e' OK — le quote vuote NON sono righe mancanti,")
|
|
print(" il conteggio righe resta identico. Ogni misura su questa finestra va pesata.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|