36dc55748e
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>
282 lines
15 KiB
Python
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())
|