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