Files
PythagorasGoal/scripts/analysis/certify_cb_chain.py
T
Adriano Dal Pastro d55eb13533 feat(chain): assorbita la raccolta catena opzioni, cerbero-bite dismesso
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>
2026-07-30 20:14:34 +00:00

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())