research(vrp): il f del credito netto e' 0.73 sulle quote reali, non 1.0

Prima integrazione della catena opzioni Deribit mainnet accumulata da
cerbero-bite (/opt/docker/cerbero-bite, dal 2026-06-09: entrambe le ali,
1g-3mesi, oraria, con book_depth). E' l'unica fonte di prezzi opzioni VERI
del progetto, e non e' ricostruibile a posteriori: Deribit non serve book
storici, un'ora non raccolta e' persa.

VRP01 prezza entrambe le gambe con BS su DVOL ATM (VRP_CFG f=1.0 sul credito
NETTO). Misurato agli STESSI strike su 8/8 scadenze settimanali con entrambe
le gambe quotate (delta -0.270/-0.099 contro target -0.280/-0.100):

  f gamba corta   1.02   <- replica la calibrazione del 20/06
  f gamba lunga   2.30   <- l'ala che si COMPRA
  f credito NETTO 0.73   IC95% [0.698, 0.780], 0/15 osservazioni >= 1.0

Meccanismo, non rumore: IV(corta)-DVOL +0.8pp ma IV(lunga)-DVOL +7.5pp -> il
modello prezza a vol ATM anche l'ala comprata. Il difetto non e' nel premio
incassato ma nella protezione comprata, cioe' proprio il "defined-risk" per
cui v2 fu promosso.

Conseguenza standalone (solo f): 1.00 -> FULL 1.08 / HOLD +0.58; 0.80 -> 0.51
/ -0.02; 0.73 -> 0.31 / -0.23. Book 5-sleeve: FULL -0.069, HOLD -0.103, DD
invariato = dentro la banda d'ancora, ma ~meta' del contributo LOO di VRP01
era il prezzo che il modello si faceva da solo.

VRP_CFG["f"] NON cambiato: 15 osservazioni, 7 settimane, e 0/8 passano il
gate IV-rank>0.30 -> il f e' misurato nel regime in cui il sleeve sta FLAT.
Caveat quantificato, non nuovo parametro. Il criterio del 19/06 (rivalutare
quando cerbero-bite cattura un crash) e' intatto.

Book, pesi, cron, config INVARIATI.

Regole nuove congelate nei test:
- il f di una struttura multi-gamba non e' il f di una sua gamba (misurare la
  sola gamba venduta da' la risposta sbagliata con segno rassicurante);
- un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media (la prima
  stesura della certificazione diluiva un guasto di 2 giorni da 51.7% a 13.5%);
- una riga presente non e' un dato presente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-30 19:51:52 +00:00
parent 7d64dd4c2b
commit 8c18e82f1a
6 changed files with 983 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
"""FETCH + CERTIFY della catena opzioni REALE accumulata da cerbero-bite.
Perche' esiste: VRP01 prezza le sue gambe con BS su DVOL ATM (`src/portfolio/sleeves.VRP_CFG`,
f=1.0). L'unico modo per sapere se quel prezzo e' quello del mercato e' confrontarlo con quote
vere; cerbero-bite (/opt/docker/cerbero-bite) accumula la catena Deribit mainnet ora per ora,
entrambe le ali, scadenze 1g..3mesi. Una catena opzioni NON e' ricostruibile a posteriori
(Deribit non serve book storici) -> il dato va estratto e conservato, non richiesto quando serve.
Come `fetch_hyperliquid.py`: estrae, CERTIFICA, e scrive solo se la certificazione ha un esito
dichiarato. Le soglie sono qui sotto e sono decisioni, non default.
CERTIFICAZIONE — quattro difetti, ognuno con la sua diagnostica:
1. QUOTE VUOTE bid/ask NULL con lo strumento presente: il collettore persiste la riga anche
quando il ticker fallisce (rate-limit) -> il CONTEGGIO RIGHE resta identico e qualunque
controllo di copertura basato sul numero di righe dice "tutto bene". E' il difetto che il
29/07 ha portato il tasso da ~0.4% a ~50% senza che nulla lo segnalasse.
2. BOOK INCROCIATO bid > ask (quote incoerenti).
3. MONOTONIA a parita' di scadenza/istante il premio di una put deve essere non-decrescente
nello strike; una violazione e' una quota stantia o corrotta.
4. PROFONDITA' ZERO book_depth_top3 == 0 e' AMBIGUO by design (chiamata fallita e book
davvero vuoto danno lo stesso valore) -> si riporta, non si ripara.
uv run python scripts/analysis/fetch_cb_chain.py # docker cp dal container
uv run python scripts/analysis/fetch_cb_chain.py --db /percorso/state.sqlite
"""
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"
OUT = RAW / "cb_chain.parquet"
CONTAINER = "cerbero-bite-cerbero-bite-1"
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")
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
df["exp"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce")
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"])
# ------------------------------------------------------------------ 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="state.sqlite (default: docker cp)")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
tmp = None
db = args.db
if db is None:
tmp = Path(tempfile.mkdtemp()) / "state.sqlite"
print(f" docker cp {CONTAINER}:{DB_IN_CONTAINER} ...")
db = _pull_db(tmp)
df = read_chain(db)
print("=" * 100)
print(" CATENA OPZIONI cerbero-bite — certificazione")
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 by design)")
print(f" STATUS: {r['status']}")
args.out.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(args.out, index=False)
print(f"\n scritto {args.out} ({len(df):,} righe)")
if any(r["status"] != "OK" for r in rep):
print(" 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.")
if tmp is not None:
tmp.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())