diff --git a/CLAUDE.md b/CLAUDE.md index 8cb51a2..56e712e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,6 +194,40 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis `fee_frac` **NON cambiato**: il forfait e' conservativo e questo progetto tiene i numeri di ammissione conservativi. **Rientro immediato** dopo l'uscita: Sh 1.19 < 1.32 canonico; il suo ShHOLD 2.42 non e' selezionabile in-sample → **non e' un lead**. + ✅ **BUCO DEL TENORE CHIUSO col gate onesto (2026-07-30, 4° filone) — nessun cambio.** + `scripts/research/r0730_vrp_tenor_gate.py`, test `tests/test_vrp_tenor_gate.py` (12), diario + `2026-07-30-vrp-tenor-gate.md`. La griglia 03/07 si fermava a **10g**; qui la famiglia e' stata + **dichiarata prima di guardare**: 8 tenori (5-35g) x 3 delta corti x 3 lunghi = **72 celle** + (riaprire il tenore riapre la struttura → i trial si contano al RIALZO). ⚠️ `study_family_honest` + e' cablato sui candidati DIREZIONALI (`factory -> target_fn` via `candidate_daily`), quindi sono + stati usati i suoi **tre componenti reali**: selezione in-sample-only → `altlib.deflated_sharpe` + → `altlib.marginal_vs_tp01` (importati, non riscritti; c'e' un test d'identita'). + **Esito: `earns_slot_honest = False`.** Cella scelta al buio **10g −0.28/−0.05** (Sh IS 1.75 / + FULL 1.55 / HOLD 1.01 / DD 9.6%) contro il canonico 7g (1.50/1.32/0.82/10.5%; rango **17/72** + in-sample, 26/72 full) → batte il canonico, ma **DSR 0.948 < 0.95 FAIL** (il canonico stesso fa + 0.885 dentro questa famiglia). `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 meglio sul contenuto che sul gate: la regione MAI esplorata PERDE.** + Miglior cella >10g = 18g −0.28/−0.05, **rango 8/72**, e solo **3/10** della top-10 stanno 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 (δ lungo + −0.05), come 5/10 della top-10, cioe' esattamente la gamba che il 30/07 ha MISURATO + sottoprezzata (~2.3x, IV−DVOL +7.5pp). Sospetto motivato, **non dimostrazione**: il mediano non + separa −0.10 da −0.05 (1.37 vs 1.37), si separa la coda alta. Ma `f` **non e' misurato** fuori + dalla struttura canonica, e la sensibilita' a f uniforme (cella scelta 0.84 vs canonico 0.47 a + f=0.73) **e' la lente sbagliata** per una struttura il cui errore e' concentrato in una gamba. + ⚠️ **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 `test_il_verdetto_dipende_dal_numero_di_trial_dichiarati`. + **Seguito giusto = una MISURA, non un altro backtest:** quanto vale f sul 10g/−0.05 sulle quote + vere (la catena ora la raccogliamo noi ogni ora; oggi 15 osservazioni sul solo canonico). + **REGOLE:** (a) quando si riapre un parametro si riapre la sua **famiglia**, e il deflated-Sharpe + e' l'unico posto dove barare e' indolore e invisibile; (b) dichiarare la griglia **prima** e + pubblicare la **sensibilita' del verdetto al conteggio**; (c) un buco si chiude meglio mostrando + che dentro **non c'e' niente** che bocciando il candidato; (d) se la selezione converge dove un + difetto di modello e' gia' misurato, **il sospetto vale piu' del numero** — e si dice che e' un + sospetto. 💰 **A $3.000 la domanda non e' il rendimento** (`min_trade_amount` letti dal venue il 30/07): **BTC min 0.1 = $6.210 di collaterale/lotto → FUORI**; ETH min 1 contratto = $1.832 → **1 lotto**. Il sleeve 50/50 **diventa ETH-only**, e **al peso di book (12% = $360) sono 0 lotti** — servirebbe diff --git a/docs/diary/2026-07-30-vrp-tenor-gate.md b/docs/diary/2026-07-30-vrp-tenor-gate.md new file mode 100644 index 0000000..55f247c --- /dev/null +++ b/docs/diary/2026-07-30-vrp-tenor-gate.md @@ -0,0 +1,124 @@ +# 2026-07-30 (4º filone) — il buco del tenore chiuso col gate onesto: nessun cambio a VRP01 + +**Richiesta dell'operatore:** *«chiudi il buco del tenore con study_family_honest».* +**Esito: `earns_slot_honest = False`. Book, pesi, cron, config INVARIATI.** + +Script `scripts/research/r0730_vrp_tenor_gate.py`, test `tests/test_vrp_tenor_gate.py` (12 casi). + +## Il buco, e perché era reale + +La griglia strutture del 03/07 (`r0703_vrpimp_structgrid`) cercava su `TENOR_GRID = (3, 5, 7, 10)`: +**oltre i 10 giorni non è mai stato guardato.** Lo sweep del 30/07 sul profit-take aveva toccato +14/18/21/28g di passaggio e mostrato che il 18g *sembrava* battere il 7g in hold-to-expiry +(1.51 vs 1.32) — sei celle scelte sul campione pieno, cioè esattamente la selezione che questo +gate esiste per uccidere. + +## Perché il gate è composto a mano, e non è una scorciatoia + +`altlib.study_family_honest` è cablato sui candidati **direzionali**: vuole +`factory(tf=..., **params) -> target_fn` e passa da `candidate_daily`, che costruisce i rendimenti +da una serie di *posizioni* su BTC/ETH. VRP01 non è direzionale. Si usano quindi i suoi **tre +componenti reali**, nello stesso ordine e con le stesse soglie: selezione in-sample-only → +`altlib.deflated_sharpe` (importato, non riscritto — c'è un test che lo verifica) → +`altlib.marginal_vs_tp01` (importato). + +**Famiglia dichiarata nel docstring PRIMA di guardare i numeri:** 8 tenori × 3 delta corti × +3 delta lunghi = **72 celle**. Riaprire il tenore riapre la struttura, e il conteggio dei trial si +fa **al rialzo** — contarne meno gonfia il deflated-Sharpe. + +## Il risultato + +| | tenore | δ corto | δ lungo | Sh IS | Sh FULL | Sh HOLD | maxDD | CAGR | +|---|---|---|---|---|---|---|---|---| +| **scelta al buio** | 10g | −0.28 | −0.05 | **1.75** | 1.55 | 1.01 | 9.6% | 13.5% | +| VRP01 canonico | 7g | −0.28 | −0.10 | 1.50 | 1.32 | 0.82 | 10.5% | 10.5% | + +Rango del canonico: **17/72 in-sample, 26/72 sul campione pieno**. La selezione al buio sceglie +un'altra cella, e quella cella batte il canonico sia in-sample sia full. + +**Ma il gate si chiude sul deflated-Sharpe:** + +| | DSR su 72 trial | esito | +|---|---|---| +| cella scelta | **0.948** | **FAIL** (soglia 0.95) | +| VRP01 canonico | 0.885 | FAIL | + +`marginal_vs_tp01` dà **ADDS** a entrambe (corr +0.016, `robust_oos` True, `has_insample_edge` +True, `is_hedge` False). Quindi il verdetto **non** è «VRP01 è rotto» né «il tenore non conta»: +è che il vantaggio apparente non sopravvive al conto dei trial. + +## Come il buco si chiude davvero: la regione nuova perde + +Questo è il punto che rende la chiusura pulita, più del DSR: + +| | cella | Sh IS | rango | +|---|---|---|---| +| miglior cella **≤10g** (già coperta dal 03/07) | 10g −0.28/−0.05 | 1.75 | 1/72 | +| miglior cella **>10g** (territorio mai guardato) | 18g −0.28/−0.05 | 1.56 | **8/72** | + +Solo **3 celle su 10** nella top-10 in-sample stanno oltre i 10 giorni. **Si è aperta la regione +mai esplorata e la regione mai esplorata ha perso** contro celle che il 03/07 aveva già visto e +non promosso. Il buco non è "chiuso perché il gate boccia": è chiuso perché **guardandoci dentro +non c'è niente**. + +## ⚠️ Il vincitore sta dove il modello sbaglia di più + +La cella scelta compra l'ala **più lontana** (δ lungo −0.05), e 5 delle 10 celle migliori fanno lo +stesso. Il 30/07 ha **misurato** che il modello piatto sottoprezza proprio l'ala che si *compra* +(IV(lunga)−DVOL **+7.5pp** contro +0.8pp della corta, ~**2.3×** il modello), e che l'errore cresce +con la distanza dallo strike. Una selezione che premia il δ lungo più piccolo sta plausibilmente +massimizzando **l'errore di modello**, non l'edge. + +Onestà su questo punto: è un **sospetto motivato, non una dimostrazione**. Il *mediano* in-sample +non separa −0.10 da −0.05 (1.37 contro 1.37); a separarsi è la **coda alta** della griglia. Ma +basta a spostare l'onere della prova, perché `f` **non è misurato** fuori dalla struttura canonica. + +⚠️ E la sensibilità a `f` non risolve: applicandolo uniformemente la cella scelta degrada *meno* +(f=0.73 → 0.84 contro 0.47 del canonico), ma **f uniforme è la lente sbagliata proprio per questa +struttura**, il cui errore è concentrato in una gamba sola. Un numero rassicurante ottenuto con lo +strumento sbagliato non è rassicurazione. + +## ⚠️ Quanto è robusto il mio stesso verdetto + +Il DSR dipende da quanti trial si dichiarano, e qui il verdetto **si ribalta**: + +| conteggio | N | DSR | esito | +|---|---|---|---| +| solo gli 8 tenori | 8 | 0.983 | **PASS** | +| **la griglia dichiarata** | **72** | **0.948** | **FAIL** | +| 72 + 288 simulate del 03/07 | 360 | 0.909 | FAIL | + +La griglia è stata dichiarata **prima** di guardare i numeri e il conteggio fatto al rialzo — ma il +verdetto va citato per quello che è: **fallisce per 0.002**, non è una refutazione netta. (L'ultima +riga usa trial *simulati* dalla distribuzione osservata, non le Sharpe vere del 03/07, che vengono +da un motore diverso: indicazione, non misura.) Congelato in +`test_il_verdetto_dipende_dal_numero_di_trial_dichiarati`, così che nessuno possa farlo passare +un domani restringendo il conteggio. + +## Decisione + +**Nessun cambio a VRP01**, per quattro ragioni in ordine di peso: + +1. **Il gate pre-registrato fallisce.** Una soglia dichiarata in anticipo che poi si aggira non è + una soglia. +2. **La regione davvero nuova non vince** — quindi questo studio non ribalta il 03/07, lo conferma. +3. **Il vincitore sta nell'angolo dove abbiamo misurato che il modello è meno affidabile**, e lì + `f` non è misurato. +4. Niente di tutto questo è eseguibile prima di ~$2.6k, e a $3.000 VRP01 è **0 lotti** al peso di + book. + +**Il seguito giusto non è un altro backtest, è una misura.** Se il 10g/−0.28/−0.05 interessa, la +domanda che decide è *quanto vale f su quella struttura sulle quote vere* — e da oggi la catena la +raccogliamo noi ogni ora. Serve però che il collettore accumuli abbastanza scadenze a 10 giorni: +oggi ci sono 15 osservazioni sul solo canonico. + +## Regole + +- **Quando si riapre un parametro si riapre la sua famiglia**, e i trial si contano al rialzo: il + deflated-Sharpe è l'unico posto dove barare è indolore e invisibile. +- **Dichiarare la griglia prima di guardare i numeri**, e pubblicare la sensibilità del verdetto al + conteggio — un gate che passa solo con la propria griglia preferita non è un gate. +- **Un buco si chiude meglio mostrando che dentro non c'è niente che bocciando il candidato**: qui + la frase che conta non è «DSR 0.948» ma «la regione mai esplorata è ottava». +- **Se la selezione converge dove un difetto di modello è già misurato, il sospetto vale più del + numero** — e si dice che è un sospetto. diff --git a/scripts/research/r0730_vrp_tenor_gate.py b/scripts/research/r0730_vrp_tenor_gate.py new file mode 100644 index 0000000..067fa56 --- /dev/null +++ b/scripts/research/r0730_vrp_tenor_gate.py @@ -0,0 +1,281 @@ +"""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()) diff --git a/tests/test_vrp_tenor_gate.py b/tests/test_vrp_tenor_gate.py new file mode 100644 index 0000000..7d89b9d --- /dev/null +++ b/tests/test_vrp_tenor_gate.py @@ -0,0 +1,159 @@ +"""Test del gate onesto sul tenore di VRP01 (scripts/research/r0730_vrp_tenor_gate.py). + +Cosa va congelato, in ordine di importanza: + +* **La famiglia dichiarata.** Il deflated-Sharpe dipende da quanti trial si dichiarano, e il + verdetto di questo studio si RIBALTA fra 8 e 72 celle. La griglia e' stata dichiarata nel + docstring PRIMA di guardare i numeri; questi test la congelano, cosi' che un domani nessuno + possa farla passare restringendo il conteggio. +* **La selezione e' in-sample.** Se qualcuno la cambiasse in "cella a max hold-out" il gate + diventerebbe la trappola che esiste per prevenire. +* **La coerenza fra i due script del 30/07**: la cella canonica qui deve essere la stessa + serie del canonico misurato in `r0730_vrp_profit_take`. +* **Il controllo positivo**: un gate che dice sempre FAIL non e' un gate. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +_spec = importlib.util.spec_from_file_location( + "r0730_tenor", ROOT / "scripts" / "research" / "r0730_vrp_tenor_gate.py") +TG = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(TG) + +import altlib # noqa: E402 +from src.portfolio.sleeves import VRP_CFG # noqa: E402 + + +@pytest.fixture(scope="module") +def grid(): + rows = [] + for tn in TG.TENORS: + for sd in TG.SHORT_DELTAS: + for ld in TG.LONG_DELTAS: + d = TG.cell_daily(tn, sd, ld) + ins = d[d.index < TG.HOLDOUT] + rows.append(dict(tenor=tn, sd=sd, ld=ld, daily=d, + is_sh=TG._sh(ins), full_sh=TG._sh(d), + hold_sh=TG._sh(d[d.index >= TG.HOLDOUT]))) + return rows + + +# =========================================================================== +# la famiglia dichiarata — il numero da cui dipende il verdetto +# =========================================================================== +def test_la_griglia_dichiarata_e_di_72_celle(): + assert len(TG.TENORS) * len(TG.SHORT_DELTAS) * len(TG.LONG_DELTAS) == 72 + + +def test_la_griglia_copre_la_regione_mai_esplorata_dal_0307(): + """Il buco era 'oltre i 10 giorni': la griglia 03/07 era (3, 5, 7, 10).""" + assert max(TG.TENORS) > 10 + assert sum(1 for t in TG.TENORS if t > 10) >= 4 + + +def test_il_canonico_e_dentro_la_griglia(): + """Senza il canonico dentro non c'e' un termine di paragone.""" + assert TG.CANON == (VRP_CFG["tenor_d"], VRP_CFG["short_delta"], VRP_CFG["long_delta"]) + assert TG.CANON[0] in TG.TENORS + assert TG.CANON[1] in TG.SHORT_DELTAS + assert TG.CANON[2] in TG.LONG_DELTAS + + +# =========================================================================== +# coerenza fra i due script del 30/07 +# =========================================================================== +def test_la_cella_canonica_e_lo_stesso_canonico_dell_altro_studio(): + """`r0730_vrp_profit_take.combo(pt=None)` e `cell_daily(*CANON)` devono essere la STESSA + serie: se divergono, i due diari raccontano due cose diverse chiamandole uguali.""" + a = TG.cell_daily(*TG.CANON) + b = TG.PT.combo(pt=None, f=1.0, real_fee=True)[0] + j = pd.concat({"a": a, "b": b}, axis=1, join="inner") + assert len(j) == len(a) == len(b) + assert float((j["a"] - j["b"]).abs().max()) == pytest.approx(0.0, abs=1e-12) + + +# =========================================================================== +# la selezione non sbircia l'hold-out +# =========================================================================== +def test_la_cella_e_scelta_col_solo_sharpe_in_sample(grid): + chosen = max(grid, key=lambda r: r["is_sh"]) + assert chosen["is_sh"] == max(r["is_sh"] for r in grid) + + +def test_scegliere_sull_hold_out_darebbe_una_cella_DIVERSA(grid): + """E' il motivo per cui il gate esiste: la cella a max hold-out non e' quella a max + in-sample, quindi 'scegliere guardando l'hold-out' sarebbe una scelta, non una misura.""" + a = max(grid, key=lambda r: r["is_sh"]) + b = max(grid, key=lambda r: r["hold_sh"]) + assert (a["tenor"], a["sd"], a["ld"]) != (b["tenor"], b["sd"], b["ld"]) + + +# =========================================================================== +# il risultato: la regione nuova perde +# =========================================================================== +def test_la_regione_oltre_i_10_giorni_non_produce_il_vincitore(grid): + """Il modo in cui il buco si chiude: si e' guardato dove non si era mai guardato, e li' + non c'e' il vincitore.""" + chosen = max(grid, key=lambda r: r["is_sh"]) + assert chosen["tenor"] <= 10 + best_new = max((r for r in grid if r["tenor"] > 10), key=lambda r: r["is_sh"]) + assert best_new["is_sh"] < chosen["is_sh"] + + +# =========================================================================== +# deflated Sharpe: quello di altlib, e il verdetto che ne dipende +# =========================================================================== +def test_usa_il_deflated_sharpe_di_altlib_non_una_copia(): + assert TG.altlib.deflated_sharpe is altlib.deflated_sharpe + + +def test_il_verdetto_dipende_dal_numero_di_trial_dichiarati(grid): + """Il fatto piu' importante da non dimenticare: a 8 trial passerebbe, a 72 no. La griglia + e' stata dichiarata prima di guardare, e il conteggio si fa al rialzo.""" + chosen = max(grid, key=lambda r: r["is_sh"]) + all72 = [r["full_sh"] for r in grid] + only8 = [r["full_sh"] for r in grid if (r["sd"], r["ld"]) == TG.CANON[1:]] + d72, _ = altlib.deflated_sharpe(chosen["full_sh"], all72, chosen["daily"]) + d8, _ = altlib.deflated_sharpe(chosen["full_sh"], only8, chosen["daily"]) + assert len(only8) == len(TG.TENORS) == 8 + assert d8 > 0.95 > d72 # il verdetto si ribalta + assert d72 > 0.90 # ...e fallisce per poco: va citato cosi' + + +def test_il_gate_fallisce_sulla_griglia_dichiarata(grid): + chosen = max(grid, key=lambda r: r["is_sh"]) + dsr, _ = altlib.deflated_sharpe(chosen["full_sh"], [r["full_sh"] for r in grid], + chosen["daily"]) + assert dsr < 0.95 + + +def test_controllo_positivo_il_deflated_sharpe_sa_promuovere(grid): + """Obbligatorio: un gate che risponde sempre FAIL e' indistinguibile da uno rotto. + Una serie COSTRUITA molto forte, contro gli stessi 72 trial, deve passare.""" + chosen = max(grid, key=lambda r: r["is_sh"]) + strong = chosen["daily"] * 1.0 + strong = strong + strong.std() * 0.5 # drift grande, stessa forma + dsr, _ = altlib.deflated_sharpe(TG._sh(strong), [r["full_sh"] for r in grid], strong) + assert dsr >= 0.95 + + +# =========================================================================== +# il marginale resta ADDS: il verdetto NON e' "VRP e' rotto" +# =========================================================================== +def test_il_canonico_resta_ADDS_vs_tp01(): + """Il gate boccia un CAMBIO di struttura, non lo sleeve: se questo si rompe, la + conclusione del diario va riscritta.""" + m = altlib.marginal_vs_tp01(TG.cell_daily(*TG.CANON)) + assert m["marginal_verdict"] == "ADDS" + assert m["has_insample_edge"] and not m["is_hedge"]