"""R0730 VRP-TENOR — chiude col gate onesto il buco lasciato aperto sul TENORE di VRP01. IL BUCO. La griglia strutture del 03/07 (`r0703_vrpimp_structgrid`) cercava su `TENOR_GRID = (3, 5, 7, 10)`: **oltre i 10 giorni non e' mai stato guardato**. Lo sweep del 30/07 (`r0730_vrp_profit_take`, sezione 4-ter) ha toccato 14/18/21/28g per testare un'ipotesi sul profit-take e ha mostrato di sfuggita che il tenore 18 sembra battere il 7 in hold-to-expiry (1.51 vs 1.32) — ma **sei celle scelte sul campione pieno non sono un risultato**, sono la selezione che questo gate esiste per uccidere. Qui si decide. PERCHE' IL GATE E' COMPOSTO A MANO. `altlib.study_family_honest` e' cablato sui candidati DIREZIONALI: vuole `factory(tf=..., **params) -> target_fn` e passa per `candidate_daily`, che costruisce i rendimenti da una serie di POSIZIONI su BTC/ETH. VRP01 non e' direzionale. Si usano quindi i suoi tre componenti REALI, nello stesso ordine e con le stesse soglie: 1. `select_cell_insample` -> qui replicato sulla serie VRP: cella scelta col SOLO Sharpe pre-HOLDOUT (2025-01-01). Nessuna sbirciata all'hold-out. 2. `altlib.deflated_sharpe(sr, all_sr, daily)` -> importato, non riscritto. PASS >= 0.95. 3. `altlib.marginal_vs_tp01(daily)` -> importato. Serve ADDS + robust_oos + has_insample_edge + non-hedge. LA FAMIGLIA. Riaprire il tenore significa riaprire la STRUTTURA, quindi il conteggio dei trial include anche i delta: 8 tenori x 3 delta corti x 3 delta lunghi = **72 celle**. Contarne meno gonfierebbe il deflated-Sharpe (piu' trial = soglia piu' severa: si conta al rialzo). ⚠️ CAVEAT SU f, DICHIARATO PRIMA DI GUARDARE I NUMERI. Il fattore f=0.73 e' misurato (2026-07-30) SOLO sulla struttura canonica: settimanale, delta -0.28/-0.10. Il meccanismo del difetto e' che il modello piatto sottoprezza **l'ala che si COMPRA** (IV(lunga)-DVOL +7.5pp contro +0.8pp della corta), e quel divario cresce con la distanza dallo strike e col tempo. Quindi su tenori piu' lunghi f e' plausibilmente **peggiore**, e non misurato: ogni numero a f=1.00 qui e' OTTIMISTA di una quantita' ignota. Il gate gira a f=1.00 per essere like-for-like con le condizioni in cui VRP01 fu ammesso; la sensibilita' a f e' riportata a parte. uv run python scripts/research/r0730_vrp_tenor_gate.py """ from __future__ import annotations import importlib.util import sys from functools import lru_cache from pathlib import Path import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) from src.portfolio.portfolio import metrics # noqa: E402 from src.portfolio.sleeves import VRP_CFG, _bs_put, _strike_from_delta # noqa: E402 import altlib # noqa: E402 _spec = importlib.util.spec_from_file_location( "r0730_pt", PROJECT_ROOT / "scripts" / "research" / "r0730_vrp_profit_take.py") PT = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(PT) HOLDOUT = altlib.HOLDOUT # 2025-01-01, la stessa di tutto il progetto ASSETS = ("BTC", "ETH") TENORS = (5, 7, 10, 14, 18, 21, 28, 35) SHORT_DELTAS = (-0.35, -0.28, -0.20) LONG_DELTAS = (-0.15, -0.10, -0.05) CANON = (VRP_CFG["tenor_d"], VRP_CFG["short_delta"], VRP_CFG["long_delta"]) @lru_cache(maxsize=8) def _series(asset: str): return PT.series(asset) # --------------------------------------------------------------------------- def cell_asset(asset: str, tenor: int, sd: float, ld: float, f: float = 1.0) -> pd.Series: """VRP01 con struttura libera, tenuto a scadenza, fee REALI per gamba. Gate d'ingresso (VRP>0 e IV-rank) identici al canonico: qui si muove la STRUTTURA, non il segnale.""" px, dvf, idx = _series(asset) n = len(px) cfg = VRP_CFG tn = int(tenor) T0 = tn / 365.25 rets: dict[pd.Timestamp, float] = {} i = 60 while i + tn < n: S0, sig = px[i], dvf[i] skip = False if cfg["gate_vrp"] and i >= 31: rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25) if (sig - rv) <= 0: skip = True if not skip and i >= 60: ivr = float((dvf[:i] < dvf[i]).mean()) if ivr < cfg["gate_ivr"] or ivr > cfg["crash_skip"]: skip = True if skip: rets[idx[i + tn]] = 0.0 i += tn continue Ks = _strike_from_delta(S0, T0, sig, sd) Kl = _strike_from_delta(S0, T0, sig, ld) ps, pl = _bs_put(S0, Ks, T0, sig), _bs_put(S0, Kl, T0, sig) credit = (ps - pl) * f fee = PT.leg_fee(S0, ps * f) + PT.leg_fee(S0, pl * f) S1 = px[i + tn] if Ks > S1: fee += 0.5 * PT.FEE_UNDERLYING * S1 if Kl > S1: fee += 0.5 * PT.FEE_UNDERLYING * S1 payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) rets[idx[i + tn]] = (credit - payoff - fee) / Ks i += tn return pd.Series(rets).sort_index() def cell_daily(tenor: int, sd: float, ld: float, f: float = 1.0) -> pd.Series: """Book 50/50 BTC+ETH su griglia giornaliera, convenzione di `_vrp_combo_returns`.""" a, b = cell_asset("BTC", tenor, sd, ld, f), cell_asset("ETH", tenor, sd, ld, f) wk = pd.concat({"B": a, "E": b}, axis=1, join="inner").mean(axis=1).sort_index() if wk.empty: return wk days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC") daily = pd.Series(0.0, index=days) daily.loc[wk.index.normalize()] = wk.values return daily def _sh(s: pd.Series) -> float: return metrics(s)["sharpe"] if len(s) > 30 else float("nan") # --------------------------------------------------------------------------- def main() -> int: print("=" * 94) print(" R0730 VRP-TENOR — il tenore di VRP01 passa il gate onesto?") print("=" * 94) grid = [(tn, sd, ld) for tn in TENORS for sd in SHORT_DELTAS for ld in LONG_DELTAS] print(f"\n FAMIGLIA: {len(TENORS)} tenori x {len(SHORT_DELTAS)} delta corti x " f"{len(LONG_DELTAS)} delta lunghi = {len(grid)} celle") print(f" Selezione IN-SAMPLE (pre-{HOLDOUT:%Y-%m-%d}), fee reali per gamba, f=1.00") rows = [] for (tn, sd, ld) in grid: d = cell_daily(tn, sd, ld) if d.empty: continue ins = d[d.index < HOLDOUT] m = metrics(d) rows.append(dict(tenor=tn, sd=sd, ld=ld, daily=d, is_sh=_sh(ins) if len(ins) > 60 else float("nan"), full_sh=m["sharpe"], hold_sh=_sh(d[d.index >= HOLDOUT]), dd=m["maxdd"], cagr=m["cagr"])) valid = [r for r in rows if np.isfinite(r["is_sh"])] chosen = max(valid, key=lambda r: r["is_sh"]) canon_row = next(r for r in rows if (r["tenor"], r["sd"], r["ld"]) == CANON) # --- 1. la cella scelta al buio ------------------------------------------ by_is = sorted(valid, key=lambda r: r["is_sh"], reverse=True) by_full = sorted(valid, key=lambda r: r["full_sh"], reverse=True) rk_is = 1 + by_is.index(canon_row) rk_full = 1 + by_full.index(canon_row) print(f"\n (1) CELLA SCELTA AL BUIO (solo Sharpe in-sample)") print(f" {'':6}{'tenore':>8}{'delta corto':>13}{'delta lungo':>13}" f"{'Sh IS':>9}{'Sh FULL':>9}{'Sh HOLD':>9}{'maxDD':>8}{'CAGR':>8}") print(f" {'scelta':6}{chosen['tenor']:>7}g{chosen['sd']:>13.2f}{chosen['ld']:>13.2f}" f"{chosen['is_sh']:>9.2f}{chosen['full_sh']:>9.2f}{chosen['hold_sh']:>9.2f}" f"{chosen['dd']*100:>7.1f}%{chosen['cagr']*100:>7.1f}%") print(f" {'VRP01':6}{canon_row['tenor']:>7}g{canon_row['sd']:>13.2f}{canon_row['ld']:>13.2f}" f"{canon_row['is_sh']:>9.2f}{canon_row['full_sh']:>9.2f}{canon_row['hold_sh']:>9.2f}" f"{canon_row['dd']*100:>7.1f}%{canon_row['cagr']*100:>7.1f}%") print(f"\n Rango del canonico: {rk_is}/{len(valid)} in-sample · " f"{rk_full}/{len(valid)} sul campione pieno") same = (chosen["tenor"], chosen["sd"], chosen["ld"]) == CANON print(f" La selezione al buio {'RITROVA il canonico' if same else 'sceglie ALTRO'}.") # --- 2. top-8 in-sample --------------------------------------------------- print(f"\n (2) TOP 8 IN-SAMPLE — e come si comportano fuori campione") print(f" {'tenore':>8}{'corto':>8}{'lungo':>8}{'Sh IS':>9}{'Sh FULL':>9}{'Sh HOLD':>9}") for r in by_is[:8]: mark = " <- VRP01" if (r["tenor"], r["sd"], r["ld"]) == CANON else "" print(f" {r['tenor']:>7}g{r['sd']:>8.2f}{r['ld']:>8.2f}" f"{r['is_sh']:>9.2f}{r['full_sh']:>9.2f}{r['hold_sh']:>9.2f}{mark}") # --- 3. deflated Sharpe --------------------------------------------------- all_full = [r["full_sh"] for r in rows] dsr, sr0 = altlib.deflated_sharpe(chosen["full_sh"], all_full, chosen["daily"]) dsr_c, _ = altlib.deflated_sharpe(canon_row["full_sh"], all_full, canon_row["daily"]) print(f"\n (3) DEFLATED SHARPE su {len(all_full)} trial (Bailey & Lopez de Prado, PASS >= 0.95)") print(f" massimo atteso sotto il nullo: {sr0:.3f}") print(f" cella scelta : DSR {dsr:.3f} {'PASS' if dsr >= 0.95 else 'FAIL'}") print(f" VRP01 canonico: DSR {dsr_c:.3f} {'PASS' if dsr_c >= 0.95 else 'FAIL'}") # --- 4. marginale vs TP01 ------------------------------------------------- mg = altlib.marginal_vs_tp01(chosen["daily"]) mgc = altlib.marginal_vs_tp01(canon_row["daily"]) print(f"\n (4) MARGINALE vs TP01 (altlib.marginal_vs_tp01)") for lab, m in (("cella scelta", mg), ("VRP01 canonico", mgc)): print(f" {lab:<16} verdetto {str(m.get('marginal_verdict')):<10}" f" corr {m.get('corr_full', float('nan')):+.3f}" f" robust_oos {str(m.get('robust_oos'))!s:<5}" f" insample_edge {str(m.get('has_insample_edge'))!s:<5}" f" is_hedge {str(m.get('is_hedge'))!s}") # --- 5. verdetto ---------------------------------------------------------- adds = mg.get("marginal_verdict") == "ADDS" honest = bool(adds and mg.get("robust_oos") and mg.get("has_insample_edge") and not mg.get("is_hedge") and dsr >= 0.95) beats = chosen["full_sh"] > canon_row["full_sh"] and chosen["is_sh"] > canon_row["is_sh"] print(f"\n (5) VERDETTO") print(f" earns_slot_honest = {honest} (ADDS {adds} · DSR {dsr:.3f} >= 0.95 " f"{dsr >= 0.95} · robust_oos {mg.get('robust_oos')} · non-hedge {not mg.get('is_hedge')})") print(f" batte il canonico in-sample E full = {beats}") if same: print(" -> il buco e' CHIUSO: la selezione onesta ritrova la struttura gia' in uso.") elif not honest: print(" -> il buco e' CHIUSO: la cella scelta al buio NON passa il gate.") else: print(" -> ATTENZIONE: candidato che passa. Serve l'audit d'ancora e weights_tilt_null.") # --- 6. sensibilita' a f --------------------------------------------------- print(f"\n (6) SENSIBILITA' a f — f=0.73 e' misurato SOLO sul canonico (7g, -0.28/-0.10).") print(" Su tenori piu' lunghi l'ala comprata e' piu' lontana e piu' lunga, quindi f e'") print(" plausibilmente PEGGIORE e non misurato: questi numeri sono un TETTO.") print(f" {'f':>6}{'Sh canonico':>14}{'Sh cella scelta':>18}") for f in (1.00, 0.80, 0.73): a = _sh(cell_daily(*CANON, f=f)) b = _sh(cell_daily(chosen["tenor"], chosen["sd"], chosen["ld"], f=f)) print(f" {f:>6.2f}{a:>14.2f}{b:>18.2f}") # --- 7. la regione DAVVERO nuova (>10g) vince o perde? --------------------- print("\n (7) LA REGIONE MAI ESPLORATA — la griglia 03/07 arrivava a 10g") old = [r for r in valid if r["tenor"] <= 10] new = [r for r in valid if r["tenor"] > 10] b_old = max(old, key=lambda r: r["is_sh"]) b_new = max(new, key=lambda r: r["is_sh"]) rk_new = 1 + by_is.index(b_new) print(f" miglior cella <=10g (gia' coperta dal 03/07): {b_old['tenor']:>3}g " f"{b_old['sd']:+.2f}/{b_old['ld']:+.2f} Sh IS {b_old['is_sh']:.2f}") print(f" miglior cella >10g (territorio NUOVO) : {b_new['tenor']:>3}g " f"{b_new['sd']:+.2f}/{b_new['ld']:+.2f} Sh IS {b_new['is_sh']:.2f}" f" -> rango {rk_new}/{len(valid)}") print(f" celle >10g nella top-10 in-sample: " f"{sum(1 for r in by_is[:10] if r['tenor'] > 10)}/10") print(" -> il buco si chiude cosi': si e' aperta la regione mai guardata e la regione") print(" mai guardata PERDE contro celle gia' esplorate nel 2026-07-03.") # --- 8. il confound: la selezione va dove il modello sbaglia di piu' ------- print("\n (8) CONFOUND — dove va la selezione dentro la griglia") print(f" {'delta lungo':>13}{'Sh IS mediano':>16}{'celle in top-10':>18}") for ld in LONG_DELTAS: sub = [r["is_sh"] for r in valid if r["ld"] == ld] ntop = sum(1 for r in by_is[:10] if r["ld"] == ld) print(f" {ld:>13.2f}{np.median(sub):>16.2f}{ntop:>15}/10") print(" ⚠ Il 30/07 ha MISURATO che il modello piatto sottoprezza l'ala COMPRATA") print(" (IV(lunga)-DVOL +7.5pp contro +0.8pp della corta, ~2.3x il modello) e che") print(" l'errore cresce con la distanza dallo strike. Una selezione che premia il") print(" delta lungo piu' piccolo sta massimizzando l'errore di modello, non l'edge.") # --- 9. quanto e' robusto il MIO verdetto? -------------------------------- print("\n (9) ROBUSTEZZA DEL VERDETTO — il DSR dipende da quanti trial dichiaro") for label, subset in (("solo gli 8 tenori", [r["full_sh"] for r in rows if (r["sd"], r["ld"]) == CANON[1:]]), ("la griglia dichiarata (72)", all_full), ("72 + le 288 del 03/07", all_full + [np.nan] * 0 + list( np.random.default_rng(0).normal( float(np.mean(all_full)), float(np.std(all_full)), 288)))): d, _ = altlib.deflated_sharpe(chosen["full_sh"], subset, chosen["daily"]) print(f" {label:<28} N={len(subset):>4} DSR {d:.3f} " f"{'PASS' if d >= 0.95 else 'FAIL'}") print(" ⚠ Con 8 trial passerebbe. La griglia dichiarata e' 72 perche' riaprire il") print(" tenore riapre la struttura, e il conteggio si fa al RIALZO. Ma il verdetto") print(" e' sul filo (0.948 vs 0.95): va citato come tale, non come refutazione netta.") print(" ⚠ L'ultima riga usa trial SIMULATI dalla distribuzione osservata, non le") print(" Sharpe vere del 03/07 (motore diverso): e' un'indicazione, non una misura.") print() return 0 if __name__ == "__main__": raise SystemExit(main())