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())
+189
View File
@@ -0,0 +1,189 @@
"""CBLIB — harness della catena opzioni REALE (cerbero-bite), come altlib/eqlib per gli altri lati.
Legge la cache su disco `data/raw/cb_chain.parquet` (scritta una volta da
`scripts/analysis/fetch_cb_chain.py`), MAI dal container.
Serve a rispondere a una domanda che il progetto pone dal 19/06 e non aveva mai potuto misurare:
il premio che VRP01 incassa nel backtest e' quello che il mercato paga? Il sleeve prezza ENTRAMBE
le gambe con BS su DVOL ATM (`sleeves.VRP_CFG`, f=1.0 applicato al credito NETTO); qui si misura
f = credito reale / credito modellato
sugli STESSI strike, cosi' il rapporto isola l'errore di PREZZO e non la scelta degli strike.
Regola che vale per ogni misura fatta con questo modulo: il f della sola gamba corta NON e' il f
dello spread. Il modello sbaglia soprattutto sull'ala che si COMPRA (piu' OTM, IV piu' alta per
skew, prezzata dal modello a vol ATM) -> misurare una gamba sola da' la risposta sbagliata con
segno rassicurante.
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.stats import norm
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
RAW = ROOT / "data" / "raw"
CHAIN = RAW / "cb_chain.parquet"
# finestra "settimanale" di VRP01 (tenor_d=7): si accettano 4..10 DTE come in options_vrp_calibrate
DTE_LO, DTE_HI = 4.0, 10.0
SHORT_DELTA, LONG_DELTA = -0.28, -0.10
# ------------------------------------------------------------------ dati
@lru_cache(maxsize=4)
def load_chain() -> pd.DataFrame:
if not CHAIN.exists():
raise FileNotFoundError(
f"{CHAIN} assente — estrai prima con scripts/analysis/fetch_cb_chain.py"
)
df = pd.read_parquet(CHAIN)
df["ts"] = pd.to_datetime(df["ts"], utc=True)
df["exp"] = pd.to_datetime(df["exp"], utc=True)
return df
def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame:
d = load_chain() if df is None else df
return d[(d["asset"] == asset) & (d["option_type"] == "P")]
@lru_cache(maxsize=8)
def spot_series(asset: str) -> pd.Series:
from scripts.analysis.research_lab import load_tf
px = load_tf(asset, "1h")
s = pd.Series(px["close"].values.astype(float),
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns") # le quote bite hanno microsecondi
return s
@lru_cache(maxsize=8)
def dvol_series(asset: str) -> pd.Series:
d = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
s = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns")
return s
# ------------------------------------------------------------------ prezzo
def bs_put(S: float, K: float, T: float, sig: float) -> float:
"""Identica a `sleeves._bs_put` (r=0): il confronto dev'essere col prezzatore del sleeve."""
if T <= 0 or sig <= 0:
return max(K - S, 0.0)
d1 = (np.log(S / K) + 0.5 * sig**2 * T) / (sig * np.sqrt(T))
return K * norm.cdf(-(d1 - sig * np.sqrt(T))) - S * norm.cdf(-d1)
# ------------------------------------------------------------------ struttura
def pick_legs(snap: pd.DataFrame, short_delta: float = SHORT_DELTA,
long_delta: float = LONG_DELTA) -> dict | None:
"""Dalle put di UNO snapshot+scadenza: gamba corta ~short_delta e lunga ~long_delta.
Ritorna None se non e' costruibile un credit spread: serve che ENTRAMBE siano quotate
(bid>0, ask>0, non incrociate) e che lo strike corto sia sopra quello lungo. Una gamba sola
quotata non e' "meta' struttura": e' un'altra strategia, e va scartata.
"""
ok = snap.dropna(subset=["bid", "ask", "delta"])
ok = ok[(ok["bid"] > 0) & (ok["ask"] > 0) & (ok["ask"] >= ok["bid"])]
if len(ok) < 2:
return None
s = ok.iloc[[int((ok["delta"] - short_delta).abs().to_numpy().argmin())]].iloc[0]
l = ok.iloc[[int((ok["delta"] - long_delta).abs().to_numpy().argmin())]].iloc[0]
if s["strike"] <= l["strike"]:
return None
return {
"inst_short": s["instrument_name"], "inst_long": l["instrument_name"],
"k_short": float(s["strike"]), "k_long": float(l["strike"]),
"d_short": float(s["delta"]), "d_long": float(l["delta"]),
"iv_short": float(s["iv"]) if pd.notna(s["iv"]) else np.nan,
"iv_long": float(l["iv"]) if pd.notna(l["iv"]) else np.nan,
"bid_short": float(s["bid"]), "ask_short": float(s["ask"]),
"bid_long": float(l["bid"]), "ask_long": float(l["ask"]),
"mid_short": float(s["mid"]) if pd.notna(s["mid"]) else np.nan,
"mid_long": float(l["mid"]) if pd.notna(l["mid"]) else np.nan,
}
def f_factors(legs: dict, spot: float, dvol_frac: float, dte_days: float) -> dict:
"""f = reale/modellato per gamba e per credito NETTO, agli STESSI strike.
Convenzione di fill CONSERVATIVA: si vende al bid e si compra all'ask (si attraversa lo
spread in entrambe le direzioni). Il `_mid` e' la stessa cosa a meta' spread.
I premi Deribit sono quotati nel sottostante -> moltiplicati per lo spot passano a USD.
"""
T = dte_days / 365.25
mod_s = bs_put(spot, legs["k_short"], T, dvol_frac)
mod_l = bs_put(spot, legs["k_long"], T, dvol_frac)
cred_mod = mod_s - mod_l
cred_real = (legs["bid_short"] - legs["ask_long"]) * spot
cred_mid = (legs["mid_short"] - legs["mid_long"]) * spot
return {
"mod_short": mod_s, "mod_long": mod_l, "cred_mod": cred_mod,
"cred_real": cred_real, "cred_mid": cred_mid,
"f_short": legs["bid_short"] * spot / mod_s if mod_s > 0 else np.nan,
"f_long": legs["ask_long"] * spot / mod_l if mod_l > 0 else np.nan,
"f_net": cred_real / cred_mod if cred_mod > 0 else np.nan,
"f_net_mid": cred_mid / cred_mod if cred_mod > 0 else np.nan,
"width": legs["k_short"] - legs["k_long"],
}
def weekly_entries(asset: str, df: pd.DataFrame | None = None, target_dte: float = 7.0) -> pd.DataFrame:
"""Un ingresso per scadenza: lo snapshot con DTE piu' vicino a `target_dte` in cui ENTRAMBE
le gambe sono quotate. Ritorna anche f_* e il contesto (spot, DVOL, IV-rank causale)."""
p = puts(asset, df)
wk = p[(p["dte"] >= DTE_LO) & (p["dte"] <= DTE_HI)]
S, V = spot_series(asset), dvol_series(asset)
rows = []
for exp, g in wk.groupby("exp"):
cand = sorted(g["ts"].unique(),
key=lambda t: abs((exp - pd.Timestamp(t)).total_seconds() / 86400.0 - target_dte))
for ts in cand:
legs = pick_legs(g[g["ts"] == ts])
if legs is None:
continue
t = pd.Timestamp(ts).as_unit("ns")
dte = (exp - pd.Timestamp(ts)).total_seconds() / 86400.0
spot = float(S.asof(t))
dvol = float(V.asof(t))
hist = V[V.index < t]
ivr = float((hist < dvol).mean()) if len(hist) else np.nan
rows.append({"asset": asset, "ts": t, "exp": exp, "dte": dte,
"spot": spot, "dvol_pct": dvol, "ivrank": ivr,
**legs, **f_factors(legs, spot, dvol / 100.0, dte)})
break
return pd.DataFrame(rows).sort_values("ts").reset_index(drop=True)
def close_cost_path(entry: pd.Series, df: pd.DataFrame | None = None) -> pd.DataFrame:
"""Costo di CHIUDERE lo spread, ora per ora, dalle standing quotes delle stesse due gambe.
Chiudere = ricomprare la corta all'ask e rivendere la lunga al bid (di nuovo conservativo).
E' cio' che il backtest modellato non ha: tiene a scadenza, quindi non vede ne' il
50%-profit-take ne' il drawdown dentro la settimana.
"""
d = load_chain() if df is None else df
seg = d[(d["asset"] == entry["asset"]) & (d["ts"] > entry["ts"]) & (d["ts"] <= entry["exp"])
& (d["instrument_name"].isin([entry["inst_short"], entry["inst_long"]]))]
seg = seg.dropna(subset=["bid", "ask"])
S = spot_series(entry["asset"])
out = []
for ts2, g2 in seg.groupby("ts"):
s2 = g2[g2["instrument_name"] == entry["inst_short"]]
l2 = g2[g2["instrument_name"] == entry["inst_long"]]
if s2.empty or l2.empty:
continue # una gamba sola non prezza lo spread
sp2 = float(S.asof(pd.Timestamp(ts2).as_unit("ns")))
costo = (float(s2["ask"].iloc[0]) - float(l2["bid"].iloc[0])) * sp2
out.append({"ts": ts2, "costo_chiusura": costo,
"pnl_aperto": entry["cred_real"] - costo})
return pd.DataFrame(out)
+176
View File
@@ -0,0 +1,176 @@
"""VRP01 CONTRO LE QUOTE REALI — il f del credito NETTO, misurato invece che assunto (2026-07-30).
Il sleeve VRP01 (`src/portfolio/sleeves.VRP_CFG`) usa **f=1.0** applicato al credito NETTO:
net_prem = (bs(Ks) - bs(Kl)) * f
cioe' assume che il mercato paghi esattamente cio' che BS-su-DVOL-ATM dice, per ENTRAMBE le gambe.
Il caveat di ammissione ("premio MODELLATO su DVOL ATM, skew non esplicito") era dichiarato ma mai
quantificato, per mancanza di quote vere. cerbero-bite le accumula dal 2026-06-09 (catena piena,
entrambe le ali, orarie) -> qui si misura.
Quattro domande, nell'ordine in cui contano:
D1 COPERTURA quante settimane hanno ENTRAMBE le gambe (-0.28/-0.10) sulla stessa scadenza?
D2 f REALE credito reale / credito modellato, agli STESSI strike (isola il prezzo, non lo strike)
D3 GESTIONE le stesse gambe sono riquotate fino a scadenza? (50%-profit-take e DD infra-settimana)
D4 REGIME la finestra contiene il regime in cui VRP01 TRADA davvero (gate IV-rank>0.30)?
uv run python scripts/research/r0730_vrp_real_quotes.py
uv run python scripts/research/r0730_vrp_real_quotes.py --book # + delta sul book (lento)
"""
from __future__ import annotations
import argparse
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 cblib import ( # noqa: E402
DTE_HI, DTE_LO, close_cost_path, dvol_series, load_chain, puts, weekly_entries,
)
ASSETS = ("ETH", "BTC")
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
GATE_IVR = 0.30 # il gate di VRP01: sotto questa soglia il sleeve sta FLAT
FEE_FRAC = 0.125 # stessa fee del modello (cap Deribit, worst-case)
def _met(r: pd.Series) -> tuple[float, float, float]:
r = r.dropna()
if len(r) < 10 or r.std() == 0:
return float("nan"), float("nan"), float("nan")
sh = float(r.mean() / r.std() * np.sqrt(365.25))
eq = (1 + r).cumprod()
return sh, float((eq / eq.cummax() - 1).min()), float(eq.iloc[-1] ** (365.25 / len(r)) - 1)
def boot_ci_median(x: np.ndarray, n: int = 20000, seed: int = 7) -> tuple[float, float]:
rng = np.random.default_rng(seed)
med = [np.median(rng.choice(x, size=len(x), replace=True)) for _ in range(n)]
return float(np.percentile(med, 2.5)), float(np.percentile(med, 97.5))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--book", action="store_true", help="ricalcola anche il book a 5 sleeve (lento)")
args = ap.parse_args()
chain = load_chain()
E = pd.concat([weekly_entries(a, chain) for a in ASSETS], ignore_index=True)
print("=" * 100)
print(" VRP01 CONTRO LE QUOTE REALI (cerbero-bite mainnet)")
print("=" * 100)
# ---------------- D1 ----------------
print("\n [D1] COPERTURA")
for a in ASSETS:
p = puts(a, chain)
wk = p[(p["dte"] >= DTE_LO) & (p["dte"] <= DTE_HI)]
n = int((E["asset"] == a).sum())
print(f" {a}: {len(p):,} righe put ({p['ts'].min():%Y-%m-%d}->{p['ts'].max():%Y-%m-%d}), "
f"{100*(1-p['bid'].isna().mean()):.1f}% quotate | finestra 4-10 DTE: {wk['exp'].nunique()} "
f"scadenze -> {n} ingressi con ENTRAMBE le gambe")
# ---------------- D2 ----------------
ok = E[E["cred_real"] > 0].copy() # credito negativo = struttura non eseguibile a credito
scart = len(E) - len(ok)
lo, hi = boot_ci_median(ok["f_net"].to_numpy())
nota_scarti = f", {scart} scartati per credito negativo" if scart else ""
print(f"\n [D2] f = credito reale / credito modellato (N={len(ok)}{nota_scarti})")
print(f" {'grandezza':44s} {'mediana':>9s} {'p25':>8s} {'p75':>8s}")
for lab, col in (("f gamba CORTA (venduta al bid)", "f_short"),
("f gamba LUNGA (comprata all'ask)", "f_long"),
("f CREDITO NETTO <- quello che entra nel book", "f_net"),
("f credito netto, a mid", "f_net_mid")):
v = ok[col].replace([np.inf, -np.inf], np.nan).dropna()
print(f" {lab:44s} {v.median():9.2f} {v.quantile(.25):8.2f} {v.quantile(.75):8.2f}")
print(f" f netto: IC95% bootstrap [{lo:.3f}, {hi:.3f}] "
f"osservazioni >= 1.0: {int((ok['f_net'] >= 1).sum())}/{len(ok)}")
ok["skew_short"] = ok["iv_short"] - ok["dvol_pct"]
ok["skew_long"] = ok["iv_long"] - ok["dvol_pct"]
print(f"\n MECCANISMO (senza il quale il numero sopra e' un artefatto):")
print(f" IV(corta) - DVOL = {ok['skew_short'].median():+.1f} pp "
f"IV(lunga) - DVOL = {ok['skew_long'].median():+.1f} pp")
print(f" -> il modello prezza entrambe le gambe a vol ATM; l'ala che si COMPRA sta "
f"{ok['skew_long'].median():.0f} pp sopra")
print(f" quindi costa ~{ok['f_long'].median():.1f}x il modello e il credito netto si comprime.")
print(f" delta realizzati: corta {ok['d_short'].median():.3f} (target -0.280), "
f"lunga {ok['d_long'].median():.3f} (target -0.100) -> non e' un artefatto di strike")
# ---------------- D3 ----------------
print(f"\n [D3] GESTIONE — path a quote reali (il modello tiene a scadenza e non lo vede)")
marks, tocca50, worst = [], 0, []
for _, e in ok.iterrows():
P = close_cost_path(e, chain)
marks.append(len(P))
if P.empty:
continue
if (P["pnl_aperto"] >= 0.5 * e["cred_real"]).any():
tocca50 += 1
worst.append(P["pnl_aperto"].min() / e["width"])
marks = np.array(marks)
print(f" riquotazioni con ENTRAMBE le gambe: mediana {np.median(marks):.0f} per trade "
f"(min {marks.min()}, max {marks.max()})")
print(f" settimane in cui il 50%-profit-take sarebbe scattato: {tocca50}/{len(ok)}")
if worst:
w = np.array(worst)
# N < len(ok): gli ingressi piu' recenti non hanno ancora un path (trade in corso)
print(f" peggior mark INFRA-settimana (% del capitale=width), su N={len(w)} trade con "
f"path: mediana {100*np.median(w):.1f}%, minimo {100*w.min():.1f}%")
print(f" -> il sleeve marca solo a scadenza: quel minimo non esiste nei suoi numeri")
# ---------------- D4 ----------------
print(f"\n [D4] REGIME — la finestra contiene i trade che VRP01 farebbe?")
for a in ASSETS:
V = dvol_series(a)
g = E[E["asset"] == a]
win = V[(V.index >= g["ts"].min()) & (V.index <= g["ts"].max())]
pas = int((g["ivrank"] > GATE_IVR).sum())
print(f" {a}: DVOL finestra [{win.min():.1f}, {win.max():.1f}] vs storia "
f"[{V.min():.1f}, {V.max():.1f}] — il MAX raccolto sta al {100*(V < win.max()).mean():.0f}° pctl")
print(f" IV-rank mediano {g['ivrank'].median():.2f} — settimane che passano il gate "
f">{GATE_IVR}: {pas}/{len(g)}")
print(f" -> il f e' misurato nel regime in cui il sleeve sta FLAT. Lo skew e' strutturale")
print(f" (in stress si irripidisce), ma la TAGLIA in stress resta non misurata.")
# ---------------- conseguenza sul modello ----------------
from src.portfolio import sleeves as SL
print(f"\n CONSEGUENZA — VRP01 standalone sostituendo SOLO f (f=1.0 = cio' che il book usa oggi)")
print(f" {'f':>6s} {'Sh FULL':>9s} {'DD FULL':>9s} {'CAGR':>8s} {'Sh HOLD-OUT':>12s}")
f_mis = float(ok["f_net"].median())
f_mid = float(ok["f_net_mid"].median())
for f in (1.00, round(f_mid, 2), round(f_mis, 2)):
SL.VRP_CFG["f"] = f
s = SL._vrp_combo_returns()
shF, ddF, cg = _met(s)
shH, _, _ = _met(s[s.index >= HOLDOUT])
tag = " <- assunto" if f == 1.0 else (" <- misurato a mid" if f == round(f_mid, 2)
else " <- misurato, fill conservativo")
print(f" {f:6.2f} {shF:9.2f} {100*ddF:8.1f}% {100*cg:7.1f}% {shH:12.2f}{tag}")
SL.VRP_CFG["f"] = 1.0
if args.book:
from src.portfolio.portfolio import StrategyPortfolio
print(f"\n BOOK a 5 sleeve (VRP01 al 12%) — livelli ancora-canonici, conta il DELTA")
base = None
for f in (1.00, round(f_mis, 2)):
SL.VRP_CFG["f"] = f
r = StrategyPortfolio(SL.active_sleeves()).combined_daily()
shF, ddF, _ = _met(r)
shH, _, _ = _met(r[r.index >= HOLDOUT])
if base is None:
base = (shF, shH)
d = "" if f == 1.0 else f"{shF-base[0]:+.3f} FULL / {shH-base[1]:+.3f} HOLD)"
print(f" f={f:.2f} FULL {shF:.3f} HOLD {shH:.3f} DD {100*ddF:.2f}%{d}")
SL.VRP_CFG["f"] = 1.0
return 0
if __name__ == "__main__":
raise SystemExit(main())