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>
This commit is contained in:
Adriano Dal Pastro
2026-07-30 22:06:31 +00:00
parent 04cb572535
commit 36dc55748e
4 changed files with 598 additions and 0 deletions
+34
View File
@@ -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, IVDVOL +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
+124
View File
@@ -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`**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.
+281
View File
@@ -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())
+159
View File
@@ -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"]