Files
Adriano Dal Pastro 36dc55748e research(vrp): buco del tenore chiuso col gate onesto — earns_slot_honest = False
Book, pesi, cron, config INVARIATI.

La griglia strutture del 03/07 si fermava a 10 giorni. Famiglia dichiarata nel
docstring PRIMA di guardare i numeri: 8 tenori (5-35g) x 3 delta corti x 3
lunghi = 72 celle, perche' riaprire il tenore riapre la struttura e i trial si
contano al rialzo.

study_family_honest e' cablato sui candidati direzionali (factory -> target_fn
via candidate_daily) e VRP01 non lo e': usati i suoi tre componenti reali —
selezione in-sample-only, altlib.deflated_sharpe, altlib.marginal_vs_tp01 —
importati e non riscritti (c'e' un test d'identita').

ESITO. Cella scelta al buio 10g -0.28/-0.05 (Sh IS 1.75 / FULL 1.55 / HOLD 1.01)
contro il canonico 7g (1.50/1.32/0.82; rango 17/72 in-sample). Batte il canonico
ma DSR 0.948 < 0.95 FAIL. marginal_vs_tp01 = ADDS per entrambe: il verdetto non
e' 'VRP01 e' rotto', e' che il vantaggio non sopravvive al conto dei trial.

IL BUCO SI CHIUDE SUL CONTENUTO, non sul gate: la regione mai esplorata PERDE.
Miglior cella >10g = 18g, rango 8/72, e solo 3/10 della top-10 sta oltre i 10
giorni -> lo studio conferma il 03/07 invece di ribaltarlo.

IL VINCITORE STA DOVE IL MODELLO SBAGLIA DI PIU': compra l'ala piu' lontana
(delta lungo -0.05), come 5/10 della top-10, cioe' la gamba che il 30/07 ha
misurato sottoprezzata ~2.3x. Sospetto motivato, non dimostrazione: il mediano
non separa -0.10 da -0.05, si separa la coda alta. Ma f non e' misurato fuori
dalla struttura canonica, e la sensibilita' a f uniforme e' la lente sbagliata
per una struttura il cui errore e' concentrato in una gamba sola.

ROBUSTEZZA DEL VERDETTO, PUBBLICATA: DSR 0.983 PASS a N=8, 0.948 FAIL a N=72,
0.909 a N=360. Il verdetto si ribalta col conteggio. La griglia era dichiarata
in anticipo e il conto fatto al rialzo, ma fallisce per 0.002: si cita come
tale, non come refutazione netta. Congelato in un test.

Seguito giusto = una MISURA, non un altro backtest: quanto vale f sul
10g/-0.05 sulle quote vere, ora che la catena la raccogliamo noi ogni ora.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:06:31 +00:00

282 lines
15 KiB
Python

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