From 26f8d27a611d44484a6fb2743a0f35c6b5f98fb4 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 3 Jul 2026 07:05:19 +0000 Subject: [PATCH] =?UTF-8?q?research(wave-0703):=20migliora+proteggi=20VRP0?= =?UTF-8?q?1=20=E2=80=94=207=20filoni,=200=20miglioramenti,=20anchor-audit?= =?UTF-8?q?=20VRP01=20chiuso=20(4/4=20sleeve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal: migliorare la strategia short-vol (famiglia Albimarini/VRP01) e proteggerla dai DD. Workflow 26 agenti (7 filoni + 2 lenti avversariali + scettico incrociato). Esito: NON migliorabile; la protezione DD si compra SOLO con la size. - Griglia 288 strutture: nessuna batte VRP01 (DSR 0.000; meta' griglia = 3a occorrenza "0-perdite/Sharpe implausibile" dopo CC01/ALB-A). - 4 overlay DD (exit-spike/SL-MTM/ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive gia' nel gate IV-rank. - Gate nuovi: 4o fallimento su 4 (l'alpha e' il binario IV-rank>0.30). - Sizing: 12% deploy ~ 0.27 Kelly onesto (anti-rovina); disambiguazione unita' obbligatoria vs 12% peso book (0.014 Kelly, fattore 19x). - Gate term-structure VIX/VXV su SPX (dSh +0.90, DSR 0.992) = confound di modello al 100% -> nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture BS-flat. - ANCHOR-AUDIT VRP01 CHIUSO: primo sleeve SENZA luck (fase canonica = peggiore delle 7 -> numeri di ammissione conservativi). Audit anchor ora completo su 4/4 sleeve ancorati. Book/pesi INVARIATI. Nessun nuovo sleeve. 168 test verdi. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 16 + docs/diary/2026-07-03-vrp-improve-dd.md | 167 +++++ scripts/research/r0703_vrpimp_anchor.py | 484 ++++++++++++++ scripts/research/r0703_vrpimp_ddoverlay.py | 323 +++++++++ scripts/research/r0703_vrpimp_newgates.py | 444 +++++++++++++ scripts/research/r0703_vrpimp_sizing.py | 655 +++++++++++++++++++ scripts/research/r0703_vrpimp_spxgate.py | 462 +++++++++++++ scripts/research/r0703_vrpimp_stresslab.py | 691 ++++++++++++++++++++ scripts/research/r0703_vrpimp_structgrid.py | 426 ++++++++++++ 9 files changed, 3668 insertions(+) create mode 100644 docs/diary/2026-07-03-vrp-improve-dd.md create mode 100644 scripts/research/r0703_vrpimp_anchor.py create mode 100644 scripts/research/r0703_vrpimp_ddoverlay.py create mode 100644 scripts/research/r0703_vrpimp_newgates.py create mode 100644 scripts/research/r0703_vrpimp_sizing.py create mode 100644 scripts/research/r0703_vrpimp_spxgate.py create mode 100644 scripts/research/r0703_vrpimp_stresslab.py create mode 100644 scripts/research/r0703_vrpimp_structgrid.py diff --git a/CLAUDE.md b/CLAUDE.md index 882ef1b..652e020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,22 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca `scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`. + ⚠️ **ANCHOR-AUDIT CHIUSO + ondata "migliora e proteggi" (2026-07-03, 7 filoni + 2 lenti + scettico): + VRP01 NON è migliorabile e la protezione DD si compra SOLO con la size.** (a) **Anchor-luck (ciclo + settimanale, 7 fasi): PRIMO sleeve SENZA firma di luck** — la fase canonica è la PEGGIORE delle 7 su + FULL (1.09 = 7° pctl) e su DD (11.8% = 93° pctl), mediana su HOLD (0.59); spike bootstrap NEGATIVO → + i numeri di ammissione FULL 1.10/HOLD 0.60/DD 12% sono CONSERVATIVI, non gonfiati. Da ora si citano + con banda: ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]; edge OOS f-dipendente (f=0.8 → + HOLD~0). **Con questo l'audit anchor è completo su 4/4 sleeve ancorati.** (b) Griglia 288 strutture: + nessuna batte VRP01 (DSR 0.000; metà griglia = 3ª occorrenza "0-perdite/Sharpe implausibile" dopo + CC01/ALB-A → gate `implausible_sharpe` alzato di priorità). (c) 4 overlay DD (exit-spike/SL-MTM/ + ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive già nel gate + d'ingresso IV-rank. (d) Gate nuovi: 4° fallimento su 4 (l'alpha è il binario IV-rank>0.30). (e) + Sizing: 12% deploy ≈ 0.27 Kelly onesto (anti-rovina); ⚠️ NON confondere col 12% di PESO del book + (~0.014 Kelly, fattore 19x). (f) Gate term-structure VIX/VXV su SPX (ΔSh +0.90, DSR 0.992) = + **confound di modello al 100%** (la var del gate coincide con l'errore BS-flat vs term-structure) → + nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture + BS-flat. Book/pesi INVARIATI. Diario `2026-07-03-vrp-improve-dd.md`; script `scripts/research/r0703_vrpimp_*.py` (7 file). Diario `2026-06-20-financeold-analysis-vrp-v2.md`. - **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico / trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend diff --git a/docs/diary/2026-07-03-vrp-improve-dd.md b/docs/diary/2026-07-03-vrp-improve-dd.md new file mode 100644 index 0000000..65ae1d5 --- /dev/null +++ b/docs/diary/2026-07-03-vrp-improve-dd.md @@ -0,0 +1,167 @@ +# 2026-07-03 — Ondata "migliora e proteggi VRP01 dai DD" (7 filoni + verifica a 2 lenti + scettico incrociato) + +**Contesto.** Dopo la chiusura del dossier Albimarini (3 backtest indipendenti convergenti: premio +piccolo e reale, code non campionate, numeri del corso spiegabili al 100% dalla struttura del +payoff), goal esplicito: **migliorare la strategia short-vol** — la famiglia Albimarini → il nostro +**VRP01**, l'unica forma sopravvissuta agli audit — **e proteggerla dai drawdown**, con +orchestrazione a decine di agenti. Workflow deterministico: 7 filoni di ricerca in parallelo, 2 +refuter avversariali (lente selection-bias + lente de-levering) su ogni miglioramento proposto, +scettico incrociato finale sui sopravvissuti. **26 agenti, 0 errori.** + +**Scripts:** `scripts/research/r0703_vrpimp_{structgrid,ddoverlay,sizing,newgates,anchor,spxgate,stresslab}.py`. +Nessun file di produzione toccato, nessun commit dagli agenti. Motore riusato: `r0702_alb_structure.py` +(DVOL, riproduce VRP01 bit-exact: baseline bridge max|diff|=0.00e+00 su 266 settimane). + +--- + +## Verdetto in una riga + +**VRP01 non è migliorabile oltre lo status quo, e la protezione DD si compra SOLO con la size.** +Zero nuovi sleeve, zero overlay che reggono, zero gate nuovi, zero cambio di sizing. I due unici +"sopravvissuti" alla verifica **non propongono alcun cambiamento** (una conferma dello status quo + +una lente di misura). Il candidato più luccicante dell'ondata — un gate term-structure VIX su SPX +con ΔSharpe +0.90 e DSR 0.992 — è stato smontato dallo scettico come **confound di modello al +100%**. Deliverable reale = **audit anchor-luck di VRP01 CHIUSO** (ultimo sleeve ancorato): la fase +canonica è all'estremo **conservativo** della banda, quindi i numeri di ammissione reggono e da ora +si citano con banda. + +--- + +## I 7 filoni + +### 1. Griglia di struttura (288 celle) — NESSUNA batte VRP01 +6 strutture (vertical put/call, condor, 3 diagonali) × distanza {1,1.5,2,2.5}σ × ali {0.5,1,2}σ × +tenor {3,5,7,10}g, gate IV-rank canonico sempre attivo. La cella scelta col **solo in-sample** +(DIAG-PUT z=1.0 3g, ShIS 2.28) crolla in hold-out a **+0.19** vs 0.86 del bridge stesso-motore; +DSR 0.000/0.004 su 288 trial; multi-cut negativo a 3-4 cut su 4; banda d'ancora che non tiene +nemmeno il segno (ShH [−0.21,+0.64]). **Metà griglia (150/288) è il pattern "0-perdite = Sharpe +implausibile"** (celle z≥2 lunghe, 0 perdite su ~97 trade, ShH fasulli 4-6.5) — **terza occorrenza +dopo CC01 e ALB-A**. Quarta conferma indipendente: nel VRP l'alpha sta nel gate di regime + nella +zona ~δ-0.28 dove la coda è campionata, non nella geometria. + +### 2. Overlay di protezione DD (14 celle) — 4/4 REFUTED (2 lenti ciascuno) +(a) exit intra-settimana su spike DVOL, (b) stop-loss MTM, (c) ala di coda comprata (broken-wing), +(d) cooldown post-perdita. **Nessuno batte il null del de-levering** a pari maxDD su tutta la banda +f; nessuno passa multi-cut (max 1/4) né DSR (0.70-0.88, tutti <0.95). Esempio decisivo: SL MTM 3x fa +DD 7.9%/worst −5.7%, ma VRP01 × 0.66 (semplice riduzione di size) fa lo **stesso DD a Sharpe +invariato e worst-week migliore (−4.9%)**. Meccanismo confermato: i trigger di spike scattano solo +2-3 volte in 5 anni **perché il gate IV-rank/crash-skip evita già gli ingressi vicino alle +esplosioni di vol** — la protezione crash vive nel gate d'ingresso, non serve un overlay a valle. +Coerente con TP01×DVOL (2026-06-26): "per meno DD la leva è la size, non un overlay". + +### 3. Sizing anti-rovina — CONFERMA (12% ≈ quarter-Kelly), 1 candidato REFUTED +Il sizing non crea alpha (Sharpe del flusso invariante ~0.95 a ogni frazione). Frontiera onesta +CAGR-DD-P(rovina) mappata su 92 trade gated ETH 2021-26 (pooled banda-f + coda sintetica full-loss): +Kelly onesto q*=44% → il **12% del book = 0.27 Kelly, già in zona quarter-Kelly anti-rovina** +(P(rovina-50%|5y) 0.0-0.1%, P(DD>30%) 9-18%, CAGR med 13.5-17% a 2k/5k). Salire al 25% → P(DD>30%) +> 92%; Albimarini 1→4 → P(rovina) 53-55%. **Nessun cambio raccomandato.** Il candidato anti-streak +(N=2, size 12%→3% dopo 2 vittorie, l'opposto di Albimarini) era il più luccicante mai visto sullo +sleeve (FULL CAGR +19.4%/DD 6.3%/Sh 2.08, random-null 0.996, DSR 0.999) ma **auto-refutato come +artefatto di selezione**: plateau assente (solo N=2 vince, N=3/5 collassano), P(loss|streak) non +monotona, delever-null perde in 2/7 ancore — tutto l'effetto sono ~6 perdite su 15 trade a +streak==2 (binomtest p=0.033, CI contiene la base). Istruttivo: mostra come un DSR 0.999 possa +convivere con l'assenza di meccanismo. + +### 4. Gate nuovi — 4° FALLIMENTO su 4 (barra alta) +(a) term-structure DVOL **non testabile oggi** (storia vol_term = 7 giorni, logger forward dal +2026-06-26 → rivalutare a 6-12 mesi); (b) vol-of-vol, (c) RV-acceleration, (d) combinazioni-AND. Il +candidato IS-best (vol-of-vol pctl>0.80) passa DSR 0.960 e il de-levering all'ancora di default ma +la **banda d'ancora lo smaschera** (mediana uplift FULL −0.07 su 7 ancore, uplift concentrato in UNA +finestra 2021-22 di 5 settimane, hold-out uplift 0.00). Bonus strutturale: i veti RV-acceleration +bloccano settimane **profittevoli** (+0.93/+1.25% vs +0.6% tenute) — dopo il gate IV-rank, la vol +che accelera è dove il premio venduto è più ricco. Conferma il verdetto 2026-07-01: l'alpha è tutto +nel binario IV-rank>0.30. + +### 5. Audit anchor-luck VRP01 — CHIUSO: primo sleeve SENZA firma di luck +VRP01 è l'ultimo sleeve ancorato dopo TP01/XS01/SKH01. Ciclo settimanale → 7 ancore (giorno di +apertura). Replica bit-exact della sleeve (max|diff|=0.0). Le 7 fasi con parametri identici: + +| metrica | canonica (fase-0) | mediana 7 fasi | banda | pctl canonica | +|---|---|---|---|---| +| Sh FULL | 1.09 | 1.32 | [1.09, 1.83] | **7° (peggiore)** | +| Sh HOLD | 0.59 | 0.59 | [−0.03, 1.11] | 50° (mediana) | +| maxDD | 11.8% | 7.2% | [5.7, 11.8%] | **93° (peggiore)** | + +**Firma OPPOSTA a TP01/XS01/SKH01**: lo spike bootstrap è NEGATIVO (g0 HOLD −0.06, FULL −0.21, +P(g0≤0) 0.54/0.74) → niente da de-luckare. La fase canonica ha beccato le settimane crash 2022 +**peggio** di ogni altra (unico anno-fase negativo, −5.8%). **I numeri di ammissione FULL 1.10 / +HOLD 0.60 / DD 12% sono quindi CONSERVATIVI, non gonfiati** — il timore di "protezione fittizia" è +refutato: il DD 12% è il caso peggiore della banda, non quello fortunato. Impatto della fase sul +book 5-sleeve ≤ 0.04 Sh (banda ShFULL 2.23-2.27, ShHOLD 2.35-2.48, DD 6.2-6.3%). **Con questo +l'audit anchor-luck è completo su tutti gli sleeve ancorati (4/4).** + +### 6. Gate di regime sulla diagonale SPX (l'esperimento mancante) — REFUTED, con confound istruttivo +L'esperimento che mancava ai 3 backtest Albimarini: il gate di regime sulla diagonale SPX (VIX + +VXV da FRED, motore BS sintetico con skew log-lineare). **Domanda secca: il gate IV-rank ribalta gli +anni di coda su SPX come su crypto? NO.** Il gate VIX-rank canonico *abbassa* lo Sharpe (1.36→1.19), +non ribalta nessun anno di coda, peggiora il 2018, muore sul de-levering (ΔSh −0.17). Meccanismo: +su SPX il VRP è già positivo l'**84% dei giorni** (vs ~58% delle settimane crypto) → il filtro di +rank non ha lavoro discriminante; su equity IV-rank alto = crisi, non vol ricca. +Il candidato **term-structure VIX/VXV<1 (contango)** sembrava reale — Sh 1.18→2.08, DD 3.5→1.3%, +ΔSh **+0.90** a pari DD, multicut 3/3, flippa il 2020, DSR 0.992, plateau su c∈[0.93,1.05], jackknife +robusto — **e passa la lente selection-bias pulito**. Ma la lente de-levering lo affonda: +**l'intero uplift è un confound di modello, quantificato al 100%.** La variabile del gate (VIX/VXV>1) +coincide 1:1 con la variabile dell'errore di pricing (tenor 6g prezzato a VIX-30g). Il refuter ha +riprezzato con σ(6g)=VIX·(VIX/VXV)^α term-structure-consistent (α=1.42 = estrapolazione log-lineare +zero-parametri della pendenza osservata 30g→93g) → l'edge sparisce. **Lezione: un gate che si +correla con un errore di modello è indistinguibile da alpha finché non correggi il modello** — vale +per ogni futuro test su strutture prezzate BS-flat. + +### 7. Stress lab di coda — fisica dello strumento, nessun cambio +La protezione di coda del VRP01 viene per ~metà dall'**ala far-OTM** (nel replay COVID taglia ~90% +della perdita naked) e ~metà dal **gate canonico** (sulle 10 peggiori finestre 14g taglia la perdita +cumulata del 44-71% saltando i re-entry a IV-rank>0.90). L'ala T+1 "Albimarini" è assicurazione solo +contro i gap **medi** (−15/−20%, dove atterra ATM con un giorno di time value: +150bps) e **NON** +contro i gap profondi (k(−30%)=0.98 → il de-levering la batte), con valore interamente dipendente +dal vol-spike (banda f/mult da −324 a +263bps). Worst-case onesto a sizing 12%: piccolo in +convenzione book (−€12/sett a 2k, −€31 a 5k alla cella −30%) ma **è il margine intero** se il 12% +fosse deployato fisicamente come margine (−€164..−227). + +--- + +## Scettico incrociato — verdetto e disambiguazione critica + +I 2 sopravvissuti (sizing-conferma, anchor-ensemble) usano leve diverse (posizione sulla frontiera +vs varianza della stima), ma lo scettico ha trovato **tre sovrapposizioni che i refuter singoli non +potevano vedere**: + +1. **Stessa dipendenza dal modello f** — entrambi flippano segno allo stesso punto (f=0.6-0.8 → + negativo). Due sopravvissuti = **un solo grado di libertà di rischio-modello** (il caveat "premio + MODELLATO su DVOL ATM" resta il collo di bottiglia dell'intera famiglia). +2. **⚠️ CONFLITTO DI UNITÀ sul "12%"** (il punto più pericoloso per la narrativa): il **12% di PESO + del book** (convenzione pnl/Ks di `sleeves.py`) = ~**0.6% margine/equity ≈ 0.014 Kelly**, mentre + il "FISSO 12%" del filone sizing = **12% margine/equity ≈ 0.27 Kelly** — **fattore 19x**. Entrambe + corrette internamente, ma non vanno confuse: worst-week −12.2% del conto (convenzione margine) vs + −0.64% (convenzione peso book). +3. **L'ensemble-7-fasi è ridondante con il tranching TP01** (matematica di diversificazione: corr fra + fasi ρ=0.355, boost teorico ×1.50 vs osservato ×1.53, residuo alpha ≈ 0) → **non promuovere a LEAD + autonomo**; la coda strutturale è invariata (worst = 7 tranche a full-loss simultaneo), il DD 4.7% + è campionario, non un cap. + +**Effetto congiunto sul book 5-sleeve: ZERO** (entrambi propongono nessun cambio; controfattuale +|ΔSh| ≤ 0.04). Il book live Deribit (TP01+SKH01) non contiene VRP01 → impatto operativo nullo. + +--- + +## Cosa entra nella narrativa (una sola voce, status = misura research) + +- **Audit anchor-luck VRP01 CHIUSO** → i numeri di ammissione reggono e si citano con banda + (ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]); l'edge OOS resta f-dipendente. + Con questo l'audit anchor è completo su 4/4 sleeve ancorati. +- **Sizing (conferma, con disambiguazione unità obbligatoria):** 12% deploy ≈ 0.27 Kelly onesto, + anti-rovina; NON confondere col 12% di peso book (0.014 Kelly, 19x). +- **VRP01 canonico resta lo sleeve giusto, INVARIATO** — non migliorabile per struttura, gate, + overlay o sizing; la protezione DD si compra con la size. + +## Regole/candidati rafforzati + +- **`implausible_sharpe` gate in altlib**: 3ª occorrenza del pattern "0-perdite = Sharpe + implausibile" (griglia struttura, dopo CC01 e ALB-A) → priorità alzata. +- **Nuova regola metodologica**: un gate che si correla con un errore di modello (term-structure vs + BS-flat) è indistinguibile da alpha finché non si corregge il modello — riprezzare + term-structure-consistent prima di credere a qualsiasi gate vol su strutture BS-flat. +- **Conferma (4ª): l'alpha del VRP è il gate IV-rank binario**, non struttura/sizing/overlay/gate + aggiuntivi. + +**Stato:** book live INVARIATO, nessun nuovo sleeve, nessun forward-monitor. Test suite verde. 7 +script committati. diff --git a/scripts/research/r0703_vrpimp_anchor.py b/scripts/research/r0703_vrpimp_anchor.py new file mode 100644 index 0000000..18572fa --- /dev/null +++ b/scripts/research/r0703_vrpimp_anchor.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python +"""r0703_vrpimp_anchor.py — FILONE 5: AUDIT ANCHOR-LUCK di VRP01 (le 7 fasi settimanali). + +VRP01 apre un put credit spread ogni 7 GIORNI-INDICE a partire da i=60 del join px/DVOL +(gap-free dal 2021-03-24) e lo tiene a scadenza. L'ancora e' quindi il GIORNO DELLA SETTIMANA +di apertura: 7 fasi a priori (i0 = 60+phase, phase in 0..6). E' l'unico sleeve ancorato non +ancora auditato dopo TP01 (24 ancore, P=0.86), XS01 (10 fasi) e SKH01 (23 offset). + +Domande (ricerca di PROTEZIONE, non di alpha): + 0. REPLICA bit-exact della sleeve canonica (_vrp_combo_returns) prima di tutto. + 1. Banda delle 7 fasi a parametri IDENTICI (gate IV-rank/VRP ricalcolati causali per fase, + automatico: i gate si valutano all'indice d'ingresso della fase): Sh FULL/HOLD, maxDD, + worst-week, per-anno. Percentile della fase canonica. + 2. Banda f skew-caveat {0.6, 0.8, 1.0, 1.3} (pricing BS flat su DVOL ATM). + 3. ENSEMBLE delle 7 fasi (1/7 ciascuna) + null del DE-LEVERING (lezione TP01xDVOL: + una riduzione DD replicabile scalando la size non vale nulla). + 4. Block-bootstrap P(spike) alla maniera dello scettico TP01 (r0702_skeptic_offset.py): + lo spike della fase canonica e' speciale o e' il massimo atteso di 7 stime correlate? + 5. Impatto sul book 5-sleeve (TP 33/XS 15/VRP 12/SKH 20/GTAA 20, combine_outer) alla fase + canonica / mediana / peggiore / ensemble. + 6. Ri-verdetto dei numeri di ammissione (FULL 1.10 / HOLD 0.60 / DD 12%) alla fase mediana. + +Macchineria RIUSATA, non riscritta: helper del pricing e config presi in sola lettura da +src/portfolio/sleeves (_bs_put, _strike_from_delta, VRP_CFG) — il motore replica quindi la +sleeve canonica per costruzione, e la replica e' VERIFICATA bit-exact in PART 0. (Il motore +r0702_alb_structure usa load_tf di research_lab: qui serve il path della sleeve, resample_1d +su load_data, per la bit-exactness.) Nessun file di produzione toccato. Nessuna selezione +sull'hold-out (nessuna grid: 7 fasi = famiglia a priori, parametri congelati). + +Regole standing rispettate: niente short-vol da modello in deploy (esito = conoscenza); +niente '7D' con origin (qui il ciclo e' index-based come il canonico, niente resample +settimanale); pandas 2.x niente .view('int64'). + + uv run python scripts/research/r0703_vrpimp_anchor.py +""" +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT)) + +import altlib as al # noqa: E402 (harness di progetto: _sh/_dd_ret/HOLDOUT) +from src.data.downloader import load_data # noqa: E402 +from src.strategies.trend_portfolio import resample_1d # noqa: E402 +from src.portfolio.sleeves import ( # noqa: E402 (SOLA LETTURA) + VRP_CFG, _bs_put, _strike_from_delta, _vrp_weekly_asset, _vrp_combo_returns, _HL_DIR) +from src.portfolio.portfolio import combine_outer, to_daily # noqa: E402 + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +WKY = 365.25 / 7.0 +F_BAND = (0.6, 0.8, 1.0, 1.3) +PHASES = tuple(range(7)) +ASSETS = ("BTC", "ETH") +B_BOOT = 4000 +DAYNAME = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +ADMISSION = dict(full=1.10, hold=0.60, dd=0.12) # numeri dichiarati in CLAUDE.md + + +# =========================================================================== +# motore per-fase (specchio 1:1 di sleeves._vrp_weekly_asset, param i0 e f) +# =========================================================================== +@lru_cache(maxsize=4) +def joined(asset: str) -> pd.DataFrame: + """Stesso join px/DVOL della sleeve canonica (resample_1d su load_data 1h).""" + df = resample_1d(load_data(asset, "1h")) + s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"])) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet") + d = pd.Series(dv["close"].values.astype(float), + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + return pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() + + +def vrp_weekly_phase(asset: str, phase: int = 0, f: float = 1.0) -> pd.Series: + """Replica ESATTA di _vrp_weekly_asset con i0 = 60+phase e fattore premio f. + I gate (VRP>0, IV-rank espandente, crash-skip) sono ricalcolati CAUSALI all'indice + d'ingresso di ciascuna fase (dvf[:i]) — nessun riuso della sequenza gate di fase 0.""" + J = joined(asset) + px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index + n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25 + rets = {} + i = 60 + phase + while i + tn < n: + S0 = px[i]; sig = dvf[i] + skip = False + if cfg["gate_vrp"] and i >= 31: # VRP>0 causale + 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 (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60: + ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente + if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]: + skip = True + if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]: + skip = True + if skip: + rets[idx[i + tn]] = 0.0; i += tn; continue + Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"]) + Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"]) + net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * f + S1 = px[i + tn] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem) + rets[idx[i + tn]] = pnl / Ks + i += tn + return pd.Series(rets) + + +@lru_cache(maxsize=64) +def combo_weekly(phase: int, f: float = 1.0) -> pd.Series: + """Book 50/50 BTC+ETH per fase (stessa inner-join su date di scadenza del canonico).""" + rB = vrp_weekly_phase("BTC", phase, f) + rE = vrp_weekly_phase("ETH", phase, f) + return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index() + + +def to_daily_lumped(wk: pd.Series) -> pd.Series: + """Convenzione _vrp_combo_returns: lump del rendimento settimanale sul giorno di scadenza.""" + 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 + + +# =========================================================================== +# metriche +# =========================================================================== +def m_wk(r: pd.Series) -> dict: + """Metriche sul ciclo settimanale naturale (convenzione dei numeri di ammissione).""" + r = r.dropna() + if len(r) < 3 or r.std() == 0: + return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, act=0.0, n=len(r)) + def _s(x): + return float(x.mean() / x.std() * np.sqrt(WKY)) if len(x) > 2 and x.std() > 0 else 0.0 + eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq) + yrs = len(r) / WKY + return dict(sh=_s(r), sh_h=_s(r[r.index >= HOLDOUT]), + cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0, + dd=float(np.max((pk - eq) / pk)), worst=float(r.min()), + act=float((r != 0.0).mean()), n=int(len(r))) + + +def sh_daily(s: pd.Series) -> float: + return al._sh(s) + + +def worst_7d(daily: pd.Series) -> float: + """Peggior finestra di 7 giorni compounding, ancor-free (per l'ensemble).""" + lr = np.log1p(daily.values) + if len(lr) < 8: + return 0.0 + cs = np.concatenate([[0.0], np.cumsum(lr)]) + w = cs[7:] - cs[:-7] + return float(np.expm1(w.min())) + + +def pctl_of(vals: np.ndarray, v0: float) -> float: + return float(((vals < v0).sum() + 0.5 * (vals == v0).sum()) / len(vals) * 100.0) + + +def per_year(r: pd.Series) -> dict: + return {int(y): float(np.prod(1 + g.values) - 1) for y, g in r.groupby(r.index.year)} + + +# =========================================================================== +# PART 0 — replica bit-exact +# =========================================================================== +def part0() -> None: + print("=" * 100) + print("PART 0 — REPLICA della sleeve canonica (sanity bit-exact vs sleeves._vrp_combo_returns)") + print("=" * 100) + for a in ASSETS: + mine = vrp_weekly_phase(a, phase=0, f=VRP_CFG["f"]) + ref = _vrp_weekly_asset(a) + assert len(mine) == len(ref), f"P0 len mismatch {a}: {len(mine)} vs {len(ref)}" + assert (mine.index == ref.index).all(), f"P0 index mismatch {a}" + dmax = float(np.max(np.abs(mine.values - ref.values))) + assert dmax == 0.0, f"P0 valori non bit-exact {a}: max|diff|={dmax:.2e}" + print(f" [{a}] settimanale fase-0 == _vrp_weekly_asset: bit-exact " + f"(n={len(mine)}, max|diff|={dmax:.1e})") + mine_d = to_daily_lumped(combo_weekly(0, VRP_CFG["f"])) + ref_d = _vrp_combo_returns() + assert len(mine_d) == len(ref_d) and (mine_d.index == ref_d.index).all(), "P0 grid daily" + dmax = float(np.max(np.abs(mine_d.values - ref_d.values))) + assert dmax == 0.0, f"P0 combo daily non bit-exact: {dmax:.2e}" + print(f" [COMBO] daily-lumped fase-0 == _vrp_combo_returns: bit-exact (n={len(mine_d)})") + for a in ASSETS: + J = joined(a) + gaps = J.index.to_series().diff().dt.total_seconds().dropna() / 86400.0 + print(f" [{a}] join px/DVOL: {len(J)} giorni {J.index.min().date()} -> " + f"{J.index.max().date()}, gap>1g: {int((gaps > 1).sum())} " + f"(gap-free -> ogni fase = giorno-della-settimana FISSO)") + + +# =========================================================================== +# PART 1 — le 7 fasi a parametri identici (f=1.0) +# =========================================================================== +def part1() -> dict: + print("\n" + "=" * 100) + print("PART 1 — LE 7 FASI (parametri canonici, f=1.0). Ancora canonica = fase 0.") + print("=" * 100) + rows = {} + print(f" {'fase':<6}{'apre':>5}{'n_wk':>6}{'att%':>6}{'ShFULL':>8}{'ShHOLD':>8}" + f"{'CAGR':>8}{'maxDD':>8}{'worst-wk':>9}") + for p in PHASES: + wk = combo_weekly(p, 1.0) + entry_wd = int(pd.DatetimeIndex(wk.index - pd.Timedelta(days=VRP_CFG["tenor_d"])) + .dayofweek.min()) + wd_n = pd.DatetimeIndex(wk.index).dayofweek.nunique() + mm = m_wk(wk) + rows[p] = dict(mm, entry_wd=entry_wd, wd_n=wd_n, wk=wk) + tag = " <- CANONICA" if p == 0 else "" + print(f" {p:<6}{DAYNAME[entry_wd]:>5}{mm['n']:>6}{mm['act']*100:>5.0f}%" + f"{mm['sh']:>8.2f}{mm['sh_h']:>8.2f}{mm['cagr']*100:>+7.1f}%" + f"{mm['dd']*100:>7.1f}%{mm['worst']*100:>+8.2f}%{tag}") + assert wd_n == 1, f"fase {p}: weekday non unico ({wd_n})" + + for key, label in (("sh", "Sh FULL"), ("sh_h", "Sh HOLD"), ("dd", "maxDD"), + ("worst", "worst-week"), ("cagr", "CAGR")): + v = np.array([rows[p][key] for p in PHASES]) + print(f" banda {label:<11}: min {v.min():+.3f} / mediana {np.median(v):+.3f} / " + f"max {v.max():+.3f} | canonica {v[0]:+.3f} " + f"(pctl {pctl_of(v, v[0]):.0f}° su 7 fasi)") + + print("\n per-anno (f=1.0) — dispersione di fase attraverso il 2022 (LUNA/FTX):") + yrs = sorted({y for p in PHASES for y in per_year(rows[p]["wk"])}) + print(" fase " + "".join(f"{y:>9}" for y in yrs)) + for p in PHASES: + py = per_year(rows[p]["wk"]) + print(f" {p:<6}" + "".join(f"{py.get(y, float('nan'))*100:>+8.1f}%" for y in yrs)) + return rows + + +# =========================================================================== +# PART 2 — banda f (caveat skew: pricing BS flat su DVOL ATM) +# =========================================================================== +def part2() -> None: + print("\n" + "=" * 100) + print("PART 2 — BANDA f {0.6, 0.8, 1.0, 1.3} x 7 fasi (skew non esplicito -> banda, non punto)") + print("=" * 100) + print(f" {'f':>5} | {'ShFULL min/med/max':>22} {'(canon)':>8} | {'ShHOLD min/med/max':>22} " + f"{'(canon)':>8} | {'maxDD min/med/max':>20} {'(canon)':>8} | {'worst min':>9}") + for f in F_BAND: + mm = [m_wk(combo_weekly(p, f)) for p in PHASES] + sf = np.array([m["sh"] for m in mm]); shh = np.array([m["sh_h"] for m in mm]) + dd = np.array([m["dd"] for m in mm]); wo = np.array([m["worst"] for m in mm]) + print(f" {f:>5.1f} | {sf.min():>6.2f} {np.median(sf):>6.2f} {sf.max():>6.2f} " + f"{sf[0]:>7.2f} | {shh.min():>6.2f} {np.median(shh):>6.2f} {shh.max():>6.2f} " + f"{shh[0]:>7.2f} | {dd.min()*100:>5.1f} {np.median(dd)*100:>5.1f} " + f"{dd.max()*100:>5.1f}% {dd[0]*100:>6.1f}% | {wo.min()*100:>+8.2f}%") + + +# =========================================================================== +# PART 3 — ensemble delle 7 fasi + null del de-levering +# =========================================================================== +def phase_matrix(f: float = 1.0) -> pd.DataFrame: + cols = {f"p{p}": to_daily_lumped(combo_weekly(p, f)) for p in PHASES} + M = pd.concat(cols, axis=1, join="outer").sort_index() + lo = max(c.index.min() for c in cols.values()) + hi = min(c.index.max() for c in cols.values()) + return M.loc[(M.index >= lo) & (M.index <= hi)].fillna(0.0) + + +def part3(rows: dict) -> dict: + print("\n" + "=" * 100) + print("PART 3 — ENSEMBLE delle 7 fasi (1/7 ciascuna) + NULL DEL DE-LEVERING") + print("=" * 100) + M = phase_matrix(1.0) + ens = M.mean(axis=1) + can = M["p0"] + def _stats(s, name): + dd = al._dd_ret(s) + out = dict(sh=sh_daily(s), sh_h=sh_daily(s[s.index >= HOLDOUT]), dd=dd, + w7=worst_7d(s), + cagr=float(np.prod(1 + s.values) ** (365.25 / len(s)) - 1)) + print(f" {name:<28} ShFULL {out['sh']:>5.2f} ShHOLD {out['sh_h']:>5.2f} " + f"CAGR {out['cagr']*100:>+5.1f}% maxDD {out['dd']*100:>5.1f}% " + f"worst-7g {out['w7']*100:>+6.2f}%") + return out + print(" (griglia daily comune alle 7 fasi; Sharpe daily ~= Sharpe weekly del lump)") + s_can = _stats(can, "CANONICA fase-0") + s_ens = _stats(ens, "ENSEMBLE 7 fasi (1/7)") + + # NULL DEL DE-LEVERING: lo Sharpe e' scale-free -> scalare la canonica non lo cambia. + # Cerco lambda t.c. maxDD(lam*canonica) ~= maxDD(ensemble) e confronto CAGR a pari DD. + lams = np.linspace(0.30, 1.00, 141) + dds = np.array([al._dd_ret(can * lam) for lam in lams]) + k = int(np.argmin(np.abs(dds - s_ens["dd"]))) + lam = float(lams[k]); scaled = can * lam + cagr_sc = float(np.prod(1 + scaled.values) ** (365.25 / len(scaled)) - 1) + print(f"\n NULL de-levering: canonica x lambda={lam:.2f} -> maxDD {dds[k]*100:.1f}% " + f"(~ ensemble {s_ens['dd']*100:.1f}%), Sharpe INVARIATO {sh_daily(scaled):.2f}, " + f"CAGR {cagr_sc*100:+.1f}% (ensemble {s_ens['cagr']*100:+.1f}%)") + # LENTE ONESTA sul numero dell'ensemble: lo Sharpe daily (sopra) e' GONFIATO dalla + # convenzione del lump — le 7 fasi lumpano su giorni DISGIUNTI (corr daily ~0 per + # costruzione) ma i 7 spread concorrenti condividono ~6/7 dell'esposizione economica. + # Ricompound a blocchi di 7 giorni (tutti gli offset) = P&L settimanale del libro + # sfalsato con la correlazione cross-fase RIPRISTINATA. + def sh_7d_blocks(s: pd.Series) -> np.ndarray: + lr = np.log1p(s.values) + shs = [] + for o in range(7): + m = (len(lr) - o) // 7 + if m < 30: + continue + w = np.expm1(lr[o:o + 7 * m].reshape(m, 7).sum(axis=1)) + shs.append(float(w.mean() / w.std() * np.sqrt(WKY)) if w.std() > 0 else 0.0) + return np.array(shs) + print("\n LENTE ONESTA (Sharpe su blocchi 7g non-overlap, banda sui 7 offset di blocco):") + for name, s in (("CANONICA fase-0", can), ("ENSEMBLE 7 fasi", ens)): + vf = sh_7d_blocks(s); vh = sh_7d_blocks(s[s.index >= HOLDOUT]) + print(f" {name:<20} FULL min/med/max {vf.min():.2f}/{np.median(vf):.2f}/{vf.max():.2f}" + + (f" | HOLD {vh.min():.2f}/{np.median(vh):.2f}/{vh.max():.2f}" if len(vh) else "")) + print(" -> il numero citabile dell'ensemble e' quello a blocchi 7g, NON lo Sh daily del lump.") + print(" NB: anche il maxDD (canonica E ensemble) e' su equity a date di REGOLAMENTO —") + print(" il mark-to-market intra-settimana degli spread aperti non e' catturato (caveat noto).") + beats = (s_ens["sh"] > s_can["sh"] + 1e-9) and (s_ens["cagr"] > cagr_sc + 1e-9) + print(f"\n -> l'ensemble batte il de-levering? {'SI' if beats else 'NO'} " + f"(serve Sh_ens > Sh_can E CAGR_ens > CAGR de-levered a pari DD; il CAGR e' " + f"convention-free, lo Sh daily dell'ensemble NO — vedi lente onesta)") + print(" NB deploy: a $600-2k un solo spread min-size satura il libro -> 7 tranche da 1/7") + print(" NON sono eseguibili (stesso muro del tranching TP01); l'ensemble e' una LENTE di") + print(" misura (de-lucking della stima), non una modifica proponibile del book.") + return dict(ens=s_ens, can=s_can, lam=lam, cagr_scaled=cagr_sc, beats=beats) + + +# =========================================================================== +# PART 4 — block-bootstrap P(spike) (metodo r0702_skeptic_offset) +# =========================================================================== +def _sh_mat(R: np.ndarray) -> np.ndarray: + mu = R.mean(axis=1); sd = R.std(axis=1) + with np.errstate(divide="ignore", invalid="ignore"): + out = np.where(sd > 0, mu / sd, 0.0) + return np.nan_to_num(out) * np.sqrt(365.25) + + +def block_boot(M: np.ndarray, B: int, block: int, rng) -> dict: + n, K = M.shape + nblocks = int(np.ceil(n / block)) + g0s, gmaxs = [], [] + done = 0 + while done < B: + b = min(500, B - done) + starts = rng.integers(0, n, size=(b, nblocks)) + idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n + idx = idx.reshape(b, -1)[:, :n] + R = M[idx] + Sh = np.stack([_sh_mat(R[:, :, k]) for k in range(K)], axis=1) + med_others = np.empty_like(Sh) + for h in range(K): + others = np.delete(Sh, h, axis=1) + med_others[:, h] = np.median(others, axis=1) + g = Sh - med_others + g0s.append(g[:, 0]); gmaxs.append(g.max(axis=1)) + done += b + return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs)) + + +def part4() -> dict: + print("\n" + "=" * 100) + print("PART 4 — BLOCK-BOOTSTRAP: lo spike della fase canonica e' speciale o massimo atteso di 7?") + print("=" * 100) + M = phase_matrix(1.0) + out = {} + for wname, W in (("HOLD-OUT 2025+", M[M.index >= HOLDOUT]), + ("FULL 2021-26", M)): + Mv = W.values + sh = _sh_mat(Mv.T) + g0 = float(sh[0] - np.median(sh[1:])) + corr = np.corrcoef(Mv.T); iu = np.triu_indices(len(PHASES), 1) + print(f"\n [{wname}] {Mv.shape[0]} giorni | Sh fase-0 {sh[0]:+.3f}, mediana altre " + f"{np.median(sh[1:]):+.3f}, spike g0 = {g0:+.3f} | corr daily fra fasi: " + f"mediana {np.median(corr[iu]):+.3f} (lump su giorni diversi -> quasi-ortogonali " + f"per costruzione)") + for blk in (14, 28, 56): + bs = block_boot(Mv, B_BOOT, blk, np.random.default_rng(73 + blk)) + p_any = float(np.mean(bs["gmax"] >= g0)) + p_le0 = float(np.mean(bs["g0"] <= 0.0)) + ci = np.percentile(bs["g0"], [2.5, 97.5]) + print(f" block={blk:>2}: P(spike di UNA QUALSIASI fase >= {g0:+.2f}) = {p_any:.3f}" + f" | P(g0<=0) = {p_le0:.3f} | CI95 g0 [{ci[0]:+.2f},{ci[1]:+.2f}]") + out[(wname, blk)] = p_any + # finestre annuali: la fase migliore e' stabile o gira? + print("\n finestre annuali — Sh per fase, pctl della canonica, best-fase:") + print(f" {'finestra':<9}" + "".join(f"{'p'+str(p):>8}" for p in PHASES) + + f"{'pctl p0':>9}{'best':>6}") + idx_years = sorted(set(M.index.year)) + for y in idx_years: + W = M[M.index.year == y] + if len(W) < 60: + continue + sh = _sh_mat(W.values.T) + print(f" {y:<9}" + "".join(f"{v:>8.2f}" for v in sh) + + f"{pctl_of(sh, sh[0]):>8.0f}°{int(np.argmax(sh)):>6}") + return out + + +# =========================================================================== +# PART 5 — impatto sul book 5-sleeve +# =========================================================================== +def part5(rows: dict) -> dict: + print("\n" + "=" * 100) + print("PART 5 — BOOK 5-SLEEVE (TP 33/XS 15/VRP 12/SKH 20/GTAA 20) con VRP01 per fase") + print("=" * 100) + from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns, + _tp01_returns, _xsec_returns) + tp = to_daily(_tp01_returns()) + fixed = dict(TP=tp, XS=to_daily(_xsec_returns()), SKH=to_daily(_skyhook_returns()), + GTAA=to_daily(_gtaa_daily_returns())) + W = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20) + lo = tp.index.min() + + def book(vrp_daily): + s = combine_outer(dict(fixed, VRP=vrp_daily), W, lo=lo) + return dict(sh=al._sh(s), sh_h=al._sh(s[s.index >= HOLDOUT]), dd=al._dd_ret(s), + dd_h=al._dd_ret(s[s.index >= HOLDOUT])) + + res = {} + print(f" {'variante VRP':<22}{'ShFULL':>8}{'ShHOLD':>8}{'maxDD':>8}{'DD hold':>9}") + for p in PHASES: + res[p] = book(to_daily_lumped(rows[p]["wk"])) + tag = " <- CANONICA (book corrente)" if p == 0 else "" + print(f" fase {p} ({DAYNAME[rows[p]['entry_wd']]}){'':<10}{res[p]['sh']:>8.2f}" + f"{res[p]['sh_h']:>8.2f}{res[p]['dd']*100:>7.1f}%{res[p]['dd_h']*100:>8.1f}%{tag}") + M = phase_matrix(1.0) + res["ens"] = book(M.mean(axis=1)) + print(f" {'ENSEMBLE 7 fasi':<22}{res['ens']['sh']:>8.2f}{res['ens']['sh_h']:>8.2f}" + f"{res['ens']['dd']*100:>7.1f}%{res['ens']['dd_h']*100:>8.1f}%") + + for key, label in (("sh", "ShFULL"), ("sh_h", "ShHOLD"), ("dd", "maxDD")): + v = np.array([res[p][key] for p in PHASES]) + print(f" banda book {label:<7}: min {v.min():.3f} / mediana {np.median(v):.3f} / " + f"max {v.max():.3f} | canonica {v[0]:.3f} (pctl {pctl_of(v, v[0]):.0f}°)") + print(" (il peso VRP e' 12%: la banda di fase si diluisce ~0.12 nel book — atteso stretto)") + return res + + +# =========================================================================== +# PART 6 — ri-verdetto dei numeri di ammissione alla fase mediana +# =========================================================================== +def part6(rows: dict) -> None: + print("\n" + "=" * 100) + print("PART 6 — RI-VERDETTO dei numeri di ammissione VRP01 (FULL 1.10 / HOLD 0.60 / DD 12%)") + print("=" * 100) + sf = np.array([rows[p]["sh"] for p in PHASES]) + shh = np.array([rows[p]["sh_h"] for p in PHASES]) + dd = np.array([rows[p]["dd"] for p in PHASES]) + wo = np.array([rows[p]["worst"] for p in PHASES]) + print(f" Sh FULL : dichiarato {ADMISSION['full']:.2f} | canonica {sf[0]:.2f} " + f"(pctl {pctl_of(sf, sf[0]):.0f}°) | mediana fasi {np.median(sf):.2f} | " + f"banda [{sf.min():.2f}, {sf.max():.2f}]") + print(f" Sh HOLD : dichiarato {ADMISSION['hold']:.2f} | canonica {shh[0]:.2f} " + f"(pctl {pctl_of(shh, shh[0]):.0f}°) | mediana fasi {np.median(shh):.2f} | " + f"banda [{shh.min():.2f}, {shh.max():.2f}]") + print(f" maxDD : dichiarato {ADMISSION['dd']*100:.0f}% | canonica {dd[0]*100:.1f}% " + f"(pctl {pctl_of(dd, dd[0]):.0f}°) | mediana fasi {np.median(dd)*100:.1f}% | " + f"banda [{dd.min()*100:.1f}%, {dd.max()*100:.1f}%]") + print(f" worst-wk: canonica {wo[0]*100:+.2f}% | mediana {np.median(wo)*100:+.2f}% | " + f"peggiore {wo.min()*100:+.2f}%") + print("\n Regola 2026-07-02: i numeri di VRP01 si citano d'ora in poi CON la banda di fase.") + + +def main() -> None: + part0() + rows = part1() + part2() + part3(rows) + part4() + part5(rows) + part6(rows) + print("\nFatto (r0703_vrpimp_anchor).") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_ddoverlay.py b/scripts/research/r0703_vrpimp_ddoverlay.py new file mode 100644 index 0000000..4e6801b --- /dev/null +++ b/scripts/research/r0703_vrpimp_ddoverlay.py @@ -0,0 +1,323 @@ +"""R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 canonico vs NULL DEL DE-LEVERING. + +FILONE 2 (ondata 2026-07-03): su VRP01 canonico (put credit spread settimanale -0.28/-0.10, +gate vrp>0 + ivr>=0.30 + crash-skip 0.90, riprodotto ESATTO dal motore path qui sotto e +bridge-validato vs options_vrp_v2.vrp_spread_weekly) si testano 4 famiglie di overlay: + + (a) EXIT INTRA-SETTIMANA su spike: DVOL sale >X% dall'ingresso, oppure RV5 causale supera + m x DVOL d'ingresso -> chiudi lo spread al close del giorno del trigger (MTM BS). + (b) STOP-LOSS sul MTM: esci se il valore dello spread raggiunge mult x credito incassato. + (c) ALA DI CODA comprata: q put molto piu' OTM (delta -0.05/-0.03) finanziate col credito + (ratio/broken-wing-ish), tenute a scadenza. + (d) COOLDOWN: salta c settimane di calendario dopo una settimana attiva in perdita. + +REGOLA VINCOLANTE (lezione TP01xDVOL 2026-06-26): ogni claim di riduzione DD si confronta col +NULL DEL DE-LEVERING — lo scaling s della size del baseline che produce lo STESSO maxDD. Lo +Sharpe e' scale-invariante => il de-levered ha SEMPRE lo Sharpe del baseline: un overlay "vale" +solo se il suo Sharpe batte quello del baseline A PARI maxDD (e regge banda f + multi-cut + DSR). + +⚠️ LENS DICHIARATA (in testa, non in fondo): + - Granularita' 1d: i trigger (a)/(b) si valutano al CLOSE giornaliero e si esegue a QUEL close + -> il gap-through oltre il livello di stop E' DENTRO il fill (onesto), ma lo spike intraday + peggiore del close NON e' modellato (lezione SKH01: a 1d lo stop e' una lente, non un fill). + - MTM Black-Scholes FLAT sulla DVOL-30g usata per tenor <=7g: in stress il front-end esplode + piu' della 30g e lo skew irripidisce -> il MTM avverso e' SOTTOSTIMATO => gli stop scattano + piu' tardi e costano piu' del modellato. Percio' banda f {0.6,0.8,1.0,1.3} obbligatoria. + - Booking: il pnl di ogni trade (anche se chiuso prima) e' registrato alla data di SCADENZA + -> griglia settimanale identica al baseline, Sharpe confrontabile (lente, non timing reale). + - Equity settimanale compounding (convenzione VRP01): DD/worst-week su quella lente. + - Banda d'ancora (regola 2026-07-02): loop settimanale ancorato a i0=60 -> 7 offset riportati. + +Regola standing: niente short-vol da modello in deploy — obiettivo = conoscenza/LEAD. +Selezione celle SOLO in-sample (pre-2025); hold-out 2025-26 mai usato per scegliere; DSR sul +numero totale di celle (14). + + uv run python scripts/research/r0703_vrpimp_ddoverlay.py +""" +from __future__ import annotations +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) +import numpy as np, pandas as pd +from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year +from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30 + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +WK = 365.25 / 7.0 +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +FEE_FRAC = 0.125 # convenzione VRP01: 12.5% del premio netto per il round-trip base +TENOR = 7 + + +def _rv5(px: np.ndarray, j: int) -> float: + """RV annualizzata causale dagli ultimi 5 rendimenti giornalieri (fino a j incluso).""" + if j < 6: + return np.nan + r = np.diff(np.log(px[j - 5:j + 1])) + return float(np.std(r) * np.sqrt(365.25)) + + +def vrp_path_weekly(asset, f=1.0, overlay=None, i0=60, collect=None): + """VRP01 canonico con path MTM giornaliero. overlay=None riproduce vrp_spread_weekly + (defined_risk, gate vrp>0 + ivr>=0.30 + crash_skip 0.90) ESATTO (bridge sotto). + + overlay: ('spike_dvol', x) | ('spike_rv', m) | ('sl', mult) | ('tail', delta, q) | ('cool', c) + Ritorna Series settimanale di rendimenti su capitale = strike corto (convenzione VRP01). + """ + J = load_series(asset) + px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index + n = len(px); T = TENOR / 365.25 + kind = overlay[0] if overlay else None + rets = {} + cool_left = 0 + i = i0 + while i + TENOR < n: + S0 = px[i]; sig = dv[i] + # --- cooldown (d): decisione causale dal risultato del trade precedente gia' chiuso --- + if kind == "cool" and cool_left > 0: + cool_left -= 1 + rets[idx[i + TENOR]] = 0.0 + i += TENOR + continue + # --- gate canonici VRP01 (identici a vrp_spread_weekly, NON riottimizzati) --- + skip = False + rv = _rv30(px, i) + if not np.isnan(rv) and (sig - rv) <= 0: + skip = True + ivr = _ivrank(dv, i) + if not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90): + skip = True + if skip: + rets[idx[i + TENOR]] = 0.0 + i += TENOR + continue + # --- struttura canonica --- + Ks = strike_from_delta(S0, T, sig, -0.28) + Kl = strike_from_delta(S0, T, sig, -0.10) + net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * f + base_fee = FEE_FRAC * abs(net_prem) + # --- ala di coda (c): q put extra-OTM a stessa scadenza, pagata col credito --- + tail_cost = tail_fee = 0.0; K_tail = None; q = 0 + if kind == "tail": + _, dlt, q = overlay + K_tail = strike_from_delta(S0, T, sig, dlt) + prem_t = bs_put(S0, K_tail, T, sig) * f + tail_cost = q * prem_t + tail_fee = q * min(0.0003 * S0, 0.125 * max(prem_t, 1e-12)) + if collect is not None: + collect.setdefault("tail_frac", []).append(tail_cost / max(net_prem, 1e-12)) + # --- path MTM giornaliero: exit intra-settimana (a)/(b) al primo trigger, a QUEL close --- + exited = False + if kind in ("spike_dvol", "spike_rv", "sl"): + for j in range(i + 1, i + TENOR): + T_rem = (i + TENOR - j) / 365.25 + trig = False + if kind == "spike_dvol": + trig = (dv[j] / sig - 1.0) > overlay[1] + elif kind == "spike_rv": + r5 = _rv5(px, j) + trig = (not np.isnan(r5)) and (r5 > overlay[1] * sig) + elif kind == "sl": + Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f + trig = Vj >= overlay[1] * net_prem + if trig: + Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f + exit_fee = min(0.0006 * px[j], 0.125 * max(Vj, 1e-12)) # 2 gambe buyback + pnl = net_prem - Vj - base_fee - exit_fee + rets[idx[i + TENOR]] = pnl / Ks # booking a scadenza (lens dichiarata) + if collect is not None: + collect.setdefault("exits", []).append((idx[j], (net_prem - Vj) / Ks)) + exited = True + break + if not exited: + S1 = px[i + TENOR] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + pnl = net_prem - payoff - base_fee + if kind == "tail": + pnl += q * max(0.0, K_tail - S1) - tail_cost - tail_fee + rets[idx[i + TENOR]] = pnl / Ks + if kind == "cool" and rets[idx[i + TENOR]] < 0: + cool_left = overlay[1] + i += TENOR + return pd.Series(rets) + + +def book(f=1.0, overlay=None, i0=60, collect=None): + rB = vrp_path_weekly("BTC", f=f, overlay=overlay, i0=i0, collect=collect) + rE = vrp_path_weekly("ETH", f=f, overlay=overlay, i0=i0, collect=collect) + return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + + +def maxdd(r): + eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(r) else 0.0 + + +def delever_scale(r_base, dd_target, hi=5.0): + """Scaling s della size del baseline che produce maxDD == dd_target (bisezione).""" + lo = 0.0 + for _ in range(80): + mid = (lo + hi) / 2 + if maxdd(mid * r_base) > dd_target: + hi = mid + else: + lo = mid + return (lo + hi) / 2 + + +def mstats(r): + full = m_weekly(r); ho = m_weekly(r[r.index >= HOLDOUT]); isam = m_weekly(r[r.index < HOLDOUT]) + return dict(sh=full["sh"], sh_h=ho["sh"], sh_is=isam["sh"], cagr=full["cagr"], + dd=full["dd"], worst=float(r.min()) if len(r) else 0.0) + + +def fmt(name, m, extra=""): + print(f" {name:<34} ShF {m['sh']:>5.2f} ShH {m['sh_h']:>5.2f} Sh-IS {m['sh_is']:>5.2f} " + f"CAGR {m['cagr']*100:>+5.1f}% DD {m['dd']*100:>4.1f}% worst {m['worst']*100:>+5.1f}%{extra}") + + +OVERLAYS = { + "a-spike": [("spike_dvol", 0.15), ("spike_dvol", 0.25), ("spike_dvol", 0.40), + ("spike_rv", 1.00), ("spike_rv", 1.25)], + "b-stoploss": [("sl", 1.5), ("sl", 2.0), ("sl", 3.0)], + "c-tailwing": [("tail", -0.05, 1), ("tail", -0.05, 2), ("tail", -0.03, 1), ("tail", -0.03, 2)], + "d-cooldown": [("cool", 1), ("cool", 2)], +} + + +def olabel(ov): + k = ov[0] + if k == "spike_dvol": + return f"exit DVOL +{ov[1]*100:.0f}% da entry" + if k == "spike_rv": + return f"exit RV5 > {ov[1]:.2f}x DVOL entry" + if k == "sl": + return f"stop-loss MTM {ov[1]:.1f}x credito" + if k == "tail": + return f"tail put d={ov[1]:+.2f} q={ov[2]}" + return f"cooldown {ov[1]} settimana/e" + + +def main(): + print("=" * 112) + print(" R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 vs NULL DEL DE-LEVERING") + print(" Lens: MTM BS flat su DVOL-30g, trigger e fill al close 1d (gap-through nel fill, spike") + print(" intraday e term-structure di stress NON modellati -> stop piu' lenti/cari del reale).") + print(" Booking a scadenza su griglia settimanale. Selezione celle SOLO in-sample (pre-2025).") + print("=" * 112) + for a in ("BTC", "ETH"): + J = load_series(a) + print(f" {a}: dati {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)") + print(" Eventi chiave nel campione: 2021-05 (crash Cina), 2022-05 (LUNA), 2022-11 (FTX).") + + # ------------------------------------------------------------- (0) bridge di validazione + print("\n (0) BRIDGE: motore path (overlay=None) vs options_vrp_v2.vrp_spread_weekly (VRP01)") + base = {} + for f in F_SWEEP: + canon = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90), + "E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90)}, + axis=1, join="inner").mean(axis=1) + mine = book(f=f) + Jn = pd.concat({"c": canon, "m": mine}, axis=1, join="inner") + dmax = float((Jn["c"] - Jn["m"]).abs().max()) + base[f] = mine + if f == 1.0: + print(f" f={f}: max|diff| = {dmax:.2e} su {len(Jn)} settimane " + f"{'OK (riproduzione esatta)' if dmax < 1e-12 else '⚠️ MISMATCH'}") + + print("\n (1) BASELINE VRP01 canonico — banda f") + for f in F_SWEEP: + fmt(f"VRP01 f={f}", mstats(base[f])) + pyb = per_year(base[1.0]) + print(" per-anno f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyb.items()))) + anchor_sh = [m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)] + print(f" banda d'ancora (7 offset del loop settimanale): ShF in " + f"[{min(anchor_sh):.2f}, {max(anchor_sh):.2f}] mediana {np.median(anchor_sh):.2f}") + + # ------------------------------------------------------------- (2) griglia overlay f=1.0 + print(f"\n (2) GRIGLIA OVERLAY (f=1.0) — 14 celle, selezione per-famiglia su Sh-IS (pre-2025)") + m0 = mstats(base[1.0]) + all_full_sh = [m0["sh"]] + cells = {} + best = {} + for fam, ovs in OVERLAYS.items(): + for ov in ovs: + col = {} + r = book(f=1.0, overlay=ov, collect=col) + m = mstats(r) + cells[ov] = (r, m, col) + all_full_sh.append(m["sh"]) + n_ex = len(col.get("exits", [])) + extra = f" dSh {m['sh']-m0['sh']:+.2f}" + if n_ex: + extra += f" early-exit n={n_ex}" + if col.get("tail_frac"): + extra += f" costo ala = {np.mean(col['tail_frac'])*100:.0f}% del credito" + fmt(f"[{fam}] {olabel(ov)}", m, extra) + if fam not in best or m["sh_is"] > cells[best[fam]][1]["sh_is"]: + best[fam] = ov + print() + + # ------------------------------------------------------------- (3) best-IS per famiglia: banda f + NULL de-levering + print(" (3) CELLA BEST-IN-SAMPLE PER FAMIGLIA — banda f + CONFRONTO OBBLIGATORIO col null de-levering") + print(" (de-lever = s x baseline con lo STESSO maxDD; lo Sharpe del de-levered = Sharpe baseline,") + print(" scale-invariante: l'overlay vale SOLO se ShF_overlay > ShF_base a pari DD)") + import altlib as al + verdicts = {} + for fam, ov in best.items(): + print(f"\n --- {fam}: {olabel(ov)} ---") + print(f" {'f':>4} | {'overlay: ShF':>12} {'ShH':>6} {'DD':>6} {'worst':>7} | " + f"{'de-lever s':>10} {'ShF':>6} {'CAGR':>7} {'worst':>7} | {'dSh a pari DD':>13}") + wins = 0 + for f in F_SWEEP: + r = book(f=f, overlay=ov) if f != 1.0 else cells[ov][0] + m = mstats(r); mb = mstats(base[f]) + s = delever_scale(base[f], m["dd"]) + rd = s * base[f]; md = mstats(rd) + dsh = m["sh"] - md["sh"] + wins += dsh > 0.05 + print(f" {f:>4} | {m['sh']:>12.2f} {m['sh_h']:>6.2f} {m['dd']*100:>5.1f}% {m['worst']*100:>+6.1f}% | " + f"{s:>10.2f} {md['sh']:>6.2f} {md['cagr']*100:>+6.1f}% {md['worst']*100:>+6.1f}% | {dsh:>+13.2f}") + # multi-cut: dSh per finestra annuale disgiunta (f=1.0) — persistenza, non finestra fortunata + r1 = cells[ov][0] + dcut = {} + for y in (2021, 2022, 2023, 2024, 2025): + sel = r1.index.year == y if y < 2025 else r1.index >= HOLDOUT + selb = base[1.0].index.year == y if y < 2025 else base[1.0].index >= HOLDOUT + dcut[y] = m_weekly(r1[sel])["sh"] - m_weekly(base[1.0][selb])["sh"] + npos = sum(v > 0 for y, v in dcut.items() if y < 2025) + print(" multi-cut dSh: " + " ".join(f"{y}:{v:+.2f}" for y, v in dcut.items()) + + f" ({npos}/4 finestre IS positive)") + dsr, null_max = al.deflated_sharpe(cells[ov][1]["sh"], all_full_sh, + cells[ov][0].values, dpy=WK) + print(f" deflated-Sharpe (14 celle + base): DSR {dsr:.3f} (null-max {null_max:.2f}) " + f"{'PASS' if dsr >= 0.95 else 'FAIL'}") + aband = [m_weekly(book(f=1.0, overlay=ov, i0=60 + o))["sh"] + - m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)] + print(f" banda d'ancora dSh (7 offset): [{min(aband):+.2f}, {max(aband):+.2f}] " + f"mediana {np.median(aband):+.2f}") + survives = (wins == len(F_SWEEP)) and npos >= 3 and dsr >= 0.95 and np.median(aband) > 0 + verdicts[fam] = dict(ov=ov, survives=survives, wins_f=int(wins), multicut=dcut, + dsr=float(dsr), anchor=(min(aband), max(aband))) + print(f" => {'SOPRAVVIVE al null de-levering' if survives else 'NON batte il de-levering'} " + f"(pari-DD win {wins}/4 f, multicut {npos}/4, DSR {dsr:.2f})") + + # ------------------------------------------------------------- (4) per-anno + print("\n (4) PER-ANNO (f=1.0) — 2021 (crash 5/2021) e 2022 (LUNA+FTX) sono il banco di prova") + rows = [("VRP01 base", base[1.0])] + [(f"{fam} best", cells[best[fam]][0]) for fam in OVERLAYS] + for tag, r in rows: + py = per_year(r) + print(f" {tag:<14} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items()))) + + print("\n VERDETTO: " + "; ".join( + f"{fam} {'PASS' if v['survives'] else 'FAIL'}" for fam, v in verdicts.items())) + print(" Se tutti FAIL: la protezione DD su VRP01 si compra meglio con la SIZE (de-levering),") + print(" coerente con TP01xDVOL 2026-06-26. Regola standing: niente short-vol da modello in deploy.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_newgates.py b/scripts/research/r0703_vrpimp_newgates.py new file mode 100644 index 0000000..26062ef --- /dev/null +++ b/scripts/research/r0703_vrpimp_newgates.py @@ -0,0 +1,444 @@ +"""R0703 VRPIMP-NEWGATES — gate d'ingresso NUOVI (veto additivi) su VRP01 canonico, barra alta. + +FILONE 4 (ondata 2026-07-03). TRE raffinamenti del gate sono GIA' falliti (2026-07-01, +r0701_vrp_refine: sizing sul gap IV-RV, filtro DVOL-momentum, gate di regime TP01) e qui NON +si ritestano. Solo angoli mai provati, sempre come VETO ADDITIVO sopra il gate canonico +(vrp>0 AND ivr>=0.30 AND ivr<=0.90 — MAI riottimizzato, lezione 2026-07-01): + + (a) TERM STRUCTURE del DVOL (contango=vendi / backwardation=fuori). PRIMA il dato: la storia + per-scadenza NON e' pubblica su Deribit (probe_vol_termstructure 2026-06-29); il logger + forward (log_vol_termstructure -> data/raw/vol_term_*.parquet) accumula da fine giugno. + Se la storia e' < ~180 giorni il gate NON e' testabile e si DICHIARA. L'unico stand-in + ricostruibile dai nostri dati e' TSPROXY = DVOL30 - RV7 (pseudo-slope implied-30g vs + realized-7g): dichiaratamente NON una term structure (e' il gate VRP canonico con + finestra RV corta) — incluso in griglia solo per completezza, etichettato PROXY. + (b) VOL-OF-VOL: std rolling delle variazioni giornaliere del DVOL (k=10/20 gg), messa in + percentile espandente CAUSALE; alto = regime vol instabile -> veto (stai fuori). + (Diverso dal DVOL-momentum fallito il 2026-07-01: la' era il LIVELLO in salita, qui la + VOLATILITA' del DVOL, direzione-agnostica.) + (c) ACCELERAZIONE della RV: RV5/RV20 causale > soglia (vol che accelera = precursore di + crash) -> veto. Anche in versione percentile espandente. + (d) AND pre-dichiarati: canonico + veto(b) + veto(c), 4 combo FISSE scritte nel codice + prima di guardare qualunque risultato. + +METODO (vincolante, regole comuni ondata): + - selezione soglie SOLO in-sample (pre-2025); hold-out 2025-26 mai usato per scegliere; + - multi-cut: uplift vs baseline a 5 date di taglio (>=4/5 richiesti; >=3 = soglia minima + del filone) + 4 finestre DISGIUNTE; + - deflated-Sharpe sul TOTALE delle celle provate (18, baseline inclusa) — e comunque + anti-conservativo (celle correlate + VRP01 stesso viene da ~20 config storiche); + - NULL DEL DE-LEVERING esplicito (lezione TP01xDVOL 2026-06-26): lo Sharpe e' scale-invariante, + quindi un veto "vale" solo se batte lo Sharpe del baseline A PARI maxDD; + - banda f {0.6, 0.8, 1.0, 1.3} (pricing BS flat su DVOL ATM, skew non modellato); + - banda d'ancora obbligatoria (regola 2026-07-02): loop settimanale ancorato a i0=60 -> + 7 offset i0 in {60..66} riportati per baseline e cella scelta. + +Motore: riproduzione ESATTA di options_vrp_v2.vrp_spread_weekly (bridge-check a diff=0 nel +main) + veto parametrici. Fee: 12.5% del premio netto (convenzione VRP01). Capitale = strike +corto (cash-secured). Regola standing: niente short-vol da modello in deploy — esito massimo += LEAD/conoscenza. Aspettativa dichiarata: FALLIRE (3 precedenti su 3); la mappa dei +fallimenti E' il deliverable. + + uv run python scripts/research/r0703_vrpimp_newgates.py +""" +from __future__ import annotations +import sys +from pathlib import Path +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 collections import Counter +from functools import lru_cache +import numpy as np, pandas as pd +from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year +from scripts.research.options_vrp_v2 import vrp_spread_weekly +from altlib import deflated_sharpe + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +WK = 365.25 / 7.0 +TENOR = 7 +FEE_FRAC = 0.125 # convenzione VRP01: 12.5% del premio netto round-trip +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +CUTS = [pd.Timestamp(c, tz="UTC") for c in + ("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")] +WINDOWS = [("2021-2022", None, "2023-01-01"), ("2023", "2023-01-01", "2024-01-01"), + ("2024", "2024-01-01", "2025-01-01"), ("2025+", "2025-01-01", None)] +MIN_IS_ACTIVE = 0.20 # baseline ~41% attivo +SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad") + + +# ------------------------------------------------------------------ features causali +def _expanding_pctl(x: np.ndarray, min_hist: int = 60) -> np.ndarray: + """pctl[i] = quota della storia PASSATA valida x[:i] sotto x[i] (espandente, causale).""" + n = len(x); out = np.full(n, np.nan) + for i in range(n): + if np.isnan(x[i]): + continue + h = x[:i]; h = h[~np.isnan(h)] + if len(h) >= min_hist: + out[i] = float((h < x[i]).mean()) + return out + + +@lru_cache(maxsize=None) +def prep(asset: str): + """Tutte le feature usano SOLO dati <= close[i] (sell-date). lr[k]=log(px[k+1]/px[k]) => + lr[i-w:i] sono i w rendimenti che finiscono a close[i] (stessa convenzione _rv30 di VRP01).""" + J = load_series(asset) + px = J["px"].values.astype(float) + dv = J["dvol"].values.astype(float) / 100.0 + idx = J.index + n = len(px) + lr = np.diff(np.log(px)) + + def _rv(i, w): + if i < w + 1: + return np.nan + return float(np.std(lr[i - w:i]) * np.sqrt(365.25)) + + rv5 = np.array([_rv(i, 5) for i in range(n)]) + rv7 = np.array([_rv(i, 7) for i in range(n)]) + rv20 = np.array([_rv(i, 20) for i in range(n)]) + rv30 = np.array([_rv(i, 30) for i in range(n)]) + ivr = np.full(n, np.nan) + for i in range(60, n): + ivr[i] = float((dv[:i] < dv[i]).mean()) + # (b) vol-of-vol: std delle ultime k variazioni giornaliere del DVOL (ultima = dv[i]-dv[i-1]) + ddv = np.diff(dv) + def _vv(i, k): + if i < k + 1: + return np.nan + return float(np.std(ddv[i - k:i])) + vv = {k: np.array([_vv(i, k) for i in range(n)]) for k in (10, 20)} + vvp = {k: _expanding_pctl(vv[k]) for k in (10, 20)} + # (c) accelerazione RV + with np.errstate(invalid="ignore", divide="ignore"): + rvacc = rv5 / rv20 + rap = _expanding_pctl(rvacc) + # (a-proxy) pseudo-slope: DVOL30 - RV7 (PROXY, non term structure) + tsproxy = dv - rv7 + tsp = _expanding_pctl(tsproxy) + return dict(px=px, dv=dv, idx=idx, n=n, rv30=rv30, ivr=ivr, + vvp=vvp, rvacc=rvacc, rap=rap, tsproxy=tsproxy, tsp=tsp) + + +# ------------------------------------------------------------------ motore settimanale +def _veto_hit(P, i, veto) -> bool: + """True se il veto scatta al giorno i (NaN = veto non applicabile -> non scatta).""" + kind = veto[0] + if kind == "vv": # ("vv", k, pctl_thr) + v = P["vvp"][veto[1]][i] + return (not np.isnan(v)) and v > veto[2] + if kind == "ra": # ("ra", ratio_thr) + v = P["rvacc"][i] + return (not np.isnan(v)) and v > veto[1] + if kind == "rap": # ("rap", pctl_thr) + v = P["rap"][i] + return (not np.isnan(v)) and v > veto[1] + if kind == "ts_bin": # backwardation-proxy: RV7 sopra DVOL30 + v = P["tsproxy"][i] + return (not np.isnan(v)) and v <= 0.0 + if kind == "ts_pctl": # ("ts_pctl", thr): slope-proxy nel fondo percentile + v = P["tsp"][i] + return (not np.isnan(v)) and v < veto[1] + raise ValueError(kind) + + +def vrp_weekly(asset: str, vetoes=(), f: float = 1.0, i0: int = 60, + collect: dict | None = None) -> tuple[pd.Series, Counter]: + """VRP01 canonico (riproduce vrp_spread_weekly gated ESATTO con vetoes=()) + veto additivi. + I veto sono valutati DOPO i gate canonici -> il counter conta l'intervento MARGINALE + (settimane altrimenti tradate). Causale: gate/strike/premio a sell-date, payoff a scadenza.""" + P = prep(asset) + px, dv, idx, n = P["px"], P["dv"], P["idx"], P["n"] + T = TENOR / 365.25 + rets = {}; st = Counter() + i = i0 + while i + TENOR < n: + st["weeks"] += 1 + S0 = px[i]; sig = dv[i] + # --- gate canonici (identici a options_vrp_v2, NON riottimizzati) --- + rv = P["rv30"][i]; ivr = P["ivr"][i] + skip = ((not np.isnan(rv) and (sig - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + if skip: + st["blk_canon"] += 1 + rets[idx[i + TENOR]] = 0.0 + i += TENOR + continue + # --- veto NUOVI (marginali) --- + hit = None + for v in vetoes: + if _veto_hit(P, i, v): + hit = v[0] + break + if hit is not None: + st[f"blk_{hit}"] += 1 + if collect is not None: + collect.setdefault("vetoed_i", []).append((asset, i)) + rets[idx[i + TENOR]] = 0.0 + i += TENOR + continue + st["traded"] += 1 + # --- struttura canonica: put credit spread -0.28/-0.10, capitale = strike corto --- + Ks = strike_from_delta(S0, T, sig, -0.28) + Kl = strike_from_delta(S0, T, sig, -0.10) + net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * f + S1 = px[i + TENOR] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + pnl = net_prem - payoff - FEE_FRAC * abs(net_prem) + rets[idx[i + TENOR]] = pnl / Ks + i += TENOR + return pd.Series(rets), st + + +def book(vetoes=(), f: float = 1.0, i0: int = 60) -> tuple[pd.Series, Counter]: + rB, sB = vrp_weekly("BTC", vetoes=vetoes, f=f, i0=i0) + rE, sE = vrp_weekly("ETH", vetoes=vetoes, f=f, i0=i0) + b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index() + return b, sB + sE + + +# ------------------------------------------------------------------ metriche / null +def sh_wk(r: pd.Series) -> float: + r = r.dropna() + if len(r) < 8 or r.std() == 0: + return float("nan") + return float(r.mean() / r.std() * np.sqrt(WK)) + + +def mstats(r: pd.Series) -> dict: + full = m_weekly(r) + return dict(sh=full["sh"], dd=full["dd"], cagr=full["cagr"], + sh_is=sh_wk(r[r.index < HOLDOUT]), sh_h=sh_wk(r[r.index >= HOLDOUT]), + worst=float(r.min()) if len(r) else 0.0, active=float((r != 0).mean())) + + +def maxdd(r: pd.Series) -> float: + eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(r) else 0.0 + + +def delever_scale(r_base: pd.Series, dd_target: float, hi: float = 2.0) -> float: + """Scaling s della size del baseline con maxDD == dd_target (bisezione, Sharpe invariante).""" + lo = 0.0 + for _ in range(80): + mid = (lo + hi) / 2 + if maxdd(mid * r_base) > dd_target: + hi = mid + else: + lo = mid + return (lo + hi) / 2 + + +# ------------------------------------------------------------------ griglia (A PRIORI) +def build_grid(): + cells = [("BASELINE canonico", ())] + for k in (10, 20): + for p in (0.70, 0.80, 0.90): + cells.append((f"VV k={k} p>{p:.2f}", (("vv", k, p),))) + for thr in (1.0, 1.25, 1.5): + cells.append((f"RA rv5/rv20>{thr:.2f}", (("ra", thr),))) + for p in (0.80, 0.90): + cells.append((f"RAP pctl>{p:.2f}", (("rap", p),))) + cells.append(("TSPROXY bin (rv7>dvol) [PROXY]", (("ts_bin",),))) + cells.append(("TSPROXY pctl<0.20 [PROXY]", (("ts_pctl", 0.20),))) + # combo AND pre-dichiarate (scritte prima di guardare i risultati) + cells.append(("AND VV(20,.80)+RA(1.25)", (("vv", 20, 0.80), ("ra", 1.25)))) + cells.append(("AND VV(20,.80)+RAP(.80)", (("vv", 20, 0.80), ("rap", 0.80)))) + cells.append(("AND VV(10,.80)+RA(1.25)", (("vv", 10, 0.80), ("ra", 1.25)))) + cells.append(("AND VV(20,.90)+RA(1.50)", (("vv", 20, 0.90), ("ra", 1.50)))) + return cells + + +# ------------------------------------------------------------------ main +def main(): + print("=" * 112) + print(" R0703 VRPIMP-NEWGATES — veto d'ingresso nuovi su VRP01 (term-structure / vol-of-vol / RV-accel / AND)") + print(" Barra: selezione IS-only + multi-cut >=4/5 + DSR>=0.95 + null de-levering + banda f + banda d'ancora") + print("=" * 112) + + # ---------------- (0) angolo (a): fattibilita' del dato term-structure ---------------- + print("\n (0) TERM STRUCTURE DVOL — verifica del dato PRIMA del test") + n_days = {} + for a in ("btc", "eth"): + fp = PROJECT_ROOT / "data" / "raw" / f"vol_term_{a}.parquet" + if fp.exists(): + df = pd.read_parquet(fp) + n_days[a] = len(df) + print(f" vol_term_{a}: {len(df)} righe [{df['date'].min().date()} -> {df['date'].max().date()}] " + f"slope7-180 medio {df['slope_7_180'].mean():+.1f}pp") + else: + n_days[a] = 0 + print(f" vol_term_{a}: NESSUN FILE") + n_ts = min(n_days.values()) if n_days else 0 + print(f" -> storia term-structure = {n_ts} GIORNI (logger forward dal 2026-06-26; serve >=~180g +") + print(" certificazione). La storia per-scadenza NON e' pubblica (probe 2026-06-29) e NON e'") + print(" ricostruibile dai fetch. VERDETTO (a): NON TESTABILE OGGI — rivalutare a ~6-12 mesi di log.") + print(" In griglia resta SOLO il proxy TSPROXY = DVOL30-RV7, dichiarato NON-term-structure") + print(" (e' il gate VRP canonico a finestra RV corta).") + + # ---------------- (1) baseline + bridge ---------------- + base, st_base = book() + ref = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=1.0, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90), + "E": vrp_spread_weekly("ETH", defined_risk=True, f=1.0, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90)}, + axis=1, join="inner").mean(axis=1).sort_index() + al_ = base.align(ref, join="inner") + bridge = float((al_[0] - al_[1]).abs().max()) + mb = mstats(base) + print(f"\n (1) BASELINE VRP01 riprodotto: ShF {mb['sh']:.2f} ShH {mb['sh_h']:.2f} Sh-IS {mb['sh_is']:.2f} " + f"CAGR {mb['cagr']*100:+.1f}% DD {mb['dd']*100:.1f}% worst {mb['worst']*100:+.1f}% attivo {mb['active']*100:.0f}%") + print(f" bridge vs options_vrp_v2.vrp_spread_weekly: max|diff| = {bridge:.2e} " + f"({'OK motore ESATTO' if bridge < 1e-12 else '⚠️ DIVERGE — non fidarsi dei numeri'})") + + # ---------------- (2) diagnostica: i veto rimuovono settimane cattive? (IS only) ---------------- + print("\n (2) DIAGNOSTICA IN-SAMPLE (pre-2025): pnl medio del BASELINE nelle settimane che il veto") + print(" avrebbe bloccato vs tenuto (per-asset, solo settimane canonicamente tradate):") + probes = [("VV k=10 p>.80", ("vv", 10, 0.80)), ("VV k=20 p>.80", ("vv", 20, 0.80)), + ("RA >1.25", ("ra", 1.25)), ("RAP p>.80", ("rap", 0.80)), + ("TSPROXY bin", ("ts_bin",)), ("TSPROXY p<.20", ("ts_pctl", 0.20))] + for label, veto in probes: + cut_v, cut_k = [], [] + for a in ("BTC", "ETH"): + r0, _ = vrp_weekly(a) # baseline per-asset + P = prep(a) + pos = {ts: k for k, ts in enumerate(P["idx"])} + for ts, v in r0.items(): + if v == 0.0: + continue + i_sell = pos[ts] - TENOR + if P["idx"][i_sell] >= HOLDOUT: + continue + (cut_v if _veto_hit(P, i_sell, veto) else cut_k).append(v) + mv = np.mean(cut_v) * 100 if cut_v else float("nan") + mk = np.mean(cut_k) * 100 if cut_k else float("nan") + print(f" {label:<16} bloccate n={len(cut_v):>3} pnl medio {mv:>+6.2f}% | " + f"tenute n={len(cut_k):>3} pnl medio {mk:>+6.2f}% " + f"({'veto sensato IS' if cut_v and mv < mk else 'veto NON discrimina IS'})") + + # ---------------- (3) griglia 18 celle ---------------- + cells = build_grid() + print(f"\n (3) GRIGLIA {len(cells)} celle (tutte nel DSR) — selezione SOLO su Sh-IS (pre-2025):") + print(f" {'cella':<32}{'Sh-IS':>7}{'ShF':>7}{'ShH':>7}{'DD':>7}{'worst':>8}{'att.':>6}{'blk-marg':>9}") + results = {} + for name, vetoes in cells: + b, st = book(vetoes=vetoes) + m = mstats(b) + blk = sum(v for k, v in st.items() if k.startswith("blk_") and k != "blk_canon") + results[name] = dict(name=name, vetoes=vetoes, b=b, st=st, blk=blk, **m) + print(f" {name:<32}{m['sh_is']:>7.2f}{m['sh']:>7.2f}{m['sh_h']:>7.2f}" + f"{m['dd']*100:>6.1f}%{m['worst']*100:>+7.1f}%{m['active']*100:>5.0f}%{blk:>9}") + pd.DataFrame([{k: v for k, v in r.items() if k not in ("b", "st", "vetoes")} + for r in results.values()]).to_csv(SCRATCH / "r0703_newgates_grid.csv", index=False) + + bl = results["BASELINE canonico"] + ranked = sorted((r for r in results.values() if r["active"] >= MIN_IS_ACTIVE + and not np.isnan(r["sh_is"])), key=lambda r: r["sh_is"], reverse=True) + cand = ranked[0] + is_baseline_best = cand["name"] == bl["name"] + n_beat_hold = sum(1 for r in results.values() if r["sh_h"] > bl["sh_h"]) + print(f"\n -> cella scelta IN-SAMPLE: [{cand['name']}] Sh-IS {cand['sh_is']:.2f} " + f"(baseline {bl['sh_is']:.2f}, Δ {cand['sh_is'] - bl['sh_is']:+.2f})") + print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)} — " + f"NON selezionabili (selection-on-holdout, gate 2026-06-29).") + + # ---------------- (4) multi-cut + finestre disgiunte + DSR ---------------- + print("\n (4) MULTI-CUT (Sharpe dal taglio a fine storia; uplift = cand - baseline):") + pos_cuts = 0 + for c in CUTS: + sc = sh_wk(cand["b"][cand["b"].index >= c]); sb = sh_wk(bl["b"][bl["b"].index >= c]) + u = sc - sb + pos_cuts += int(u > 0) + print(f" cut {c.date()}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}") + print(f" -> uplift positivo in {pos_cuts}/{len(CUTS)} tagli (barra alta >=4/5; soglia minima filone >=3)") + print(" FINESTRE DISGIUNTE:") + pos_win = 0 + for wname, lo, hi in WINDOWS: + m_ = pd.Series(True, index=cand["b"].index) + if lo: + m_ &= cand["b"].index >= pd.Timestamp(lo, tz="UTC") + if hi: + m_ &= cand["b"].index < pd.Timestamp(hi, tz="UTC") + sc, sb = sh_wk(cand["b"][m_]), sh_wk(bl["b"][m_]) + u = sc - sb + pos_win += int(u > 0) + print(f" {wname:<10} cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}") + print(f" -> uplift positivo in {pos_win}/{len(WINDOWS)} finestre disgiunte") + + all_sh = [r["sh"] for r in results.values()] + dsr_c, null_max = deflated_sharpe(cand["sh"], all_sh, cand["b"].values, dpy=WK) + dsr_b, _ = deflated_sharpe(bl["sh"], all_sh, bl["b"].values, dpy=WK) + print(f"\n DEFLATED SHARPE (N={len(all_sh)} celle): cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | " + f"baseline DSR {dsr_b:.3f} [PASS >= 0.95; anti-conservativo: celle correlate + ~20 config storiche VRP]") + + # ---------------- (5) NULL DEL DE-LEVERING ---------------- + print("\n (5) NULL DEL DE-LEVERING (lezione TP01xDVOL): baseline scalato allo STESSO maxDD del candidato") + s = delever_scale(bl["b"], cand["dd"]) + scaled = s * bl["b"] + ms = mstats(scaled) + print(f" candidato [{cand['name']}]: Sh {cand['sh']:.2f} ShH {cand['sh_h']:.2f} CAGR {cand['cagr']*100:+.1f}% " + f"DD {cand['dd']*100:.1f}% worst {cand['worst']*100:+.1f}%") + print(f" baseline x{s:.2f} (pari DD): Sh {ms['sh']:.2f} ShH {ms['sh_h']:.2f} CAGR {ms['cagr']*100:+.1f}% " + f"DD {ms['dd']*100:.1f}% worst {ms['worst']*100:+.1f}%") + survives_delever = (cand["sh"] > ms["sh"]) and (cand["sh_h"] > ms["sh_h"]) + print(f" -> il veto {'BATTE' if survives_delever else 'NON batte'} il de-levering " + f"(serve Sh full E hold > baseline a pari DD; lo Sharpe e' scale-invariante).") + + # ---------------- (6) banda f + banda d'ancora ---------------- + print("\n (6a) BANDA f (skew non modellato) — uplift cand-baseline per f:") + for f in F_SWEEP: + bf, _ = book(f=f); cf, _ = book(vetoes=cand["vetoes"], f=f) + mbf, mcf = mstats(bf), mstats(cf) + print(f" f={f}: base ShF {mbf['sh']:>5.2f}/ShH {mbf['sh_h']:>5.2f} cand ShF {mcf['sh']:>5.2f}/ShH {mcf['sh_h']:>5.2f} " + f"uplift full {mcf['sh']-mbf['sh']:>+5.2f} hold {mcf['sh_h']-mbf['sh_h']:>+5.2f}") + + print("\n (6b) BANDA D'ANCORA (7 offset i0 del ciclo settimanale; regola 2026-07-02):") + ups_f, ups_h = [], [] + for ph in range(7): + bA, _ = book(i0=60 + ph); cA, _ = book(vetoes=cand["vetoes"], i0=60 + ph) + mbA, mcA = mstats(bA), mstats(cA) + ups_f.append(mcA["sh"] - mbA["sh"]); ups_h.append(mcA["sh_h"] - mbA["sh_h"]) + print(f" i0=+{ph}g: base {mbA['sh']:>5.2f}/{mbA['sh_h']:>5.2f} cand {mcA['sh']:>5.2f}/{mcA['sh_h']:>5.2f} " + f"uplift {mcA['sh']-mbA['sh']:>+5.2f}/{mcA['sh_h']-mbA['sh_h']:>+5.2f}") + print(f" banda uplift FULL [{min(ups_f):+.2f}, {max(ups_f):+.2f}] mediana {np.median(ups_f):+.2f} | " + f"HOLD [{min(ups_h):+.2f}, {max(ups_h):+.2f}] mediana {np.median(ups_h):+.2f}") + + # ---------------- (7) per-anno + verdetto ---------------- + print("\n (7) PER-ANNO (ritorno composto):") + pyb, pyc = per_year(bl["b"]), per_year(cand["b"]) + print(" base: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyb.items()))) + print(" cand: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyc.items()))) + + print("\n" + "=" * 112) + gates = dict( + non_e_baseline=not is_baseline_best, + batte_IS=cand["sh_is"] > bl["sh_is"], + multicut_4su5=pos_cuts >= 4, + multicut_min3=pos_cuts >= 3, + finestre_disgiunte=pos_win >= 3, + hold_migliore=cand["sh_h"] > bl["sh_h"], + dsr_pass=bool(dsr_c >= 0.95), + batte_delevering=bool(survives_delever), + anchor_mediana_pos=float(np.median(ups_h)) > 0, + ) + improves = all(gates.values()) + if is_baseline_best: + print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.") + print(" QUARTO fallimento del filone 'raffina il gate': l'alpha resta il binario IV-rank>0.30.") + elif improves: + print(f" VERDETTO: MIGLIORA — [{cand['name']}] passa TUTTI i gate. Resta sleeve MODELLATO") + print(" (DVOL ATM, no skew, f di stress non catturato): MAI deploy, solo aggiornamento del modello.") + else: + fails = [k for k, v in gates.items() if not v] + print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['name']}] bocciata su: {', '.join(fails)}.") + print(" QUARTO fallimento del filone 'raffina il gate' (dopo sizing/momentum/TP01 del 2026-07-01):") + print(" l'alpha del VRP01 resta interamente nel gate binario IV-rank>0.30. Angolo (a) term-structure:") + print(f" non testabile ({n_ts}g di storia), il logger forward accumula — rivalutare a ~6-12 mesi.") + print(" gates: " + " ".join(f"{k}={'PASS' if v else 'FAIL'}" for k, v in gates.items())) + print("=" * 112) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_sizing.py b/scripts/research/r0703_vrpimp_sizing.py new file mode 100644 index 0000000..4523999 --- /dev/null +++ b/scripts/research/r0703_vrpimp_sizing.py @@ -0,0 +1,655 @@ +#!/usr/bin/env python +"""r0703_vrpimp_sizing.py — FILONE 3: SIZING ANTI-ROVINA per lo sleeve short-vol defined-risk. + +DOMANDA: ALB-B ha mostrato che il sizing 1->4 di Albimarini porta alla rovina (1998/2002/2020). +Qual e' la politica di sizing OTTIMA per uno sleeve put-credit-spread ETH su Deribit a 2.000-5.000$? + +MOTORE (riusato, non riscritto): la struttura VRP01 canonica (put credit spread 7g, short delta +-0.28 / long -0.10, gate CANONICO vrp>0 + ivr>=0.30 + crash-skip 0.90 — NON riottimizzato, lezione +2026-07-01) prezzata col motore ALB-A (r0702_alb_structure): BS flat su DVOL reale, fee Deribit +PER GAMBA 0.03% notional cap 12.5% premio + delivery 0.015% su ITM. ETH-only: a 2-5k solo ETH e' +granulare (BTC min 0.1 = margine ~$470+, diario r0702_capital_scaling). Rendimenti PER-TRADE su +MARGINE (= max loss defined-risk, width - credito): e' il capitale davvero a rischio, e rende la +rovina ben definita (perdita massima = 100% del margine impegnato + fee). + +POLITICHE TESTATE (il sizing NON cambia l'edge: cambia CAGR/DD/P(rovina) — lo Sharpe del flusso +sottostante e' invariante di scala, quindi qui NON si caccia Sharpe, si mappa la frontiera): + (a) frazione fissa del capitale q in {5,10,12,15,20,25}% (margine impegnato/equity per settimana) + (b) vol-scaled: q ∝ 1/DVOL (riferimento = mediana ESPANDENTE causale della DVOL) + (c) anti-streak: q ridotta dopo N vittorie consecutive (l'OPPOSTO di Albimarini) + (d) Kelly frazionario con stima ONESTA delle code: distribuzione empirica per-trade IS (pre-2025) + POOLED sulla banda skew f in {0.6,0.8,1.0,1.3} + coda sintetica (full-loss -102% con probabilita' + extra = P(move settimanale <= strike long | storia ETH 2019-26 CERTIFICATA) - freq empirica) + (e) [riferimento negativo] Albimarini 1->4: size crescente con lo streak di vittorie + +METRICHE: CAGR, maxDD, worst-week, P(rovina -50% / -80% | 5 anni, bootstrap a BLOCCHI L=13 settimane +che preserva il clustering di regime del gate), a C=2.000 e 5.000$ con granularita' REALE degli +ordini (size INTERE di spread min 1 ETH/gamba; margine/spread dal dato della settimana stessa — +la nota task "0.1 ETH" e' incoerente coi numeri citati $66-76 = spread da 1 ETH di r0702_capital_ +scaling sez.5; la sensitivity 0.1 ETH e' riportata a parte). Confronto esplicito col 12% del book +(VRP01 @12% peso = margine settimanale ~12% del conto, stessa convenzione del conteggio spread del +diario capital-scaling). + +REGOLE ONORATE: hold-out 2025-26 MAI usato per selezionare (selezione overlay su MAR in-sample); +NULL DEL DE-LEVERING esplicito (ogni claim di riduzione DD degli overlay b/c/d confrontato con la +frazione fissa che raggiunge lo stesso DD); banda f {0.6,0.8,1.0,1.3}; banda d'ancora sulle 7 fasi +della cadenza settimanale; niente '7D'-origin (qui la cadenza e' a passi di indice, non resample). +REGOLA STANDING INVARIATA: niente short-vol da modello in deploy — esito = conoscenza/frontiera. + + uv run python scripts/research/r0703_vrpimp_sizing.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT)) + +import numpy as np +import pandas as pd + +import altlib as al # noqa: E402 (harness di progetto; usato per storia ETH certificata + DSR) +from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series # noqa: E402 +from scripts.research.options_vrp_v2 import _ivrank, _rv30 # noqa: E402 + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +WK = 365.25 / 7.0 +TENOR = 7 +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +CAPITALS = (2000.0, 5000.0) +MIN_ETH = 1.0 # min size Deribit ETH per gamba (r0702_capital_scaling sez.5) +Q_CAP = 0.95 # mai impegnare piu' del 95% dell'equity (loss max ~102% del margine) +FULL_LOSS = -1.02 # full-loss defined-risk incl. fee (empirico: worst -1.018) +RUIN_LVL = (0.50, 0.80) # rovina = equity sotto (1-lvl)*E0 ... definita come perdita >= lvl +SEED = 20260703 + + +# =========================================================================== +# MOTORE PER-TRADE (struttura VRP01, fee ALB-A per gamba, ritorni su MARGINE) +# =========================================================================== +def trade_records(f: float = 1.0, phase: int = 0) -> pd.DataFrame: + """Vendita settimanale ETH put credit spread -0.28/-0.10, gate canonico. Una riga per + settimana di cadenza (phase = fase d'ancora 0..6): active, r (ritorno su margine), + margin ($ per spread da 1 ETH), sig (DVOL a decisione), kl_dist (distanza strike long).""" + J = load_series("ETH") + px = J["px"].values + dv = J["dvol"].values / 100.0 + idx = J.index + n = len(px) + T = TENOR / 365.25 + rows = [] + i = 60 + phase + while i + TENOR < n: + S0 = px[i] + sig = dv[i] + rv = _rv30(px, i) + ivr = _ivrank(dv, i) + skip = ((not np.isnan(rv) and (sig - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + if skip: + rows.append(dict(date=idx[i + TENOR], active=0, r=0.0, margin=0.0, + sig=sig, win=0, kl_dist=np.nan)) + i += TENOR + continue + Ks = strike_from_delta(S0, T, sig, -0.28) + Kl = strike_from_delta(S0, T, sig, -0.10) + ps = bs_put(S0, Ks, T, sig) * f + pl = bs_put(S0, Kl, T, sig) * f + credit = ps - pl + width = Ks - Kl + margin = width - credit # margine defined-risk Deribit ~ max loss + S1 = px[i + TENOR] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + fee = min(0.0003 * S0, 0.125 * max(ps, 0.0)) + min(0.0003 * S0, 0.125 * max(pl, 0.0)) + deliv = (min(0.00015 * S1, 0.125 * max(0.0, Ks - S1)) + + min(0.00015 * S1, 0.125 * max(0.0, Kl - S1))) + pnl = credit - payoff - fee - deliv # $ per spread da 1 ETH + rows.append(dict(date=idx[i + TENOR], active=1, r=pnl / margin, margin=margin, + sig=sig, win=int(pnl > 0), kl_dist=Kl / S0 - 1.0)) + i += TENOR + return pd.DataFrame(rows) + + +# =========================================================================== +# POLITICHE DI SIZING — q = frazione di equity impegnata come margine questa settimana +# state: sig (DVOL decisione), ref (mediana espandente DVOL), streak (vittorie consecutive) +# =========================================================================== +def pol_fixed(frac): + return lambda st: frac + + +def pol_volscaled(frac, lo=0.5, hi=2.0): + """q = frac * clip(DVOL_ref/DVOL, lo, hi): meno size quando la vol e' alta. Causale.""" + return lambda st: frac * float(np.clip(st["ref"] / max(st["sig"], 1e-9), lo, hi)) + + +def pol_antistreak(frac, N, mult): + """Dopo >=N vittorie consecutive riduci a frac*mult (l'opposto di Albimarini).""" + return lambda st: frac * (mult if st["streak"] >= N else 1.0) + + +def pol_alb14(frac): + """Riferimento NEGATIVO: Albimarini 1->4, size crescente con le vittorie consecutive.""" + return lambda st: frac * min(1.0 + st["streak"], 4.0) + + +def simulate(rec: pd.DataFrame, qfun, E0: float, granular: bool = True, + min_eth: float = MIN_ETH, stress_p: float = 0.0, rng=None, + q_series=None) -> dict: + """Applica la politica al flusso settimanale. granular=True -> size intere di spread + (floor(budget/margine_spread)); False -> frazione continua (lens frontiera). + stress_p>0: ogni settimana attiva ha prob extra di full-loss sintetico (coda stress). + q_series: array di q per riga (bypassa qfun — usato dal null a piazzamento casuale).""" + E = E0 + eq, rets, dates = [], [], [] + sig_hist: list[float] = [] + streak = 0 + halted = 0 + for t, row in enumerate(rec.itertuples(index=False)): + ref = float(np.median(sig_hist)) if len(sig_hist) >= 20 else row.sig + st = dict(sig=row.sig, ref=ref, streak=streak) + sig_hist.append(row.sig) + r_wk = 0.0 + if row.active and E > 0: + q_raw = q_series[t] if q_series is not None else qfun(st) + q = float(np.clip(q_raw, 0.0, Q_CAP)) + unit = row.margin * min_eth + if granular: + n_spread = int((q * E) // unit) if unit > 0 else 0 + committed = n_spread * unit + if q > 0 and n_spread == 0: + halted += 1 + else: + committed = q * E + r_trade = row.r + if stress_p > 0.0 and rng is not None and rng.random() < stress_p: + r_trade = FULL_LOSS + r_wk = committed * r_trade / E if E > 0 else 0.0 + E = E + committed * r_trade + if row.active: + streak = streak + 1 if (row.win and row.r == row.r) else 0 + if not row.win: + streak = 0 + rets.append(r_wk) + eq.append(max(E, 0.0)) + dates.append(row.date) + return dict(dates=pd.DatetimeIndex(dates), eq=np.asarray(eq), rets=np.asarray(rets), + halted=halted, E_end=E) + + +def path_metrics(dates, rets) -> dict: + r = np.asarray(rets, float) + if len(r) < 3: + return dict(cagr=0.0, dd=0.0, worst=0.0, sh=0.0) + eq = np.cumprod(1.0 + r) + pk = np.maximum.accumulate(eq) + yrs = len(r) / WK + cagr = eq[-1] ** (1.0 / yrs) - 1.0 if eq[-1] > 0 else -1.0 + sh = float(r.mean() / r.std() * np.sqrt(WK)) if r.std() > 0 else 0.0 + return dict(cagr=float(cagr), dd=float(np.max((pk - eq) / pk)), worst=float(r.min()), sh=sh) + + +def full_hold(sim) -> tuple[dict, dict]: + m_full = path_metrics(sim["dates"], sim["rets"]) + mask = sim["dates"] >= HOLDOUT + m_hold = path_metrics(sim["dates"][mask], sim["rets"][mask]) + return m_full, m_hold + + +# =========================================================================== +# KELLY con code oneste + bootstrap a blocchi +# =========================================================================== +def kelly_star(rs: np.ndarray, p_extra: float, tail: float = FULL_LOSS) -> float: + """argmax_q E[log(1+q r)] su mistura: con prob p_extra r=tail, altrimenti empirico.""" + qs = np.linspace(0.0, Q_CAP, 191) + best_q, best_g = 0.0, -np.inf + for q in qs: + vals = np.log1p(q * rs) + g = (1.0 - p_extra) * vals.mean() + p_extra * np.log1p(q * tail) + if np.isfinite(g) and g > best_g: + best_g, best_q = g, q + return float(best_q) + + +def synthetic_tail_prob(rec: pd.DataFrame) -> tuple[float, float, float]: + """p_extra = P(move 7g ETH <= distanza strike long | storia certificata 2019-26) + - freq empirica di full-loss nel flusso gated. Ritorna (p_extra, p_uncond, p_emp).""" + d1 = al.get("ETH", "1d") + px = d1["close"].values.astype(float) + wk_mv = px[TENOR:] / px[:-TENOR] - 1.0 # mosse 7g overlapping, 2019->oggi + act = rec[rec.active == 1] + kl_med = float(act["kl_dist"].median()) + p_uncond = float(np.mean(wk_mv <= kl_med)) + p_emp = float((act["r"] <= -0.9).mean()) + return max(0.0, p_uncond - p_emp), p_uncond, p_emp + + +def block_bootstrap_ruin(rec: pd.DataFrame, qfun, C: float, npaths: int = 1000, + horizon_w: int = 261, L: int = 13, stress_p: float = 0.0, + seed: int = SEED, needs_ref: bool = False) -> dict: + """P(rovina | 5 anni): bootstrap a blocchi circolari di L settimane dal flusso storico + (preserva clustering del gate/regime), politica applicata con granularita' REALE a C. + needs_ref: calcola la mediana espandente della DVOL solo per le politiche che la usano.""" + rows = list(rec.itertuples(index=False)) + n = len(rows) + rng = np.random.default_rng(seed) + ruin = np.zeros((npaths, len(RUIN_LVL)), bool) + dd30 = np.zeros(npaths, bool) + cagrs = np.zeros(npaths) + for p in range(npaths): + starts = rng.integers(0, n, size=horizon_w // L + 1) + seq = [rows[(s + k) % n] for s in starts for k in range(L)][:horizon_w] + E = C + peak = C + minE = C + sig_hist: list[float] = [] + streak = 0 + maxdd = 0.0 + for row in seq: + if needs_ref: + ref = float(np.median(sig_hist)) if len(sig_hist) >= 20 else row.sig + sig_hist.append(row.sig) + else: + ref = row.sig + if row.active and E > 0: + q = float(np.clip(qfun(dict(sig=row.sig, ref=ref, streak=streak)), 0.0, Q_CAP)) + unit = row.margin * MIN_ETH + n_spread = int((q * E) // unit) if unit > 0 else 0 + r_trade = row.r + if stress_p > 0.0 and rng.random() < stress_p: + r_trade = FULL_LOSS + E = E + n_spread * unit * r_trade + streak = streak + 1 if (row.win and r_trade > 0) else 0 + peak = max(peak, E) + minE = min(minE, E) + maxdd = max(maxdd, (peak - E) / peak if peak > 0 else 1.0) + for j, lvl in enumerate(RUIN_LVL): + ruin[p, j] = minE <= C * (1.0 - lvl) + dd30[p] = maxdd >= 0.30 + cagrs[p] = (max(E, 0.0) / C) ** (1.0 / (horizon_w / WK)) - 1.0 if E > 0 else -1.0 + return dict(p_ruin50=float(ruin[:, 0].mean()), p_ruin80=float(ruin[:, 1].mean()), + p_dd30=float(dd30.mean()), cagr_med=float(np.median(cagrs)), + cagr_p10=float(np.percentile(cagrs, 10))) + + +# =========================================================================== +# REPORT +# =========================================================================== +def hist_row(label, rec, qfun, C, granular=True): + sim = simulate(rec, qfun, C, granular=granular) + mf, mh = full_hold(sim) + return sim, mf, mh, (f" {label:<34} {mf['cagr']*100:>+6.1f}% {mf['dd']*100:>5.1f}% " + f"{mf['worst']*100:>+6.1f}% {mf['sh']:>5.2f} | {mh['cagr']*100:>+6.1f}% " + f"{mh['dd']*100:>5.1f}% | halt {sim['halted']:>3}") + + +HDR = (f" {'politica':<34} {'CAGR-F':>7} {'DD-F':>5} {'worst':>6} {'Sh-F':>5} | " + f"{'CAGR-H':>7} {'DD-H':>5} | settimane-0-spread") + + +def main(): + print("=" * 112) + print(" R0703 VRPIMP-SIZING — sizing anti-rovina dello sleeve short-vol defined-risk ETH (2-5k, Deribit)") + print(" Flusso: put credit spread ETH 7g -0.28/-0.10, gate canonico, motore ALB-A (fee per gamba), 2021-2026.") + print(" Rendimenti per-trade su MARGINE (=max loss). Il sizing non cambia lo Sharpe del flusso: mappa la") + print(" frontiera CAGR-DD-P(rovina). REGOLA STANDING INVARIATA: niente short-vol da modello in deploy.") + print("=" * 112) + + rec = trade_records(f=1.0, phase=0) + act = rec[rec.active == 1] + is_act = act[act.date < HOLDOUT] + print(f"\n flusso f=1.0 ancora-0: {len(rec)} settimane, {len(act)} attive ({len(act)/len(rec)*100:.0f}%), " + f"win {act['win'].mean()*100:.0f}%, r-margine medio {act['r'].mean()*100:+.1f}% " + f"(mediana {act['r'].median()*100:+.1f}%), full-loss r<=-0.9: {int((act['r']<=-0.9).sum())}") + print(f" margine $/spread(1 ETH): mediana ${act['margin'].median():.0f}, ultimo ${act['margin'].iloc[-1]:.0f} " + f"(la banda $66-76 del diario = spot/DVOL del run r0702)") + + # streak diagnostics: gli streak di vittorie predicono la prossima perdita? + wins = act["win"].values + streaks_before = [] + s = 0 + for w in wins: + streaks_before.append(s) + s = s + 1 if w else 0 + sb = np.asarray(streaks_before) + print("\n DIAGNOSTICA ANTI-STREAK — P(perdita | vittorie consecutive precedenti):") + base = 1.0 - act["win"].mean() + for N in (0, 2, 3, 5): + m = sb >= N + if m.sum() >= 8: + print(f" streak>={N}: P(loss)={1.0-wins[m].mean():.2f} su n={int(m.sum())} (base {base:.2f})") + + # ---------------------------------------------------------------- (1) frontiera fisso + print("\n" + "=" * 112) + print(" (1) FRONTIERA DD-CAGR — frazione fissa (IL NULL DEL DE-LEVERING), f=1.0, storico 2021-26") + print(" 'cont' = frazione continua (lens); '2k'/'5k' = granularita' REALE (spread interi da 1 ETH)") + print("=" * 112) + fracs = (0.02, 0.05, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.35, 0.50) + frontier = {} # frac -> (dd_full, cagr_full) continuo + print(f" {'q':>5} | {'cont: CAGR-F':>12} {'DD-F':>6} {'Sh-F':>5} {'CAGR-H':>7} | " + f"{'2k: CAGR-F':>10} {'DD-F':>6} {'halt':>4} | {'5k: CAGR-F':>10} {'DD-F':>6} {'halt':>4}") + for q in fracs: + sc = simulate(rec, pol_fixed(q), 2000.0, granular=False) + mfc, mhc = full_hold(sc) + frontier[q] = (mfc["dd"], mfc["cagr"]) + s2 = simulate(rec, pol_fixed(q), 2000.0, granular=True) + mf2, _ = full_hold(s2) + s5 = simulate(rec, pol_fixed(q), 5000.0, granular=True) + mf5, _ = full_hold(s5) + tag = " <- book 12%" if abs(q - 0.12) < 1e-9 else "" + print(f" {q*100:>4.0f}% | {mfc['cagr']*100:>+11.1f}% {mfc['dd']*100:>5.1f}% {mfc['sh']:>5.2f} " + f"{mhc['cagr']*100:>+6.1f}% | {mf2['cagr']*100:>+9.1f}% {mf2['dd']*100:>5.1f}% {s2['halted']:>4} | " + f"{mf5['cagr']*100:>+9.1f}% {mf5['dd']*100:>5.1f}% {s5['halted']:>4}{tag}") + print(" ('halt' = settimane attive in cui il budget q*E non compra NEMMENO 1 spread -> size 0:") + print(" a 2k le frazioni <=10% saltano trade nelle ere a margine alto — la granularita' distorce il basso)") + + # ---------------------------------------------------------------- (2) overlay b/c — selezione IS + print("\n" + "=" * 112) + print(" (2) OVERLAY (b) VOL-SCALED e (c) ANTI-STREAK — selezione SOLO in-sample (MAR pre-2025, continuo)") + print("=" * 112) + rec_is = rec[rec.date < HOLDOUT].reset_index(drop=True) + + def is_mar(qfun): + sim = simulate(rec_is, qfun, 2000.0, granular=False) + m = path_metrics(sim["dates"], sim["rets"]) + return m["cagr"] / max(m["dd"], 1e-9) + + cells_b = [(fb, lo, hi) for fb in (0.08, 0.12, 0.16) for lo, hi in ((0.5, 1.5), (0.5, 2.0))] + best_b = max(cells_b, key=lambda c: is_mar(pol_volscaled(*c))) + cells_c = [(0.12, N, m) for N in (2, 3, 5) for m in (0.25, 0.5)] + best_c = max(cells_c, key=lambda c: is_mar(pol_antistreak(*c))) + print(f" celle esplorate: vol-scaled {len(cells_b)}, anti-streak {len(cells_c)} " + f"(+6 frazioni fisse dichiarate a priori, +3 Kelly = {len(cells_b)+len(cells_c)+9} totali)") + print(f" best IS vol-scaled: base={best_b[0]:.0%} clip[{best_b[1]},{best_b[2]}] | " + f"best IS anti-streak: q=12% N={best_c[1]} mult={best_c[2]}") + + print("\n" + HDR) + named = {} + for label, qf in ( + ("(a) FISSO 12% (book)", pol_fixed(0.12)), + (f"(b) VOL-SCALED {best_b[0]:.0%} clip[{best_b[1]},{best_b[2]}]", pol_volscaled(*best_b)), + (f"(c) ANTI-STREAK 12% N={best_c[1]} m={best_c[2]}", pol_antistreak(0.12, best_c[1], best_c[2])), + ("(e) ALBIMARINI 1->4 (rif. negativo)", pol_alb14(0.12))): + sim, mf, mh, line = hist_row(label, rec, qf, 2000.0, granular=False) + named[label] = (qf, mf, mh) + print(line) + + # ---------------------------------------------------------------- (3) Kelly onesto + print("\n" + "=" * 112) + print(" (3) (d) KELLY FRAZIONARIO con code ONESTE (distribuzione IS pooled banda-f + coda sintetica)") + print("=" * 112) + p_extra, p_unc, p_emp = synthetic_tail_prob(rec) + pooled = [] + for f in F_SWEEP: + rf = trade_records(f=f, phase=0) + a = rf[(rf.active == 1) & (rf.date < HOLDOUT)] + pooled.append(a["r"].values) + rs_pool = np.concatenate(pooled) + q_star = kelly_star(rs_pool, p_extra) + q_naive = kelly_star(is_act["r"].values, 0.0) + print(f" coda: P(move7g<=Kl | ETH 2019-26 certificato)={p_unc:.3f}, freq empirica full-loss gated={p_emp:.3f}" + f" -> p_extra sintetica={p_extra:.3f} a r={FULL_LOSS}") + print(f" Kelly NAIVE (solo empirico IS f=1.0): q* = {q_naive:.1%}") + print(f" Kelly ONESTO (pooled f-band + coda): q* = {q_star:.1%}" + f" -> il 12% del book = {0.12/q_star:.2f} Kelly") + print("\n" + HDR) + for lam in (0.25, 0.5, 1.0): + q = lam * q_star + label = f"(d) KELLY {lam:.2f}x -> q={q:.1%}" + sim, mf, mh, line = hist_row(label, rec, pol_fixed(q), 2000.0, granular=False) + named[label] = (pol_fixed(q), mf, mh) + print(line) + + # ---------------------------------------------------------------- (4) null del de-levering + print("\n" + "=" * 112) + print(" (4) NULL DEL DE-LEVERING — ogni overlay vs la frazione FISSA che da' lo stesso maxDD (interp.)") + print("=" * 112) + fr = sorted(frontier.items()) + dds = np.array([v[0] for _, v in fr]) + cags = np.array([v[1] for _, v in fr]) + qss = np.array([q for q, _ in fr]) + for label, (qf, mf, mh) in named.items(): + if label.startswith("(a)") or label.startswith("(e)"): + continue + q_eq = float(np.interp(mf["dd"], dds, qss)) + c_eq = float(np.interp(mf["dd"], dds, cags)) + verdict = "OVERLAY NON VALE (delever null vince/pareggia)" if c_eq >= mf["cagr"] - 0.002 \ + else "overlay batte il null (verificare persistenza!)" + print(f" {label:<38} DD {mf['dd']*100:5.1f}% CAGR {mf['cagr']*100:+6.1f}% | fisso-equivalente " + f"q={q_eq*100:4.1f}% CAGR {c_eq*100:+6.1f}% -> {verdict}") + + # ---------------------------------------------------------------- (4b) stress-test ANTI-STREAK + print("\n" + "=" * 112) + print(" (4b) STRESS-TEST ANTI-STREAK — regola standing: 3 raffinamenti-gate VRP gia' falliti; un 4°") + print(" candidato deve battere il NULL GIUSTO (piazzamento casuale), persistere multi-cut, DSR>=0.95") + print("=" * 112) + N_as, m_as = best_c[1], best_c[2] + # replay della maschera 'settimana ridotta' (streak>=N a decisione) + red = np.zeros(len(rec), bool) + s = 0 + for t, row in enumerate(rec.itertuples(index=False)): + red[t] = bool(row.active and s >= N_as) + if row.active: + s = s + 1 if row.win else 0 + act_mask = rec["active"].values == 1 + act_idx = np.where(act_mask)[0] + k_red = int(red[act_mask].sum()) + win_arr = rec["win"].values.astype(bool) + loss_red = int((~win_arr[act_mask]) [red[act_mask]].sum()) + n_loss = int((~win_arr[act_mask]).sum()) + print(f" settimane attive ridotte (streak>={N_as}): {k_red}/{len(act_idx)} ({k_red/len(act_idx)*100:.0f}%) | " + f"perdite intercettate a size ridotta: {loss_red}/{n_loss}") + + sim_anti = simulate(rec, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False) + sim_fix = simulate(rec, pol_fixed(0.12), 2000.0, granular=False) + m_anti = path_metrics(sim_anti["dates"], sim_anti["rets"]) + + # NULL a PIAZZAMENTO CASUALE: stessa sizing a 2 livelli (12% / 12%*mult), stesse k settimane + # ridotte, ma scelte A CASO fra le attive (isola il TIMING dello streak dall'esposizione media) + rng = np.random.default_rng(SEED + 7) + null_cagr, null_dd, null_mar = [], [], [] + for _ in range(500): + choose = rng.choice(act_idx, size=k_red, replace=False) + qs = np.full(len(rec), 0.12) + qs[choose] = 0.12 * m_as + sn = simulate(rec, None, 2000.0, granular=False, q_series=qs) + mn = path_metrics(sn["dates"], sn["rets"]) + null_cagr.append(mn["cagr"]) + null_dd.append(mn["dd"]) + null_mar.append(mn["cagr"] / max(mn["dd"], 1e-9)) + mar_anti = m_anti["cagr"] / max(m_anti["dd"], 1e-9) + p_cagr = float(np.mean(np.asarray(null_cagr) < m_anti["cagr"])) + p_dd = float(np.mean(np.asarray(null_dd) > m_anti["dd"])) # quota di null con DD PEGGIORE + p_mar = float(np.mean(np.asarray(null_mar) < mar_anti)) + print(f" NULL piazzamento-casuale (500 draw, stessa esposizione media): anti-streak CAGR pctl {p_cagr:.3f}, " + f"DD-migliore-del-null {p_dd:.3f}, MAR pctl {p_mar:.3f}") + print(f" (null: CAGR med {np.median(null_cagr)*100:+.1f}%, DD med {np.median(null_dd)*100:.1f}% vs " + f"anti-streak {m_anti['cagr']*100:+.1f}%/{m_anti['dd']*100:.1f}%)") + + # banda d'ancora dell'EFFETTO (7 fasi): direzione + IL DELEVER-NULL RIFATTO PER OGNI ANCORA + # (lezione anchor-luck 2026-07-02: il claim vale solo se regge a OGNI ancora, non alla migliore) + print(f"\n banda d'ancora dell'effetto streak (7 fasi, f=1.0) + delever-null PER ANCORA:") + n_dir = 0 + n_null = 0 + for ph in range(7): + rp = trade_records(f=1.0, phase=ph) + wa = rp[rp.active == 1]["win"].values.astype(bool) + sb2 = [] + s = 0 + for w in wa: + sb2.append(s) + s = s + 1 if w else 0 + sb2 = np.asarray(sb2) + hi = sb2 >= N_as + p_hi = 1.0 - wa[hi].mean() if hi.sum() else np.nan + p_lo = 1.0 - wa[~hi].mean() if (~hi).sum() else np.nan + sa = simulate(rp, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False) + ma = path_metrics(sa["dates"], sa["rets"]) + # frontiera fissa DI QUESTA ancora -> CAGR del fisso allo stesso DD + dds_p, cags_p = [], [] + for q in fracs: + sf_ = simulate(rp, pol_fixed(q), 2000.0, granular=False) + mq = path_metrics(sf_["dates"], sf_["rets"]) + dds_p.append(mq["dd"]) + cags_p.append(mq["cagr"]) + c_eq = float(np.interp(ma["dd"], dds_p, cags_p)) + beats = ma["cagr"] > c_eq + 0.002 + direz = p_hi > p_lo + n_dir += int(direz) + n_null += int(beats) + print(f" fase {ph}: P(loss|s>={N_as})={p_hi:.2f} vs s<{N_as}={p_lo:.2f} {'OK' if direz else 'NO'} | " + f"anti CAGR {ma['cagr']*100:+5.1f}% DD {ma['dd']*100:4.1f}% | fisso stesso-DD CAGR {c_eq*100:+5.1f}% " + f"-> {'batte null' if beats else 'NULL VINCE'}") + print(f" -> direzione presente in {n_dir}/7 ancore; batte il delever-null in {n_null}/7 ancore") + + # plateau delle celle (ancora 0): l'effetto deve reggere OLTRE la cella selezionata + print("\n plateau celle anti-streak (ancora 0, FULL): MAR = CAGR/maxDD vs fisso 12% MAR " + f"{path_metrics(sim_fix['dates'], sim_fix['rets'])['cagr'] / max(path_metrics(sim_fix['dates'], sim_fix['rets'])['dd'], 1e-9):.2f}") + for (fq, N, m) in cells_c: + sa = simulate(rec, pol_antistreak(fq, N, m), 2000.0, granular=False) + mm = path_metrics(sa["dates"], sa["rets"]) + sel = " <- selezionata IS" if (N, m) == (N_as, m_as) else "" + print(f" N={N} mult={m}: CAGR {mm['cagr']*100:+5.1f}% DD {mm['dd']*100:5.1f}% " + f"MAR {mm['cagr']/max(mm['dd'],1e-9):5.2f}{sel}") + + # multi-cut: uplift Sharpe/MAR post-cut vs fisso 12% + print("\n persistenza multi-cut (uplift anti-streak vs fisso 12%, finestra POST-cut):") + for cut in ("2022-07-01", "2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01"): + cts = pd.Timestamp(cut, tz="UTC") + msk = sim_anti["dates"] >= cts + ma = path_metrics(sim_anti["dates"][msk], sim_anti["rets"][msk]) + mfx = path_metrics(sim_fix["dates"][msk], sim_fix["rets"][msk]) + print(f" cut {cut}: dSh {ma['sh']-mfx['sh']:+.2f} dCAGR {(ma['cagr']-mfx['cagr'])*100:+6.1f}pp " + f"DD {ma['dd']*100:4.1f}% vs {mfx['dd']*100:4.1f}%") + + # DSR della cella anti-streak (scelta IS) vs tutte le celle di sizing + all_sh_is = [] + for c in cells_b: + s_ = simulate(rec_is, pol_volscaled(*c), 2000.0, granular=False) + all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"]) + for c in cells_c: + s_ = simulate(rec_is, pol_antistreak(*c), 2000.0, granular=False) + all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"]) + for q in fracs: + s_ = simulate(rec_is, pol_fixed(q), 2000.0, granular=False) + all_sh_is.append(path_metrics(s_["dates"], s_["rets"])["sh"]) + sim_anti_is = simulate(rec_is, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False) + m_anti_is = path_metrics(sim_anti_is["dates"], sim_anti_is["rets"]) + ret_is = pd.Series(sim_anti_is["rets"], index=sim_anti_is["dates"]) + dsr_as, nm_as = al.deflated_sharpe(m_anti_is["sh"], all_sh_is, ret_is, dpy=WK) + print(f"\n DSR anti-streak (Sh-IS {m_anti_is['sh']:.2f} vs {len(all_sh_is)} celle): {dsr_as:.3f} " + f"(null-max {nm_as:.2f}) -> {'PASS' if dsr_as >= 0.95 else 'FAIL'} soglia 0.95") + + # banda f dell'anti-streak + print(" banda f anti-streak (FULL/HOLD):") + for f in F_SWEEP: + rf = trade_records(f=f, phase=0) + sa = simulate(rf, pol_antistreak(0.12, N_as, m_as), 2000.0, granular=False) + mfa, mha = full_hold(sa) + print(f" f={f:<4} CAGR-F {mfa['cagr']*100:>+6.1f}% DD-F {mfa['dd']*100:>5.1f}% Sh-F {mfa['sh']:>5.2f} | " + f"CAGR-H {mha['cagr']*100:>+6.1f}% DD-H {mha['dd']*100:>5.1f}%") + + # ---------------------------------------------------------------- (5) P(rovina) bootstrap + print("\n" + "=" * 112) + print(" (5) P(ROVINA | 5 ANNI) — bootstrap a blocchi (L=13 sett., 1000 path), granularita' REALE") + print(" rovina-50 = equity <= 50% del capitale iniziale in un punto qualsiasi; stress = +coda sintetica") + print("=" * 112) + boot_pols = [ + ("FISSO 5%", pol_fixed(0.05), False), ("FISSO 12% (book)", pol_fixed(0.12), False), + ("FISSO 25%", pol_fixed(0.25), False), + ("VOL-SCALED best-IS", pol_volscaled(*best_b), True), + ("ANTI-STREAK best-IS", pol_antistreak(0.12, best_c[1], best_c[2]), False), + (f"KELLY 0.25x ({0.25*q_star:.0%})", pol_fixed(0.25 * q_star), False), + (f"KELLY 0.50x ({0.5*q_star:.0%})", pol_fixed(0.5 * q_star), False), + (f"KELLY 1.00x ({q_star:.0%})", pol_fixed(q_star), False), + ("ALBIMARINI 1->4", pol_alb14(0.12), False), + ] + print(f" {'politica':<24} {'C':>5} | {'P(rov50)':>8} {'P(rov80)':>8} {'P(DD>30%)':>9} " + f"{'CAGRmed':>8} {'CAGRp10':>8} | {'stress: P(rov50)':>16} {'P(rov80)':>8}") + boot_out = {} + for label, qf, nref in boot_pols: + for C in CAPITALS: + b = block_bootstrap_ruin(rec, qf, C, stress_p=0.0, needs_ref=nref) + bs = block_bootstrap_ruin(rec, qf, C, stress_p=p_extra, seed=SEED + 1, needs_ref=nref) + boot_out[(label, C)] = (b, bs) + print(f" {label:<24} {C:>5.0f} | {b['p_ruin50']*100:>7.1f}% {b['p_ruin80']*100:>7.1f}% " + f"{b['p_dd30']*100:>8.1f}% {b['cagr_med']*100:>+7.1f}% {b['cagr_p10']*100:>+7.1f}% | " + f"{bs['p_ruin50']*100:>15.1f}% {bs['p_ruin80']*100:>7.1f}%") + + # ---------------------------------------------------------------- (6) banda f + banda ancora + print("\n" + "=" * 112) + print(" (6) BANDE OBBLIGATORIE — skew f e ancora settimanale (politica FISSO 12%, continuo)") + print("=" * 112) + print(" banda f (storico FULL/HOLD):") + for f in F_SWEEP: + rf = trade_records(f=f, phase=0) + sim = simulate(rf, pol_fixed(0.12), 2000.0, granular=False) + mf, mh = full_hold(sim) + print(f" f={f:<4} CAGR-F {mf['cagr']*100:>+6.1f}% DD-F {mf['dd']*100:>5.1f}% worst {mf['worst']*100:>+5.1f}%" + f" Sh-F {mf['sh']:>5.2f} | CAGR-H {mh['cagr']*100:>+6.1f}% DD-H {mh['dd']*100:>5.1f}%") + print(" banda d'ancora (7 fasi della cadenza settimanale, f=1.0):") + anchor = [] + for ph in range(7): + rp = trade_records(f=1.0, phase=ph) + sim = simulate(rp, pol_fixed(0.12), 2000.0, granular=False) + mf, mh = full_hold(sim) + anchor.append((ph, mf["cagr"], mf["dd"], mf["sh"], mh["cagr"])) + a = np.array([(x[1], x[2], x[3], x[4]) for x in anchor]) + print(f" CAGR-F: min {a[:,0].min()*100:+.1f}% med {np.median(a[:,0])*100:+.1f}% max {a[:,0].max()*100:+.1f}% | " + f"DD-F: {a[:,1].min()*100:.1f}/{np.median(a[:,1])*100:.1f}/{a[:,1].max()*100:.1f}% | " + f"Sh-F: {a[:,2].min():.2f}/{np.median(a[:,2]):.2f}/{a[:,2].max():.2f} | " + f"CAGR-H: {a[:,3].min()*100:+.1f}/{np.median(a[:,3])*100:+.1f}/{a[:,3].max()*100:+.1f}%") + + # DSR pro-forma sulle celle (il sizing riscala il flusso: Sharpe quasi invariante per costruzione) + all_sh = [] + for c in cells_b: + s = simulate(rec_is, pol_volscaled(*c), 2000.0, granular=False) + all_sh.append(path_metrics(s["dates"], s["rets"])["sh"]) + for c in cells_c: + s = simulate(rec_is, pol_antistreak(*c), 2000.0, granular=False) + all_sh.append(path_metrics(s["dates"], s["rets"])["sh"]) + for q in fracs: + s = simulate(rec_is, pol_fixed(q), 2000.0, granular=False) + all_sh.append(path_metrics(s["dates"], s["rets"])["sh"]) + sim_b = simulate(rec, pol_volscaled(*best_b), 2000.0, granular=False) + daily = pd.Series(sim_b["rets"], index=sim_b["dates"]) + dsr, null_max = al.deflated_sharpe(path_metrics(sim_b["dates"], sim_b["rets"])["sh"], all_sh, daily, dpy=WK) + print(f"\n DSR pro-forma (best overlay vs {len(all_sh)} celle di sizing): {dsr:.3f} (null-max {null_max:.2f})") + print(" NB: il sizing RISCALA lo stesso flusso -> gli Sharpe delle celle sono quasi identici per costruzione;") + print(" il DSR qui e' dovuto, ma la metrica decisiva del filone e' la frontiera CAGR-DD-P(rovina) vs il null.") + + # ---------------------------------------------------------------- (7) sensitivity 0.1 ETH + print("\n (7) SENSITIVITY GRANULARITA' 0.1 ETH (se il min-size fosse 0.1 ETH/gamba, unit ~$7-15):") + for C in CAPITALS: + s10 = simulate(rec, pol_fixed(0.12), C, granular=True, min_eth=0.1) + s1 = simulate(rec, pol_fixed(0.12), C, granular=True, min_eth=1.0) + m10, _ = full_hold(s10) + m1, _ = full_hold(s1) + print(f" C={C:.0f}: 1.0 ETH CAGR {m1['cagr']*100:+.1f}% DD {m1['dd']*100:.1f}% halt {s1['halted']} | " + f"0.1 ETH CAGR {m10['cagr']*100:+.1f}% DD {m10['dd']*100:.1f}% halt {s10['halted']}") + + print("\n" + "=" * 112) + print(" SINTESI ONESTA") + print(" - Il sizing non crea alpha: sposta il punto sulla frontiera CAGR-DD-P(rovina) del flusso gated.") + print(" - VERDETTI: (b) vol-scaled batte il null di ~1pp a singola ancora = rumore (lezione TP01xDVOL)") + print(" -> SCARTATO. (c) anti-streak: passa random-null 0.996 / DSR 0.999 / multi-cut / 7-7 direzione,") + print(" MA plateau ASSENTE (solo la cella N=2 vince; N=3/5 collassano al MAR del fisso), P(loss|streak)") + print(" NON monotona (0.24/0.18/0.15) e delever-null vince in 2/7 ancore: l'intero effetto sono ~6") + print(" perdite su 15 trade a streak==2 -> ARTEFATTO DI SELEZIONE, non adottato. La batteria minima per") + print(" un sizing-gate e': random-placement null + plateau celle + delever-null PER ANCORA (i primi 3") + print(" test da soli l'avrebbero promosso). (d) Kelly frazionario = punti sulla frontiera fissa (il") + print(" null vince/pareggia per costruzione): il valore e' la CALIBRAZIONE, q*_onesto=44%.") + print(" - Il 12% del book = 0.27 Kelly-onesto ~= quarter-Kelly: gia' nella zona sana anti-rovina") + print(" (P(rovina50|5y)~0.0-0.1%, P(DD>30%) 9-18%). Alzarlo a 25% decuplica P(DD>30%) a >92%.") + print(" - Albimarini 1->4 e' il controesempio: stesso flusso, stessa edge, P(rovina50|5y)=53-55%.") + print(" - Granularita' REALE: a 2k il min-size 1 ETH rende inservibili le frazioni <=10% (halt 13-92") + print(" settimane) e costa -3.7pp di CAGR al 12%; a 5k il muro sparisce (coerente con r0702: ~2.6k).") + print(" - REGOLA STANDING: niente short-vol da modello in deploy. Output = conoscenza per QUANDO/SE") + print(" il f di stress reale arrivera' da cerbero-bite. Nessun file di produzione toccato.") + print("=" * 112) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_spxgate.py b/scripts/research/r0703_vrpimp_spxgate.py new file mode 100644 index 0000000..c0d91d6 --- /dev/null +++ b/scripts/research/r0703_vrpimp_spxgate.py @@ -0,0 +1,462 @@ +"""R0703 VRPIMP-SPXGATE — gate di regime (VIX-rank / term-structure) sulla double diagonal SPX. + +FILONE 6 (ondata 2026-07-03). L'esperimento MANCANTE del backtest esterno dell'utente +(spx-diagonal-backtest, non su questa macchina): il gate di regime sulla diagonale SPX. +Domanda secca: il gate ribalta gli anni di coda come fa IV-rank su crypto (VRP01 hold-out +-0.25 -> +0.28), o su equity e' solo de-levering? + +DATI (⚠️ PROVENIENZA): + - SPY 1d: data/raw/eq_spy_1d.parquet (1996-2026, gia' in repo dal filone GTAA; research-grade). + - VIX (VIXCLS) e VIX-3M (VXVCLS): scaricati da FRED via curl (pubblico, senza token) in + scratchpad/vixcls.csv e vxvcls.csv. ⚠️ DATO RESEARCH-GRADE, NON CERTIFICATO dal nostro + pipeline (nessun cross-venue, nessun audit per-barra; missing = '.', ffill<=5g). VXV parte + dal 2007-12 -> il gate term-structure e' testabile solo dal 2008. + +MOTORE (⚠️ E' UN MODELLO, non una catena di quote): + - Double diagonal come il progetto esterno: SHORT STRANGLE a ~6 giorni di calendario + (4 trading day sul grid SPY; T di pricing = gap di calendario REALE del trade) + ALI LONG + a T+1 giorno, entrambe i lati. Distanze del corto in DELTA {0.01, 0.02, 0.05} ("1-2-5 + delta" del progetto esterno); ali a meta' del delta corto (WING_FRAC=0.5, scelta a priori). + - Pricing Black-Scholes r=0 con SKEW LOG-LINEARE: sigma(K) = VIX + SLOPE*ln(S/K) (put piu' + ricche, call piu' povere — smile equity), SLOPE=1.0 dichiarato a priori (≈ +1 vol pt per + 1% di moneyness down, tipico SPX short-dated). Strike-da-delta risolto a punto fisso con + la vol skewata. Il VIX e' IV a 30g usata per tenor 6g: term structure IGNORATA (in stress + il front-end esplode -> il modello SOTTOSTIMA il mark avverso, dichiarato). + - Ali marcate a exit con BS a 1 giorno residuo alla vol di uscita (vega catturato). + - Costi: 5% del premio di ogni gamba (entry 4 gambe + vendita ali residue a exit). + Modello commissioni+slippage SPX; gli spread reali deep-OTM sono spesso PEGGIORI (dich.). + - Banda f {0.6, 0.8, 1.0, 1.3} moltiplica il premio di ogni gamba (regola comune n.4). + - Capitale = S0 (cash-secured sul livello indice, convenzione r0702_alb_structure): CAGR/DD + sono su quel denominatore conservativo; Sharpe/PF/confronti gate-vs-no-gate invarianti. + +TRADE SOVRAPPOSTI (fedele al progetto esterno, "~6 concorrenti" in calendario): + - Ingresso OGNI trading day, holding 4 td -> 4 trade concorrenti sul grid trading-day. + - Onesta' statistica: il libro si decompone in 4 STRAND non-sovrapposti (fase d'ingresso + mod 4). Lo Sharpe headline = media degli Sharpe di strand (ppy=63); la banda min-max degli + strand E' la banda d'ancora obbligatoria (regola 2026-07-02). N_eff = N_trade/4; PF/win + riportati con lo sconto esplicito. + - Equity/maxDD/CAGR/per-anno dalla serie giornaliera di portafoglio (1/4 del capitale per + trade, PnL lumpato a scadenza dello strand che scade quel giorno). + +GATE (tutti causali a entry; rank VIX espandente con warm-start dal 1990): + (a) VIX-rank > soglia {0.20,0.30,0.40,0.50} x crash-skip {no, ivr<=0.90}; piu' il GATE + CANONICO crypto NON riottimizzato (vrp>0 AND 0.30<=ivr<=0.90, vrp = VIX-RV20). Selezione + della cella SOLO in-sample (pre-2025); deflated-Sharpe sul totale delle celle provate. + (b) Term-structure: VIX/VXV < 1 (contango) -> vendi; backwardation -> flat. Dal 2008. + (c) NULL DEL DE-LEVERING obbligatorio (lezione TP01xDVOL): lo Sharpe e' scale-invariante -> + il gate "vale" solo se batte lo Sharpe dell'always-in A PARI maxDD, con persistenza + multi-finestra, non su una finestra fortunata. + +REGOLE STANDING: niente short-vol da modello in deploy — esito massimo = CONOSCENZA sul +meccanismo del gate (crypto vs equity). Hold-out 2025-26 mai usato per selezionare. + + uv run python scripts/research/r0703_vrpimp_spxgate.py +""" +from __future__ import annotations +import sys +from bisect import bisect_left, insort +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) +import numpy as np, pandas as pd +from scipy.stats import norm +from altlib import deflated_sharpe # noqa: E402 (harness condiviso) + +SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad") +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +TS_START = pd.Timestamp("2008-01-01", tz="UTC") # finestra comune term-structure (VXV dal 2007-12) +TENOR_TD = 4 # trading days del corto (~6 giorni di calendario) +WING_FRAC = 0.5 # delta ali = meta' del delta corto (a priori) +SLOPE = 1.0 # skew log-lineare (vol per unita' di ln-moneyness) — MODELLO +SIG_FLOOR = 0.05 +COST_FRAC = 0.05 # 5% del premio per gamba (commissioni+slippage, MODELLO) +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +DELTAS = (0.01, 0.02, 0.05) # distanze corto del progetto esterno +CENTRAL_SD = 0.05 # cella centrale A PRIORI: 5-delta (la piu' campionata in coda; +# le 1-2 delta sono il territorio "0 perdite = Sharpe implausibile") +DPY_TRADE = 252.0 / TENOR_TD # ~63 trade indipendenti/anno per strand +WINDOWS = [("1996-2002", "1996", "2003"), ("2003-2009", "2003", "2010"), + ("2010-2016", "2010", "2017"), ("2017-2023", "2017", "2024"), + ("2024-2026", "2024", "2027")] +TAIL_YEARS = (1998, 2002, 2008, 2011, 2018, 2020, 2022) + + +# ----------------------------------------------------------------------------- pricing (MODELLO) +def bs_put(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(K - S, 0.0) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + d2 = d1 - sig * np.sqrt(T) + return K * norm.cdf(-d2) - S * norm.cdf(-d1) + + +def bs_call(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(S - K, 0.0) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * np.sqrt(T)) + + +def sigma_k(sig_atm, S, K): + """Skew log-lineare equity: put OTM (K0.""" + d1 = norm.ppf(1 - delta) if kind == "put" else norm.ppf(delta) + sig = sig_atm + K = S + for _ in range(8): + K = S * np.exp(0.5 * sig * sig * T - d1 * sig * np.sqrt(T)) + sig = sigma_k(sig_atm, S, K) + return K, sig + + +# ----------------------------------------------------------------------------- dati +def load_master(): + spy = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / "eq_spy_1d.parquet") + px = pd.Series(spy["close"].astype(float).values, + index=pd.to_datetime(spy["timestamp"].astype("int64"), unit="ms", utc=True)) + + def _fred(fn, col): + f = SCRATCH / fn + if not f.exists(): + return None + c = pd.read_csv(f, na_values=".") + return pd.Series(c[col].astype(float).values, + index=pd.to_datetime(c["observation_date"], utc=True)) + + vix = _fred("vixcls.csv", "VIXCLS") + vxv = _fred("vxvcls.csv", "VXVCLS") + if vix is None: + raise SystemExit("Manca scratchpad/vixcls.csv (curl FRED VIXCLS) — vedi docstring.") + # IV-rank espandente CAUSALE sulla serie VIX nativa (warm-start dal 1990, come _ivrank) + vals = vix.dropna() + hist: list[float] = [] + rank = {} + for d, v in vals.items(): + if len(hist) >= 250: + rank[d] = bisect_left(hist, v) / len(hist) + insort(hist, v) + ivr = pd.Series(rank) + + df = pd.DataFrame({"px": px}) + df["vix"] = vix.reindex(df.index).ffill(limit=5) + df["ivr"] = ivr.reindex(df.index).ffill(limit=5) + df["vxv"] = vxv.reindex(df.index).ffill(limit=5) if vxv is not None else np.nan + lr = np.log(df["px"]).diff() + df["rv20"] = lr.rolling(20).std() * np.sqrt(252) * 100.0 # causale: usa ritorni fino a i + return df.dropna(subset=["px", "vix", "ivr"]) + + +# ----------------------------------------------------------------------------- motore +def run_base(df, sd=CENTRAL_SD, f=1.0): + """Vendita della double diagonal OGNI trading day (always-in). Ritorna DataFrame trade + (i, entry, exp, pnl su capitale S0, credit). I gate si applicano DOPO come maschera: + il pricing e' gate-indipendente -> una sola passata per (sd, f).""" + px = df["px"].values + vix = df["vix"].values / 100.0 + idx = df.index + n = len(px) + recs = [] + for i in range(21, n - TENOR_TD): + j = i + TENOR_TD + S0 = px[i]; sig0 = vix[i] + if not (np.isfinite(S0) and np.isfinite(sig0)) or sig0 <= 0: + continue + T = max((idx[j] - idx[i]).days, 1) / 365.25 # gap di calendario REALE + Tl = T + 1.0 / 365.25 # ali T+1g + # gambe corte (strangle) a delta sd; ali long a delta sd*WING_FRAC, scadenza T+1 + Kps, sps = strike_skew(S0, T, sig0, sd, "put") + Kcs, scs = strike_skew(S0, T, sig0, sd, "call") + Kpl, spl = strike_skew(S0, Tl, sig0, sd * WING_FRAC, "put") + Kcl, scl = strike_skew(S0, Tl, sig0, sd * WING_FRAC, "call") + p_ps = bs_put(S0, Kps, T, sps) / S0 * f + p_cs = bs_call(S0, Kcs, T, scs) / S0 * f + p_pl = bs_put(S0, Kpl, Tl, spl) / S0 * f + p_cl = bs_call(S0, Kcl, Tl, scl) / S0 * f + credit = (p_ps + p_cs) - (p_pl + p_cl) + # exit a scadenza del corto: corti a intrinseco, ali marcate BS 1g alla vol di uscita + S1 = px[j]; sig1 = vix[j] + short_pay = (max(0.0, Kps - S1) + max(0.0, S1 - Kcs)) / S0 + lp = bs_put(S1, Kpl, 1.0 / 365.25, sigma_k(sig1, S1, Kpl)) / S0 * f + lc = bs_call(S1, Kcl, 1.0 / 365.25, sigma_k(sig1, S1, Kcl)) / S0 * f + cost = COST_FRAC * (p_ps + p_cs + p_pl + p_cl) + COST_FRAC * (lp + lc) + pnl = credit - short_pay + (lp + lc) - cost + recs.append((i, idx[i], idx[j], pnl, credit)) + tr = pd.DataFrame(recs, columns=["i", "entry", "exp", "pnl", "credit"]) + tr["active"] = True + return tr + + +def apply_gate(base, mask): + """mask: Series booleana su df.index (causale a entry). Trade bloccato -> pnl 0, inattivo.""" + tr = base.copy() + ok = mask.reindex(tr["entry"]).fillna(False).values.astype(bool) + tr["active"] = ok + tr.loc[~tr["active"], "pnl"] = 0.0 + return tr + + +# ----------------------------------------------------------------------------- metriche +def _sh(x, ppy): + x = pd.Series(x).dropna() + return float(x.mean() / x.std() * np.sqrt(ppy)) if len(x) > 10 and x.std() > 0 else float("nan") + + +def daily_series(tr): + return tr.groupby("exp")["pnl"].sum() / TENOR_TD + + +def dd_of(daily): + eq = (1 + daily).cumprod() + pk = eq.cummax() + return float(((pk - eq) / pk).max()) + + +def agg(tr): + """Metriche oneste: Sharpe = media degli strand non-sovrapposti (banda = ancora); + equity/DD/CAGR dalla serie giornaliera; PF/win sui trade ATTIVI con N_eff = N/4.""" + strands = [tr[tr["i"] % TENOR_TD == o]["pnl"] for o in range(TENOR_TD)] + shs = [_sh(s, DPY_TRADE) for s in strands] + hold = tr[tr["exp"] >= HOLDOUT] + isam = tr[tr["exp"] < HOLDOUT] + shs_h = [_sh(hold[hold["i"] % TENOR_TD == o]["pnl"], DPY_TRADE) for o in range(TENOR_TD)] + shs_i = [_sh(isam[isam["i"] % TENOR_TD == o]["pnl"], DPY_TRADE) for o in range(TENOR_TD)] + d = daily_series(tr) + eq = (1 + d).cumprod() + yrs = len(d) / 252.0 + act = tr[tr["active"]]["pnl"] + pos = act[act > 0].sum(); neg = -act[act < 0].sum() + return dict( + sh=float(np.nanmean(shs)), sh_lo=float(np.nanmin(shs)), sh_hi=float(np.nanmax(shs)), + sh_is=float(np.nanmean(shs_i)), sh_h=float(np.nanmean(shs_h)), + sh_h_lo=float(np.nanmin(shs_h)) if np.isfinite(shs_h).any() else float("nan"), + sh_h_hi=float(np.nanmax(shs_h)) if np.isfinite(shs_h).any() else float("nan"), + cagr=float(eq.iloc[-1] ** (1 / yrs) - 1) if yrs > 0 and eq.iloc[-1] > 0 else -1.0, + dd=dd_of(d), worst5=float(d.rolling(5).sum().min()), + pf=float(pos / neg) if neg > 0 else float("inf"), + win=float((act > 0).mean()) if len(act) else float("nan"), + n_act=int(len(act)), n_eff=int(len(act) / TENOR_TD), + act_frac=float(tr["active"].mean()), nloss=int((act < 0).sum())) + + +def per_year(tr): + d = daily_series(tr) + return {int(y): float((1 + g).prod() - 1) for y, g in d.groupby(d.index.year)} + + +def win_sh(tr, a, b): + """Sharpe strand-medio nella finestra [a, b) (su scadenza).""" + w = tr[(tr["exp"] >= pd.Timestamp(a, tz="UTC")) & (tr["exp"] < pd.Timestamp(b, tz="UTC"))] + return float(np.nanmean([_sh(w[w["i"] % TENOR_TD == o]["pnl"], DPY_TRADE) + for o in range(TENOR_TD)])) + + +def delever_k(daily_full, dd_target): + lo, hi = 0.0, 1.0 + if dd_of(daily_full) <= dd_target: + return 1.0 + for _ in range(40): + k = 0.5 * (lo + hi) + if dd_of(k * daily_full) > dd_target: + hi = k + else: + lo = k + return lo + + +def row(label, m): + pf = f"{m['pf']:5.2f}" if np.isfinite(m["pf"]) else " inf" + print(f" {label:<42} {m['sh']:>5.2f} [{m['sh_lo']:>5.2f},{m['sh_hi']:>5.2f}] " + f"{m['sh_is']:>6.2f} {m['sh_h']:>6.2f} {m['cagr']*100:>+6.2f}% {m['dd']*100:>5.1f}% " + f"{m['worst5']*100:>+6.2f}% {pf} {m['win']*100:>4.0f}% {m['act_frac']*100:>4.0f}% " + f"{m['n_eff']:>5}") + + +HDR = (f" {'config':<42} {'Sh':>5} {'[banda ancora]':>14} {'Sh-IS':>6} {'Sh-HO':>6} " + f"{'CAGR':>7} {'maxDD':>6} {'w5d':>7} {'PF':>5} {'win%':>5} {'att%':>5} {'Neff':>5}") + + +def main(): + print("=" * 118) + print(" R0703 VRPIMP-SPXGATE — gate di regime (VIX-rank / term-structure) sulla double diagonal SPX") + print(" ⚠️ MODELLO: BS + skew log-lineare su VIX-30g @ tenor 6g; VIX/VXV da FRED research-grade NON certificati.") + print(" ⚠️ Trade sovrapposti (~4 concorrenti trading-day, ~6 calendario): Sharpe = media 4 strand indipendenti;") + print(" PF/win su trade sovrapposti -> campione effettivo = N/4. Capitale = S0 cash-secured (conv. ALB).") + print("=" * 118) + df = load_master() + print(f" SPY {df.index[0].date()} -> {df.index[-1].date()} ({len(df)} td) | VIX medio {df['vix'].mean():.1f} " + f"| RV20 media {df['rv20'].mean():.1f} | VRP(VIX-RV20) medio {(df['vix']-df['rv20']).mean():+.1f} pt, " + f">0 nel {((df['vix']-df['rv20'])>0).mean()*100:.0f}% dei giorni | VXV dal " + f"{df['vxv'].dropna().index[0].date() if df['vxv'].notna().any() else 'N/A'}") + + trials = {} # (tag) -> Sharpe FULL strand-medio: conteggio per deflated-Sharpe + # ------------------------------------------------------------------ maschere gate (causali) + m_vrp = (df["vix"] - df["rv20"]) > 0 + m_cs = df["ivr"] <= 0.90 + m_canon = m_vrp & (df["ivr"] >= 0.30) & m_cs + m_contango = (df["vix"] / df["vxv"]) < 1.0 + m_contango = m_contango.fillna(False) + + # ------------------------------------------------------------------ (1) griglia struttura + print(f"\n (1) STRUTTURA always-in, f=1.0 — distanze corto {{1,2,5}}-delta (ali a delta/2, T+1g)") + print(HDR) + bases = {} + for sd in DELTAS: + bases[sd] = run_base(df, sd=sd, f=1.0) + m = agg(bases[sd]) + cr = bases[sd]["credit"] + tag = " ⚠️ 0-loss=Sharpe implausibile (CC01)" if m["nloss"] == 0 else "" + row(f"diag {sd*100:.0f}Δ always-in (credito {cr.mean()*1e4:+.1f}bps)", m) + if tag: + print(f" {tag}") + trials[f"sd{sd}_always"] = m["sh"] + base = bases[CENTRAL_SD] + print(f" -> cella centrale A PRIORI: {CENTRAL_SD*100:.0f}Δ (dichiarata nel docstring prima di guardare i numeri)") + + # ------------------------------------------------------------------ (2) gate VIX-rank + print(f"\n (2a) GATE VIX-RANK sulla cella centrale (f=1.0) — soglia scelta SOLO in-sample (pre-2025)") + print(HDR) + m_always = agg(base) + row("ALWAYS-IN (baseline)", m_always) + gates = {"vrp>0": m_vrp, + "CANONICO crypto (vrp>0 & 0.30<=ivr<=0.90)": m_canon} + for th in (0.20, 0.30, 0.40, 0.50): + gates[f"ivr>={th:.2f}"] = df["ivr"] >= th + gates[f"ivr>={th:.2f} & cs0.90"] = (df["ivr"] >= th) & m_cs + res = {} + for tag, msk in gates.items(): + tr = apply_gate(base, msk) + res[tag] = (tr, agg(tr)) + row(tag, res[tag][1]) + trials[tag] = res[tag][1]["sh"] + sel_tag = max(res, key=lambda t: res[t][1]["sh_is"]) + sel_tr, sel_m = res[sel_tag] + can_tr, can_m = res["CANONICO crypto (vrp>0 & 0.30<=ivr<=0.90)"] + print(f" -> cella scelta IN-SAMPLE: '{sel_tag}' (Sh-IS {sel_m['sh_is']:.2f}) | suo hold-out {sel_m['sh_h']:.2f} " + f"[{sel_m['sh_h_lo']:.2f},{sel_m['sh_h_hi']:.2f}]") + dsr, sr0 = deflated_sharpe(sel_m["sh"], list(trials.values()), + sel_tr[sel_tr["i"] % TENOR_TD == 0]["pnl"], dpy=DPY_TRADE) + print(f" -> deflated-Sharpe della cella scelta su {len(trials)} celle: DSR={dsr:.3f} " + f"(null max atteso {sr0:.2f}) {'PASS' if dsr >= 0.95 else 'FAIL'} (>=0.95)") + + # ------------------------------------------------------------------ (3) per-anno completo + print(f"\n (3) PER-ANNO COMPLETO (f=1.0, ritorni su capitale S0) — * = anno di coda") + pa, pc, ps = per_year(base), per_year(can_tr), per_year(sel_tr) + print(f" {'anno':<6} {'ALWAYS':>8} {'CANONICO':>9} {'SELECTED':>9} {'anno':<6} {'ALWAYS':>8} {'CANONICO':>9} {'SELECTED':>9}") + ys = sorted(pa) + half = (len(ys) + 1) // 2 + for k in range(half): + cells = [] + for y in (ys[k], ys[k + half] if k + half < len(ys) else None): + if y is None: + cells.append(" " * 37) + continue + star = "*" if y in TAIL_YEARS else " " + cells.append(f"{y}{star:<1} {pa[y]*100:>+7.2f}% {pc.get(y,0)*100:>+8.2f}% {ps.get(y,0)*100:>+8.2f}%") + print(" " + " ".join(cells)) + flips_c = [y for y in TAIL_YEARS if y in pa and pa[y] < 0 and pc.get(y, 0) >= 0] + flips_s = [y for y in TAIL_YEARS if y in pa and pa[y] < 0 and ps.get(y, 0) >= 0] + worse_c = [y for y in TAIL_YEARS if y in pa and pc.get(y, 0) < min(0.0, pa[y])] + print(f" anni di coda RIBALTATI (neg->non-neg): canonico {flips_c or 'NESSUNO'} | selected {flips_s or 'NESSUNO'}") + print(f" anni di coda PEGGIORATI dal canonico: {worse_c or 'nessuno'}") + + # ------------------------------------------------------------------ (4) term structure + print(f"\n (4) (2b) GATE TERM-STRUCTURE VIX/VXV — finestra comune dal 2008 (VXV research-grade)") + sub = lambda tr: tr[tr["entry"] >= TS_START] # noqa: E731 + print(HDR) + ts_res = {} + cont_tr = apply_gate(base, m_contango) + for tag, tr in (("ALWAYS-IN (2008+)", sub(base)), + ("contango VIX/VXV<1 (2008+)", sub(cont_tr)), + ("CANONICO (2008+)", sub(can_tr)), + ("CANONICO & contango (2008+)", sub(apply_gate(base, m_canon & m_contango)))): + ts_res[tag] = agg(tr) + row(tag, ts_res[tag]) + trials[f"ts_{tag}"] = ts_res[tag]["sh"] + # la finestra 2008-2009 (esclusa dal multicut per costruzione) e' il banco di prova di coda + sa08 = win_sh(base, "2008", "2010"); st08 = win_sh(cont_tr, "2008", "2010") + print(f" finestra 2008-2009 (GFC): always {sa08:.2f} vs contango {st08:.2f} (Δ {st08-sa08:+.2f})") + # per-anno del contango (2008+) + code ribaltate + pt = per_year(sub(cont_tr)); pa8 = {y: v for y, v in per_year(sub(base)).items()} + print(f" per-anno 2008+ (ALWAYS vs CONTANGO): " + " ".join( + f"{y}{'*' if y in TAIL_YEARS else ''}:{pa8[y]*100:+.2f}/{pt.get(y,0)*100:+.2f}%" for y in sorted(pa8))) + flips_t = [y for y in TAIL_YEARS if y in pa8 and pa8[y] < 0 and pt.get(y, 0) >= 0] + print(f" anni di coda RIBALTATI dal contango: {flips_t or 'NESSUNO'}") + # sovrapposizione maschere (il contango e' un crash-skip travestito?) — giorni 2008+ + w8 = df.index >= TS_START + for tag, mk in (("ivr<=0.90 (crash-skip)", m_cs), ("vrp>0", m_vrp), ("canonico", m_canon)): + a_ = m_contango[w8].values; b_ = mk[w8].values + agree = float((a_ == b_).mean()) + print(f" accordo giorni contango vs {tag:<22}: {agree*100:.0f}% (contango attivo {a_.mean()*100:.0f}%, " + f"{tag.split()[0]} attivo {b_.mean()*100:.0f}%)") + dsr_t, sr0_t = deflated_sharpe(ts_res["contango VIX/VXV<1 (2008+)"]["sh"], list(trials.values()), + sub(cont_tr)[sub(cont_tr)["i"] % TENOR_TD == 0]["pnl"], dpy=DPY_TRADE) + print(f" deflated-Sharpe contango su {len(trials)} celle totali provate: DSR={dsr_t:.3f} " + f"{'PASS' if dsr_t >= 0.95 else 'FAIL'} (>=0.95)") + print(" ⚠️ CAVEAT MODELLO (decisivo): il pricing usa VIX-30g come IV del tenor 6g. In BACKWARDATION") + print(" la IV front-end reale e' >> VIX -> il credito reale nei giorni esclusi dal gate e' maggiore") + print(" del modellato: l'uplift del contango e' quindi in parte SOVRASTIMATO per costruzione.") + + # ------------------------------------------------------------------ (5) null de-levering + print(f"\n (5) NULL DEL DE-LEVERING (obbligatorio) — always-in scalato al maxDD del gated:") + d_full = daily_series(base) + for tag, m_g in (("CANONICO", can_m), (f"SELECTED '{sel_tag}'", sel_m), + ("contango (2008+)", ts_res["contango VIX/VXV<1 (2008+)"])): + dref = d_full if "2008" not in tag else d_full[d_full.index >= TS_START] + sh_ref = m_always["sh"] if "2008" not in tag else ts_res["ALWAYS-IN (2008+)"]["sh"] + k = delever_k(dref, m_g["dd"]) + eq = (1 + k * dref).cumprod(); yrs = len(dref) / 252.0 + cagr_k = float(eq.iloc[-1] ** (1 / yrs) - 1) + beat = m_g["sh"] - sh_ref + print(f" {tag:<28} gated: Sh {m_g['sh']:.2f} DD {m_g['dd']*100:.1f}% CAGR {m_g['cagr']*100:+.2f}% | " + f"de-lever k={k:.2f}: Sh {sh_ref:.2f} (invariante) CAGR {cagr_k:+.2f}% -> " + f"ΔSh gate-vs-delever {beat:+.2f} {'(il gate AGGIUNGE)' if beat > 0.1 else '(de-levering basta)'}") + + # ------------------------------------------------------------------ (6) multi-cut + print(f"\n (6) PERSISTENZA MULTI-FINESTRA (uplift Sharpe strand-medio gated - always, finestre disgiunte)") + print(f" {'finestra':<12} {'ALWAYS':>7} {'CANON':>7} {'Δcanon':>8} {'SELECT':>7} {'Δsel':>8} {'contango':>9} {'Δcont':>8}") + pos_c = pos_s = pos_t = n_t = 0 + for wtag, a, b in WINDOWS: + sa = win_sh(base, a, b); sc = win_sh(can_tr, a, b); ss = win_sh(sel_tr, a, b) + in_ts = pd.Timestamp(a, tz="UTC") >= TS_START + st = win_sh(apply_gate(base, m_contango), a, b) if in_ts else float("nan") + dc, dsl = sc - sa, ss - sa + dt = (st - sa) if in_ts else float("nan") + pos_c += dc > 0; pos_s += dsl > 0 + if in_ts: + pos_t += dt > 0; n_t += 1 + print(f" {wtag:<12} {sa:>7.2f} {sc:>7.2f} {dc:>+8.2f} {ss:>7.2f} {dsl:>+8.2f} " + f"{st:>9.2f} {dt:>+8.2f}") + print(f" -> multicut: canonico {pos_c}/5 | selected {pos_s}/5 | contango {pos_t}/{n_t} (2008+)") + + # ------------------------------------------------------------------ (7) banda f + print(f"\n (7) BANDA f {F_SWEEP} (regola skew n.4) — cella centrale, always vs canonico vs selected") + print(HDR) + sel_mask = gates[sel_tag] + sub8 = lambda tr: tr[tr["entry"] >= TS_START] # noqa: E731 + for f in F_SWEEP: + bf = bases[CENTRAL_SD] if f == 1.0 else run_base(df, sd=CENTRAL_SD, f=f) + row(f"always-in f={f}", agg(bf)) + row(f"canonico f={f}", agg(apply_gate(bf, m_canon))) + row(f"selected f={f}", agg(apply_gate(bf, sel_mask))) + a8 = agg(sub8(bf)); c8 = agg(sub8(apply_gate(bf, m_contango))) + print(f" -> contango f={f} (2008+): Sh {c8['sh']:.2f} vs always {a8['sh']:.2f} " + f"(Δ {c8['sh']-a8['sh']:+.2f}) DD {c8['dd']*100:.1f}% vs {a8['dd']*100:.1f}%") + + # ------------------------------------------------------------------ verdetto + print("\n DOMANDA SECCA — il gate ribalta gli anni di coda come IV-rank su crypto (HOLD -0.25->+0.28)?") + print(f" crypto VRP01: gate ivr>=0.30 = vendi solo vol RICCA -> ribalta l'hold-out.") + print(f" SPX qui: always Sh {m_always['sh']:.2f} (HO {m_always['sh_h']:.2f}) vs canonico Sh {can_m['sh']:.2f} " + f"(HO {can_m['sh_h']:.2f}); code ribaltate: {flips_c or 'NESSUNA'}; multicut canonico {pos_c}/5; DSR {dsr:.2f}.") + print(" ⚠️ Tutto su MODELLO (BS+skew log-lineare, VIX 30g @ 6g, dati FRED non certificati). Regola standing:") + print(" niente short-vol da modello in deploy — esito = conoscenza sul meccanismo del gate, non un LEAD.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_stresslab.py b/scripts/research/r0703_vrpimp_stresslab.py new file mode 100644 index 0000000..f44d952 --- /dev/null +++ b/scripts/research/r0703_vrpimp_stresslab.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python +"""r0703_vrpimp_stresslab.py — FILONE 7: STRESS LAB DI CODA per la famiglia short-vol crypto. + +NON e' ricerca di alpha: e' FISICA DELLO STRUMENTO (niente hold-out, niente selezione — dichiarato). +Tre banchi: + + (a) REPLAY STORICI — le 10 peggiori finestre 2-settimane di BTC/ETH 2019-2026 (prezzi CERTIFICATI, + load_tf 1d) applicate a VRP01 canonico (put credit spread Δ-0.28/-0.10, 7g) e alle strutture + ALB-A alla cella a-priori z=2σ/ali+1σ/5g (vertical / condor / diagonal T+1), gate canonico + ON/OFF. IV: REALE (DVOL) dove esiste (2021-03+); prima, salta secondo la relazione EMPIRICA + DVOL-vs-ritorno stimata dai dati (regressione piecewise Δdvol_pts ~ b−·ret− + b+·ret+ su + finestre NON sovrapposte; applicata in avanti dal punto d'ingresso = nessun uso di futuro + dentro la finestra). Entry di base = GIORNO DI PICCO della finestra (worst-case timing); + banda d'ancora = fase d'ingresso 0..tenor-1. Banda f obbligatoria. + + (b) MATRICE SINTETICA — gap {-10,-15,-20,-30}% × IV-spike {×1.5,×2,×3} × timing {overnight, + intra-settimana}: perdita per struttura in unita' di CREDITO MEDIO (storico, gated) e in + % del capitale a sizing 12% (convenzione margine-deployed = max-loss defined-risk; per i + nudi il margine non e' definito → cash-secured, dichiarato). Il timing muove il MARK-TO- + MARKET al gap (stress di margine), non la perdita a scadenza (stessa S1): riportati entrambi. + L'asse IV-spike e' ancorato all'empirico (la regressione dice quale ×IV produce ogni gap). + + (c) VALORE DELL'ALA — per ogni feature strutturale (ala far-OTM del VRP01 Δ-0.10; ala far-OTM + dello strangle z2; ala T+1 del diagonale; ala PIU' VICINA dz=0.5): drag annuo storico in bps + (decomposto calm-drag vs tail-benefit come ALB-A) vs protezione comprata in ogni cella della + matrice → tabella "protezione di coda per bps di drag" + NULL DEL DE-LEVERING esplicito + (lezione TP01×DVOL 2026-06-26): se ridurre la size della struttura SENZA ala raggiunge la + stessa perdita di cella a costo CAGR minore, l'ala NON vale. + + Deliverable finale: worst-case ONESTO del VRP01 attuale a 2k/5k di capitale in EUR (convenzione + book cash-secured E convenzione margine-deployed, granulare in spread interi ETH), banda f. + +MACCHINERIA RIUSATA (non riscritta): motore DVOL di r0702_alb_structure (bs_call, _fee_frac, +run_structure, book, metrics — riproduce VRP01 esatto), options_vrp_lab (bs_put, load_series, +strike_from_delta), options_vrp_v2 (vrp_spread_weekly canonico, _ivrank, _rv30). Il codice NUOVO +e' solo (i) pricing di scenario a (S0, σ0, S1, σ1) fissati, (ii) replay su path certificato con +IV sintetica pre-DVOL, (iii) contabilita' drag/protezione. + +CAVEAT (in testa, non in fondo): pricing BS FLAT su DVOL-30g usato a tenor 5-7g e 1g (term +structure ignorata: in stress il front-end esplode → il MtM avverso e' SOTTOSTIMATO); skew non +esplicito → banda f {0.6,0.8,1.0,1.3} su ogni claim; DVOL pre-2021 NON esiste → le finestre +2019-2020 (COVID incluso) sono SCENARI regression-driven, non backtest. Regola standing INVARIATA: +niente short-vol da modello in deploy — l'esito massimo e' conoscenza. + +pandas 2.x: nessun DatetimeIndex.view('int64'); nessun resample '7D' (cadenze a passi d'indice). + + uv run python scripts/research/r0703_vrpimp_stresslab.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT)) + +import altlib as al # noqa: E402 (fee/holdout conventions; qui usato solo per coerenza costanti) +from scripts.research.options_vrp_lab import bs_put, load_series, strike_from_delta # noqa: E402 +from scripts.research.options_vrp_v2 import vrp_spread_weekly # noqa: E402 +from scripts.research.r0702_alb_structure import ( # noqa: E402 + bs_call, _fee_frac, run_structure, book, metrics, +) +from scripts.analysis.research_lab import load_tf # noqa: E402 + +DAY = 1.0 / 365.25 +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +START = pd.Timestamp("2019-01-01", tz="UTC") +ASSETS = ("BTC", "ETH") +ALB = dict(z=2.0, dz=1.0, tenor_d=5) # cella a-priori ALB-A (non riottimizzata) +SIZING = 0.12 # peso VRP01 nel book +GAPS = (-0.10, -0.15, -0.20, -0.30) +IVMULT = (1.5, 2.0, 3.0) +IV_FLOOR, IV_CAP = 25.0, 250.0 # punti DVOL, clamp della IV sintetica + +# strutture del banco: kind -> (engine-kind, z, dz, tenor, label) +STRUCTS = { + "NAKED28": dict(tenor=7, label="naked put Δ-0.28 7g (VRP01 senza ala)"), + "VRP01": dict(tenor=7, label="VRP01 spread Δ-0.28/-0.10 7g"), + "STRANGLE": dict(tenor=5, label="short strangle z2 5g (ALB senza ali)"), + "VERT": dict(tenor=5, label="vertical put z2/+1σ 5g"), + "CONDOR": dict(tenor=5, label="iron condor z2/+1σ 5g"), + "DIAG": dict(tenor=5, label="double diagonal z2/+1σ T+1 5g"), +} + + +# =========================================================================== +# PRICING DI SCENARIO — un trade a (S0, σ0) → (S1, σ1), stessa matematica del +# motore r0702_alb_structure (fee per gamba 0.03% cap 12.5%, delivery 0.015%) +# =========================================================================== +def _strikes(kind: str, S0: float, sig0: float, tenor_d: int, z: float, dz: float): + T = tenor_d / 365.25 + if kind in ("VRP01", "NAKED28"): + Kp_s = strike_from_delta(S0, T, sig0, -0.28) + Kp_l = strike_from_delta(S0, T, sig0, -0.10) if kind == "VRP01" else None + return Kp_s, Kp_l, None, None + m = sig0 * np.sqrt(T) + Kp_s = S0 * np.exp(-z * m) + Kp_l = S0 * np.exp(-(z + dz) * m) if kind in ("VERT", "CONDOR", "DIAG") else None + Kc_s = S0 * np.exp(+z * m) if kind in ("STRANGLE", "CONDOR", "DIAG") else None + Kc_l = S0 * np.exp(+(z + dz) * m) if kind in ("CONDOR", "DIAG") else None + return Kp_s, Kp_l, Kc_s, Kc_l + + +def trade_pnl(kind: str, S0: float, sig0: float, S1: float, sig1: float, + tenor_d: int, f_put: float = 1.0, f_call: float = 1.0, + z: float = 2.0, dz: float = 1.0) -> dict: + """PnL a scadenza degli short (frazione di S0). DIAG: ali T+1 marcate BS a σ1 (vega).""" + T = tenor_d / 365.25 + T_l = T + DAY if kind == "DIAG" else T + Kp_s, Kp_l, Kc_s, Kc_l = _strikes(kind, S0, sig0, tenor_d, z, dz) + ps = bs_put(S0, Kp_s, T, sig0) / S0 * f_put + pl = bs_put(S0, Kp_l, T_l, sig0) / S0 * f_put if Kp_l else 0.0 + cs = bs_call(S0, Kc_s, T, sig0) / S0 * f_call if Kc_s else 0.0 + cl = bs_call(S0, Kc_l, T_l, sig0) / S0 * f_call if Kc_l else 0.0 + credit = (ps + cs) - (pl + cl) + short_pay = max(0.0, Kp_s - S1) / S0 + (max(0.0, S1 - Kc_s) / S0 if Kc_s else 0.0) + if kind == "DIAG": + lp = bs_put(S1, Kp_l, DAY, sig1) / S0 * f_put + lc = bs_call(S1, Kc_l, DAY, sig1) / S0 * f_call + long_val = lp + lc + exit_fee = sum(_fee_frac(v, notional_ratio=S1 / S0) for v in (lp, lc)) + else: + long_val = (max(0.0, Kp_l - S1) / S0 if Kp_l else 0.0) \ + + (max(0.0, S1 - Kc_l) / S0 if Kc_l else 0.0) + exit_fee = 0.0 + legs = [v for v in (ps, pl, cs, cl) if v > 0] + entry_fee = sum(_fee_frac(v) for v in legs) + deliv = _fee_frac(max(0.0, Kp_s - S1) / S0, rate=0.00015) + if Kc_s: + deliv += _fee_frac(max(0.0, S1 - Kc_s) / S0, rate=0.00015) + pnl = credit - short_pay + long_val - entry_fee - exit_fee - deliv + widths = [w for w in ((Kp_s - Kp_l) / S0 if Kp_l else np.nan, + (Kc_l - Kc_s) / S0 if Kc_l else np.nan) if np.isfinite(w)] + margin = (max(widths) - credit) if widths else np.nan # defined-risk max loss + return dict(pnl=pnl, credit=credit, margin=margin, ks_frac=Kp_s / S0) + + +def trade_mtm(kind: str, sig0: float, gap: float, mult: float, t_gap: int, + tenor_d: int, f_put: float = 1.0, f_call: float = 1.0, + z: float = 2.0, dz: float = 1.0) -> float: + """Mark-to-market al giorno del gap (chiusura ipotetica, senza fee di chiusura): + tutte le gambe riprezzate BS al tempo residuo e alla IV spikeata. Frazione di S0=1.""" + S0 = 1.0 + T = tenor_d / 365.25 + Kp_s, Kp_l, Kc_s, Kc_l = _strikes(kind, S0, sig0, tenor_d, z, dz) + ps = bs_put(S0, Kp_s, T, sig0) * f_put + pl = bs_put(S0, Kp_l, (T + DAY if kind == "DIAG" else T), sig0) * f_put if Kp_l else 0.0 + cs = bs_call(S0, Kc_s, T, sig0) * f_call if Kc_s else 0.0 + cl = bs_call(S0, Kc_l, (T + DAY if kind == "DIAG" else T), sig0) * f_call if Kc_l else 0.0 + credit = (ps + cs) - (pl + cl) + legs = [v for v in (ps, pl, cs, cl) if v > 0] + entry_fee = sum(_fee_frac(v) for v in legs) + S1 = 1.0 + gap + sig_sp = sig0 * mult + Tr = max(tenor_d - t_gap, 0) / 365.25 + Tr_l = Tr + DAY if kind == "DIAG" else Tr + sv = bs_put(S1, Kp_s, Tr, sig_sp) * f_put + (bs_call(S1, Kc_s, Tr, sig_sp) * f_call if Kc_s else 0.0) + lv = (bs_put(S1, Kp_l, Tr_l, sig_sp) * f_put if Kp_l else 0.0) \ + + (bs_call(S1, Kc_l, Tr_l, sig_sp) * f_call if Kc_l else 0.0) + return credit - (sv - lv) - entry_fee + + +# =========================================================================== +# DATI FULL-HISTORY (2019+) + IV SINTETICA (proxy RV30+medVRP, regressione Δdvol~ret) +# =========================================================================== +def full_history(asset: str) -> dict: + d = load_tf(asset, "1d") + s = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + s = s[s.index >= START] + px = s.values + n = len(px) + lr = np.diff(np.log(px)) + rv30 = np.full(n, np.nan) + for i in range(30, n): + rv30[i] = float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come _rv30 (ddof=0) + dv = pd.read_parquet(ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet") + dvs = pd.Series(dv["close"].values.astype(float), + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + dvol = dvs.reindex(s.index).values # punti %, NaN pre-2021 + ok = ~np.isnan(dvol) & ~np.isnan(rv30) + medvrp = float(np.median(dvol[ok] - rv30[ok] * 100.0)) # premio mediano IV-RV, punti + proxy = rv30 * 100.0 + medvrp # IV proxy pre-DVOL, punti + hist_iv = np.where(np.isnan(dvol), proxy, dvol) # serie spliced per il RANK + return dict(idx=s.index, px=px, n=n, rv30=rv30, dvol=dvol, proxy=proxy, + hist_iv=hist_iv, medvrp=medvrp) + + +def fit_dvol_reg(asset: str, horizon_d: int) -> dict: + """Regressione piecewise Δdvol_pts = a + b−·min(ret,0) + b+·max(ret,0), finestre NON + sovrapposte di horizon_d giorni sull'era DVOL (2021-03+). E' una relazione CONTEMPORANEA + (fisica del salto di IV), applicata in avanti nel replay — nessun uso del futuro.""" + J = load_series(asset) + px = J["px"].values + dv = J["dvol"].values + r, d = [], [] + for i in range(0, len(px) - horizon_d, horizon_d): + r.append(px[i + horizon_d] / px[i] - 1.0) + d.append(dv[i + horizon_d] - dv[i]) + r = np.asarray(r); d = np.asarray(d) + X = np.column_stack([np.ones_like(r), np.minimum(r, 0.0), np.maximum(r, 0.0)]) + beta, *_ = np.linalg.lstsq(X, d, rcond=None) + pred = X @ beta + r2 = 1.0 - ((d - pred) ** 2).sum() / ((d - d.mean()) ** 2).sum() + return dict(a=float(beta[0]), b_neg=float(beta[1]), b_pos=float(beta[2]), + r2=float(r2), n=len(r)) + + +def iv_jump(iv0_pts: float, cum_ret: float, reg: dict) -> float: + """IV sintetica: livello d'ingresso + salto regressione (intercetta esclusa: e' drift + di regime, non fisica del salto — dichiarato).""" + v = iv0_pts + reg["b_neg"] * min(cum_ret, 0.0) + reg["b_pos"] * max(cum_ret, 0.0) + return float(np.clip(v, IV_FLOOR, IV_CAP)) + + +def worst_windows(px: np.ndarray, n_win: int = 10, ndays: int = 14) -> list[int]: + """Indici di partenza delle n_win peggiori finestre ndays NON sovrapposte.""" + r = px[ndays:] / px[:-ndays] - 1.0 + order = np.argsort(r) + picked: list[int] = [] + for i in order: + if all(abs(int(i) - j) >= ndays for j in picked): + picked.append(int(i)) + if len(picked) >= n_win: + break + return picked + + +# =========================================================================== +# REPLAY di una finestra: vendita sistematica della struttura dentro [i0, i0+14) +# =========================================================================== +def replay_window(H: dict, reg: dict, i0: int, kind: str, gated: bool, + f_put: float = 1.0, f_call: float = 1.0, phase: int = 0, + ndays: int = 14) -> dict: + px, n = H["px"], H["n"] + tenor = STRUCTS[kind]["tenor"] + t = i0 + phase + entry0 = t + tot = 0.0 + n_tr = n_skip = 0 + credits, margins = [], [] + while t < i0 + phase + ndays and t + tenor < n: + S0 = px[t] + real = not np.isnan(H["dvol"][t]) + if real: + sig0_pts = H["dvol"][t] + else: + base = H["proxy"][entry0] if np.isnan(H["dvol"][entry0]) else H["dvol"][entry0] + sig0_pts = iv_jump(base, px[t] / px[entry0] - 1.0, reg) + if gated: + rv = H["rv30"][t] + hist = H["hist_iv"][30:t] + ivr = float((hist < sig0_pts).mean()) if len(hist) >= 60 else np.nan + skip = ((not np.isnan(rv) and (sig0_pts / 100.0 - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + if skip: + n_skip += 1 + t += tenor + continue + j = t + tenor + S1 = px[j] + if not np.isnan(H["dvol"][j]): + sig1_pts = H["dvol"][j] + else: + base = H["proxy"][entry0] if np.isnan(H["dvol"][entry0]) else H["dvol"][entry0] + sig1_pts = iv_jump(base, px[j] / px[entry0] - 1.0, reg) + r = trade_pnl(kind, S0, sig0_pts / 100.0, S1, sig1_pts / 100.0, tenor, + f_put=f_put, f_call=f_call, z=ALB["z"], dz=ALB["dz"]) + tot += r["pnl"] + credits.append(r["credit"]); margins.append(r["margin"]) + n_tr += 1 + t += tenor + fin_m = [m for m in margins if np.isfinite(m)] + return dict(pnl=tot, n_tr=n_tr, n_skip=n_skip, + credit=float(np.mean(credits)) if credits else np.nan, + margin=float(np.mean(fin_m)) if fin_m else np.nan) + + +# =========================================================================== +# MAIN +# =========================================================================== +def main() -> None: + print("=" * 112) + print(" R0703 VRPIMP-STRESSLAB — banco di stress di coda per la famiglia short-vol (fisica, non selezione)") + print(" ⚠️ BS flat su DVOL-30g (term structure ignorata: MtM in stress SOTTOSTIMATO); banda f obbligatoria;") + print(" finestre pre-2021 = SCENARI regression-driven (DVOL non esiste); niente hold-out: nessuna selezione.") + print("=" * 112) + + # ---------------------------------------------------------------- (0) setup + check motore + H = {a: full_history(a) for a in ASSETS} + vrp_canon = pd.concat( + {a[0]: vrp_spread_weekly(a, defined_risk=True, f=1.0, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90) for a in ASSETS}, + axis=1, join="inner").mean(axis=1) + mm = metrics(vrp_canon, 7) + print(f"\n(0) CHECK MOTORE: VRP01 canonico riprodotto — ShF {mm['sh']:.2f} / ShH {mm['sh_h']:.2f} / " + f"DD {mm['dd']*100:.1f}% (atteso ~1.09/0.59/11.8%)") + for a in ASSETS: + h = H[a] + print(f" {a}: storia 1d {h['idx'][0].date()} → {h['idx'][-1].date()} ({h['n']} g) | " + f"DVOL reale da {h['idx'][np.argmax(~np.isnan(h['dvol']))].date()} | " + f"premio mediano IV-RV (proxy pre-DVOL) = {h['medvrp']:+.1f} pt") + + # ---------------------------------------------------------------- (1) relazione empirica DVOL-vs-ritorno + print("\n" + "=" * 112) + print("(1) RELAZIONE EMPIRICA Δdvol ~ ritorno (piecewise, finestre non sovrapposte, era DVOL 2021-2026)") + print("=" * 112) + reg = {} + for a in ASSETS: + reg[a] = dict(w=fit_dvol_reg(a, 7), d=fit_dvol_reg(a, 1)) + w, d = reg[a]["w"], reg[a]["d"] + print(f" {a} weekly (7g, n={w['n']}): Δdvol = {w['a']:+.1f} {w['b_neg']:+.1f}·ret⁻ {w['b_pos']:+.1f}·ret⁺ R²={w['r2']:.2f}") + print(f" {a} daily (1g, n={d['n']}): Δdvol = {d['a']:+.1f} {d['b_neg']:+.1f}·ret⁻ {d['b_pos']:+.1f}·ret⁺ R²={d['r2']:.2f}") + print("\n Grounding dell'asse IV-spike della matrice (b): moltiplicatore implicito partendo dalla DVOL mediana") + for a in ASSETS: + J = load_series(a) + med = float(np.median(J["dvol"].values)) + b = reg[a]["d"]["b_neg"] + mults = {g: (med + b * g) / med for g in GAPS} + mx1 = float((J["dvol"] / J["dvol"].shift(1)).max()) + mx7 = float((J["dvol"] / J["dvol"].shift(7)).max()) + print(f" {a}: DVOL mediana {med:.0f} → gap overnight " + + " ".join(f"{g*100:+.0f}%→×{m:.2f}" for g, m in mults.items()) + + f" | max storico ×{mx1:.2f} (1g), ×{mx7:.2f} (7g)") + print(" → ×1.5 e' un crash empiricamente normale, ×2 ≈ il massimo storico settimanale, ×3 = oltre-campione") + print(" (stress bound; il lineare della regressione NON estrapola fin li' — dichiarato).") + + # ---------------------------------------------------------------- (2) replay 10 peggiori finestre + print("\n" + "=" * 112) + print("(2) REPLAY — 10 peggiori finestre 14g per asset (entry al PICCO, fase 0), f=1.0") + print(" perdita per struttura in % del NOTIONAL S0 (somma dei trade nella finestra); G=gate canonico") + print("=" * 112) + kinds = list(STRUCTS.keys()) + replay_rows: dict[str, list] = {a: [] for a in ASSETS} + for a in ASSETS: + h = H[a] + wins = worst_windows(h["px"]) + wins.sort() + print(f"\n [{a}] {'finestra':<23} {'ret14':>7} {'IV':>5}" + + "".join(f" {k:>9} {k[:5]+'·G':>9}" for k in kinds)) + for i0 in wins: + d0, d1 = h["idx"][i0].date(), h["idx"][min(i0 + 14, h["n"] - 1)].date() + ret14 = h["px"][min(i0 + 14, h["n"] - 1)] / h["px"][i0] - 1.0 + ivtag = "real" if not np.isnan(h["dvol"][i0]) else "synt" + cells = {} + for k in kinds: + off = replay_window(h, reg[a]["w"], i0, k, gated=False) + on = replay_window(h, reg[a]["w"], i0, k, gated=True) + cells[k] = (off, on) + replay_rows[a].append(dict(i0=i0, d0=d0, d1=d1, ret=ret14, iv=ivtag, cells=cells)) + print(f" {str(d0)}→{str(d1)} {ret14*100:>+6.1f}% {ivtag:>5}" + + "".join(f" {cells[k][0]['pnl']*100:>+8.2f}% {cells[k][1]['pnl']*100:>+8.2f}%" + for k in kinds)) + tot_off = {k: sum(r["cells"][k][0]["pnl"] for r in replay_rows[a]) for k in kinds} + tot_on = {k: sum(r["cells"][k][1]["pnl"] for r in replay_rows[a]) for k in kinds} + ntr = {k: sum(r["cells"][k][1]["n_tr"] for r in replay_rows[a]) for k in kinds} + nsk = {k: sum(r["cells"][k][1]["n_skip"] for r in replay_rows[a]) for k in kinds} + print(f" {'SOMMA 10 finestre':<38}" + + "".join(f" {tot_off[k]*100:>+8.2f}% {tot_on[k]*100:>+8.2f}%" for k in kinds)) + print(f" {'gate: trade eseguiti / saltati':<38}" + + "".join(f" {'':>9} {f'{ntr[k]}/{nsk[k]}':>9}" for k in kinds)) + + # validazione IV sintetica sulle finestre DVOL-era + print("\n VALIDAZIONE IV SINTETICA (finestre DVOL-era: regressione vs DVOL reale ai punti di trade):") + for a in ASSETS: + h = H[a] + errs = [] + for r in replay_rows[a]: + if r["iv"] != "real": + continue + i0 = r["i0"] + base = h["dvol"][i0] + for tt in range(i0, min(i0 + 14, h["n"] - 1)): + if np.isnan(h["dvol"][tt]): + continue + synth = iv_jump(base, h["px"][tt] / h["px"][i0] - 1.0, reg[a]["w"]) + errs.append(synth - h["dvol"][tt]) + if errs: + e = np.asarray(errs) + print(f" {a}: bias {e.mean():+.1f} pt, MAE {np.abs(e).mean():.1f} pt su {len(e)} giorni-crash " + f"(la regressione {'SOTTOSTIMA' if e.mean() < 0 else 'sovrastima'} lo spike reale)") + + # banda d'ancora (fase d'ingresso) + banda f, sul totale delle 10 finestre gate-ON + print("\n BANDA D'ANCORA (fase d'ingresso 0..tenor-1) e BANDA f — somma 10 finestre, gate ON, per asset:") + for a in ASSETS: + h = H[a] + wins = [r["i0"] for r in replay_rows[a]] + for k in ("VRP01", "CONDOR", "DIAG"): + tenor = STRUCTS[k]["tenor"] + tots = [] + for p in range(tenor): + tots.append(sum(replay_window(h, reg[a]["w"], i0, k, gated=True, phase=p)["pnl"] + for i0 in wins)) + tots = np.asarray(tots) * 100 + fband = {f: sum(replay_window(h, reg[a]["w"], i0, k, gated=True, + f_put=f, f_call=f)["pnl"] for i0 in wins) * 100 + for f in F_SWEEP} + print(f" {a} {k:<7}: ancora med {np.median(tots):+.2f}% [{tots.min():+.2f}, {tots.max():+.2f}] " + f"(fase0 {tots[0]:+.2f}%) | f-band " + + " ".join(f"f{f}:{v:+.2f}%" for f, v in fband.items())) + print(" NB: fase 0 = venduto ESATTAMENTE al picco (worst-case timing). Le altre fasi entrano a crash") + print(" iniziato: il gate crash-skip (ivr>0.90) e la IV piu' alta (strike piu' larghi) attutiscono.") + + # ---------------------------------------------------------------- (3) matrice sintetica + print("\n" + "=" * 112) + print("(3) MATRICE SINTETICA — gap × IV-spike × timing; entry a DVOL mediana; f=1.0 (banda f in (5))") + print(" unita': ×credito-medio-storico-gated | %acct = % del conto a sizing 12% margine-deployed") + print(" (nudi: margine non definito → %acct in convenzione cash-secured sul collaterale, dichiarato)") + print("=" * 112) + # credito medio storico (gated, f=1.0) per normalizzare + print("\n CREDITO MEDIO storico (gated canonico, f=1.0, frazione di S0) e margine tipico:") + avg_credit: dict[str, dict[str, float]] = {a: {} for a in ASSETS} + avg_margin: dict[str, dict[str, float]] = {a: {} for a in ASSETS} + for a in ASSETS: + h = H[a] + for k in kinds: + tenor = STRUCTS[k]["tenor"] + creds, margs = [], [] + t = 60 + while t + tenor < h["n"]: + if np.isnan(h["dvol"][t]): + t += tenor + continue + sig0 = h["dvol"][t] + rv = h["rv30"][t] + hist = h["hist_iv"][30:t] + ivr = float((hist < sig0).mean()) if len(hist) >= 60 else np.nan + skip = ((not np.isnan(rv) and (sig0 / 100.0 - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + if not skip: + r = trade_pnl(k, h["px"][t], sig0 / 100.0, h["px"][t], sig0 / 100.0, tenor, + z=ALB["z"], dz=ALB["dz"]) + creds.append(r["credit"]); margs.append(r["margin"]) + t += tenor + avg_credit[a][k] = float(np.mean(creds)) + fin_m = [m for m in margs if np.isfinite(m)] + avg_margin[a][k] = float(np.mean(fin_m)) if fin_m else np.nan + print(f" {a}: " + " | ".join( + f"{k} cr {avg_credit[a][k]*1e4:.0f}bps" + + (f" mg {avg_margin[a][k]*100:.1f}%" if np.isfinite(avg_margin[a][k]) else " mg n/a") + for k in kinds)) + + matrix: dict[tuple, dict] = {} + for a in ASSETS: + J = load_series(a) + med_iv = float(np.median(J["dvol"].values)) / 100.0 + primary = a == "ETH" + if primary: + print(f"\n [{a}] PERDITA A SCADENZA (S resta al livello del gap; IV-spike entra solo nel mark T+1 del DIAG)") + print(f" {'gap':>5} {'IVx':>4}" + "".join(f" | {k:>16}" for k in kinds)) + for g in GAPS: + for m in IVMULT: + row = {} + for k in kinds: + tenor = STRUCTS[k]["tenor"] + r = trade_pnl(k, 1.0, med_iv, 1.0 + g, med_iv * m, tenor, + z=ALB["z"], dz=ALB["dz"]) + mg = avg_margin[a][k] + acct = (r["pnl"] / mg if np.isfinite(mg) else r["pnl"] / r["ks_frac"]) * SIZING + row[k] = dict(pnl=r["pnl"], xc=r["pnl"] / avg_credit[a][k], acct=acct) + matrix[(a, g, m)] = row + if primary: + print(f" {g*100:>+4.0f}% {m:>4.1f}" + "".join( + f" | {row[k]['xc']:>+6.1f}c {row[k]['acct']*100:>+6.2f}%" for k in kinds)) + if not primary: + print(f"\n [{a}] (compatto — solo gap -30%): " + " | ".join( + f"{k} {matrix[(a, -0.30, 2.0)][k]['xc']:+.1f}c/{matrix[(a, -0.30, 2.0)][k]['acct']*100:+.2f}%acct" + for k in kinds)) + print("\n Lettura: 'c' = multipli del credito medio; %acct = perdita sul CONTO a sizing 12%.") + print(" A scadenza il timing non cambia S1: la dimensione timing vive nel MtM qui sotto.") + + print("\n MARK-TO-MARKET AL GIORNO DEL GAP (stress di margine; ETH, IV mediana, f=1.0)") + print(f" {'gap':>5} {'IVx':>4} {'timing':>10}" + "".join(f" | {k:>14}" for k in kinds)) + J = load_series("ETH") + med_iv_e = float(np.median(J["dvol"].values)) / 100.0 + for g in GAPS: + for m in IVMULT: + for lbl, tg in (("overnight", 1), ("intra-week", None)): + cells = [] + for k in kinds: + tenor = STRUCTS[k]["tenor"] + t_gap = tg if tg is not None else max(tenor - 2, 1) + v = trade_mtm(k, med_iv_e, g, m, t_gap, tenor, z=ALB["z"], dz=ALB["dz"]) + cells.append(v / avg_credit["ETH"][k]) + if m == 2.0 or g == -0.30: # stampa compatta: x2 sempre, -30% tutte + print(f" {g*100:>+4.0f}% {m:>4.1f} {lbl:>10}" + + "".join(f" | {c:>+12.1f}c" for c in cells)) + print(" Lettura: MtM overnight ≪ MtM intra-week (piu' tempo residuo + vega). Per il defined-risk il") + print(" MtM NON e' la perdita realizzata (a scadenza vale la tabella sopra) ma e' il margine richiesto") + print(" per TENERE la posizione — e con BS-flat e' pure sottostimato (front-end IV esplode).") + + # ---------------------------------------------------------------- (4) valore dell'ala + print("\n" + "=" * 112) + print("(4) VALORE DELL'ALA — drag storico vs protezione di coda + NULL DEL DE-LEVERING") + print("=" * 112) + # coppie (base, feature): l'ala far-OTM di VRP01; l'ala far-OTM dello strangle; l'ala T+1; l'ala vicina + print("\n DRAG STORICO (gated canonico, f=1.0, book 50/50 BTC+ETH; rendimenti per-periodo):") + hist: dict[str, pd.Series] = {} + hist["NAKED28"] = pd.concat( + {a[0]: vrp_spread_weekly(a, defined_risk=False, f=1.0, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90) for a in ASSETS}, + axis=1, join="inner").mean(axis=1) + hist["VRP01"] = vrp_canon + hist["STRANGLE"] = book("condor", z=ALB["z"], dz=8.0, tenor_d=ALB["tenor_d"], gated=True) + hist["VERT"] = book("vert", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True) + hist["CONDOR"] = book("condor", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True) + hist["DIAG"] = book("diag", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True) + hist["CONDOR05"] = book("condor", z=ALB["z"], dz=0.5, tenor_d=ALB["tenor_d"], gated=True) + + def annual_bps(s: pd.Series, tenor: int) -> float: + return float(s.mean() * (365.25 / tenor) * 1e4) + + for k, tn in (("NAKED28", 7), ("VRP01", 7), ("STRANGLE", 5), ("VERT", 5), + ("CONDOR", 5), ("DIAG", 5), ("CONDOR05", 5)): + m = metrics(hist[k], tn) + print(f" {k:<9} Sh {m['sh']:>5.2f} | media aritm {annual_bps(hist[k], tn):>+7.0f} bps/anno | " + f"worst {m['worst']*100:>+6.2f}% | DD {m['dd']*100:>5.1f}%") + + features = [ + ("ala far-OTM VRP01 (Δ-0.10)", "NAKED28", "VRP01", 7), + ("ala far-OTM strangle (z+1σ)", "STRANGLE", "CONDOR", 5), + ("ala T+1 (diag vs condor)", "CONDOR", "DIAG", 5), + ("ala VICINA (dz 0.5 vs 1.0)", "CONDOR", "CONDOR05", 5), + ] + # kind/dz effettivi per il pricing di cella (CONDOR05 = condor con ala a +0.5σ) + featmap = {"NAKED28": ("NAKED28", ALB["dz"]), "VRP01": ("VRP01", ALB["dz"]), + "STRANGLE": ("STRANGLE", ALB["dz"]), "VERT": ("VERT", ALB["dz"]), + "CONDOR": ("CONDOR", ALB["dz"]), "DIAG": ("DIAG", ALB["dz"]), + "CONDOR05": ("CONDOR", 0.5)} + + def eth_cell(tag: str, g: float, m: float = 2.0) -> float: + kind, dz = featmap[tag] + tenor = STRUCTS[kind]["tenor"] + return trade_pnl(kind, 1.0, med_iv_e, 1.0 + g, med_iv_e * m, tenor, + z=ALB["z"], dz=dz)["pnl"] + print("\n DECOMPOSIZIONE calm-drag vs tail-benefit (per-trade, date comuni, f=1.0):") + feat_stats = {} + for name, b, f_, tn in features: + JJ = pd.concat({"b": hist[b], "f": hist[f_]}, axis=1, join="inner").dropna() + act = JJ[(JJ["b"] != 0) | (JJ["f"] != 0)] + diff = act["f"] - act["b"] + q05 = act["b"].quantile(0.05) + tail = diff[act["b"] <= q05] + calm = diff[act["b"] > q05] + tpy = len(act) / (len(JJ) * tn / 365.25) + drag_yr = annual_bps(hist[f_], tn) - annual_bps(hist[b], tn) + feat_stats[name] = dict(drag_yr=drag_yr, calm=float(calm.mean() * 1e4), + tail=float(tail.mean() * 1e4), base=b, feat=f_, tn=tn) + print(f" {name:<28}: drag netto {drag_yr:>+6.0f} bps/anno | calm {calm.mean()*1e4:>+6.1f} bps/trade " + f"| tail(5% peggiori) {tail.mean()*1e4:>+7.1f} bps/trade | ~{tpy:.0f} trade attivi/anno") + + print("\n PROTEZIONE COMPRATA NELLA MATRICE (ETH, a scadenza, bps di S0 risparmiati; celle -15/-20/-30, IVx2):") + print(f" {'feature':<28}" + "".join(f" {f'gap{g*100:+.0f}%':>10}" for g in (-0.15, -0.20, -0.30)) + + f" {'drag/anno':>10} {'protez/drag @-30':>17}") + for name, b, f_, tn in features: + prot = {} + for g in (-0.15, -0.20, -0.30): + lb = eth_cell(b, g) + lf = eth_cell(f_, g) + prot[g] = (lf - lb) * 1e4 # bps di S0 risparmiati (>0 = l'ala protegge) + drg = feat_stats[name]["drag_yr"] + ratio = prot[-0.30] / abs(drg) if drg < 0 else float("inf") + feat_stats[name]["prot"] = prot + feat_stats[name]["ratio"] = ratio + rtag = f"{ratio:>8.1f}x" if np.isfinite(ratio) else " GRATIS" + print(f" {name:<28}" + "".join(f" {prot[g]:>+9.0f}" for g in (-0.15, -0.20, -0.30)) + + f" {drg:>+9.0f} {rtag:>17}") + + print("\n NULL DEL DE-LEVERING (regola standing #3), DUE LETTURE ONESTE:") + print(" (i) RITORNO a pari perdita-di-cella: de-lever della base a k = L_ala/L_base costa (1-k)·ritorno;") + print(" l'ala vale se il suo drag e' minore. (ii) LETTERA della regola (Sharpe): il de-levering") + print(" PRESERVA lo Sharpe della base — se Sh(base) >= Sh(ala), il de-lever vince nominalmente.") + print(f" {'feature':<28} {'k(-30%)':>8} {'k(-20%)':>8} {'delever':>9} {'ala':>7} " + f"{'Sh base':>8} {'Sh ala':>7} {'verdetto ritorno':>17} {'verdetto Sharpe':>16}") + for name, b, f_, tn in features: + k30 = eth_cell(f_, -0.30) / eth_cell(b, -0.30) + k20 = eth_cell(f_, -0.20) / eth_cell(b, -0.20) + k = max(k30, k20) # de-lever deve coprire la cella PEGGIO protetta + base_ret = annual_bps(hist[b], tn) + cost_del = (1.0 - min(k30, 1.0)) * base_ret + cost_f = -feat_stats[name]["drag_yr"] # >0 = l'ala costa + sh_b = metrics(hist[b], tn)["sh"] + sh_f = metrics(hist[f_], tn)["sh"] + if cost_f <= 0: + v_ret = "ALA GRATIS" + elif cost_f < cost_del: + v_ret = "ala vince" + else: + v_ret = "delever vince" + v_sh = "ala vince" if sh_f > sh_b else "delever vince" + feat_stats[name]["survives_ret"] = (cost_f <= 0) or (cost_f < cost_del) + feat_stats[name]["survives_sh"] = sh_f > sh_b + print(f" {name:<28} {k30:>8.2f} {k20:>8.2f} {cost_del:>+8.0f}b {cost_f:>+6.0f}b " + f"{sh_b:>8.2f} {sh_f:>7.2f} {v_ret:>17} {v_sh:>16}") + print(" ⚠️ CAVEAT sulla lettura Sharpe: lo Sharpe in-sample della struttura SENZA ala e' tail-uncapped") + print(" — e' alto proprio perche' la cella -30% overnight non e' mai occorsa piena nel campione 2021-26") + print(" (il punto cieco CC01 'Sharpe implausibile'). La lettura (i) a pari perdita-di-cella e' quella") + print(" che prezza le celle FUORI campione; la (ii) e' la lettera della regola. Riportate entrambe.") + print(" NB a favore dell'ala (oltre il numero): il defined-risk mette un LIMITE RIGIDO oltre la cella") + print(" (-50%, -70%...) che il de-levering non mette mai; e a parita' di margine Deribit l'ala LIBERA") + print(" capitale. NB contro: il drag e' misurato IN-SAMPLE su un'era (2021-26) che le code le ha viste") + print(" (LUNA/FTX) — in un'era senza code il drag sale e il tail-benefit non si incassa.") + + # banda f sul verdetto ala (chiave: drag e protezione a f 0.6/1.3) + print("\n BANDA f sul valore dell'ala T+1 e dell'ala far-OTM (drag bps/anno | protezione bps @-30%×2):") + for f in F_SWEEP: + nk = pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=False, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90) for a in ASSETS}, + axis=1, join="inner").mean(axis=1) + sp = pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90) for a in ASSETS}, + axis=1, join="inner").mean(axis=1) + co = book("condor", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True, f_put=f, f_call=f) + di = book("diag", z=ALB["z"], dz=ALB["dz"], tenor_d=ALB["tenor_d"], gated=True, f_put=f, f_call=f) + drag_v = annual_bps(sp, 7) - annual_bps(nk, 7) + drag_d = annual_bps(di, 5) - annual_bps(co, 5) + pv = (trade_pnl("VRP01", 1.0, med_iv_e, 0.70, med_iv_e * 2, 7, f_put=f)["pnl"] + - trade_pnl("NAKED28", 1.0, med_iv_e, 0.70, med_iv_e * 2, 7, f_put=f)["pnl"]) * 1e4 + pdg = (trade_pnl("DIAG", 1.0, med_iv_e, 0.70, med_iv_e * 2, 5, f_put=f, f_call=f)["pnl"] + - trade_pnl("CONDOR", 1.0, med_iv_e, 0.70, med_iv_e * 2, 5, f_put=f, f_call=f)["pnl"]) * 1e4 + print(f" f={f}: ala VRP01 drag {drag_v:+.0f} prot {pv:+.0f} | ala T+1 drag {drag_d:+.0f} prot {pdg:+.0f}") + + # ---------------------------------------------------------------- (5) worst-case EUR a 2k/5k + print("\n" + "=" * 112) + print("(5) WORST-CASE ONESTO del VRP01 attuale a 2k / 5k (EUR; convenzione diario $≈€)") + print("=" * 112) + # margine spread ETH a IV mediana 1y (granularita' reale: spread interi da 1 ETH) + dfe = al.get("ETH", "1d") + S_eth = float(dfe["close"].iloc[-1]) + dv_e = pd.read_parquet(ROOT / "data" / "raw" / "dvol_eth.parquet") + iv1y = float(dv_e["close"].iloc[-365:].median()) / 100.0 + T7 = 7.0 / 365.25 + Ks = strike_from_delta(S_eth, T7, iv1y, -0.28) + Kl = strike_from_delta(S_eth, T7, iv1y, -0.10) + fband_credit = {} + for f in F_SWEEP: + cr = (bs_put(S_eth, Ks, T7, iv1y) - bs_put(S_eth, Kl, T7, iv1y)) * f + fee2 = (min(0.0003 * S_eth, 0.125 * bs_put(S_eth, Ks, T7, iv1y) * f) + + min(0.0003 * S_eth, 0.125 * bs_put(S_eth, Kl, T7, iv1y) * f)) + fband_credit[f] = dict(credit=cr, margin=(Ks - Kl) - cr, fee=fee2) + print(f"\n Spread ETH 1x (spot ${S_eth:,.0f}, DVOL mediana 1y {iv1y*100:.0f}%): strike {Ks:,.0f}/{Kl:,.0f}") + for f in F_SWEEP: + d = fband_credit[f] + print(f" f={f}: credito ${d['credit']:.2f} | margine/max-loss ${d['margin']:.2f} | fee 2 gambe ${d['fee']:.2f}") + worst_repl = {} + for a in ASSETS: + worst_repl[a] = min(r["cells"]["VRP01"][1]["pnl"] for r in replay_rows[a]) # gate ON + wr_book = float(np.mean([worst_repl[a] for a in ASSETS])) + print(f"\n {'capitale':>9} {'sleeve12%':>10} {'n spread ETH':>13} " + f"{'WC fisico (margine-deployed)':>36} {'WC cella -30% (book conv.)':>27} {'peggior 14g replay (gate)':>26}") + for C in (2000.0, 5000.0): + sleeve = SIZING * C + rows_f = [] + for f in F_SWEEP: + d = fband_credit[f] + n_sp = int(sleeve // d["margin"]) + wc_phys = n_sp * (d["margin"] + d["fee"]) + rows_f.append((f, n_sp, wc_phys)) + # convenzione book (cash-secured su Ks, come lo sleeve compone nel portafoglio) + r30 = trade_pnl("VRP01", 1.0, iv1y, 0.70, iv1y * 2, 7) + wc_book_eur = r30["pnl"] / r30["ks_frac"] * SIZING * C + wr_eur = wr_book / r30["ks_frac"] * SIZING * C # replay pnl frac S0 → su collaterale Ks + base = next(x for x in rows_f if x[0] == 1.0) + print(f" {C:>9.0f} {sleeve:>10.0f} {base[1]:>13d} " + f"{'-€%.0f' % base[2]:>13} [f-band -€{min(x[2] for x in rows_f):.0f}..-€{max(x[2] for x in rows_f):.0f}]" + f" {'-€%.1f' % abs(wc_book_eur):>20} {'-€%.1f' % abs(wr_eur):>20}") + print("\n Lettura (i tre numeri sono TRE domande diverse):") + print(" - WC FISICO margine-deployed: se il 12% del conto e' TUTTO margine di spread e il gap -30% li") + print(" manda full-ITM, si perde il margine intero + fee = ~il 12% del conto. E' il bound rigido del") + print(" defined-risk: a 2k ≈ -€240, a 5k ≈ -€600. Nessun modello puo' peggiorarlo (skew incluso).") + print(" - WC alla cella -30% in CONVENZIONE BOOK (cash-secured su Ks, come lo sleeve compone nel") + print(" portafoglio): la perdita che il book REGISTRAerebbe quella settimana a sizing 12%.") + print(" - Peggior 14g del replay storico (gate ON): il worst-case EMPIRICO osservato/simulato, che il") + print(" gate canonico attenua saltando i re-entry a IV-rank>0.90.") + + print("\n" + "=" * 112) + print(" CONCLUSIONI (fisica, non selezione — nessun nuovo sleeve, regola standing invariata)") + print("=" * 112) + for name, st in feat_stats.items(): + r = "ritorno:OK" if st.get("survives_ret") else "ritorno:NO" + s = "sharpe:OK" if st.get("survives_sh") else "sharpe:NO" + print(f" - {name:<28}: drag {st['drag_yr']:+.0f} bps/anno, protezione @-30% {st['prot'][-0.30]:+.0f} bps " + f"→ null de-levering [{r}, {s}]") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_vrpimp_structgrid.py b/scripts/research/r0703_vrpimp_structgrid.py new file mode 100644 index 0000000..7c51539 --- /dev/null +++ b/scripts/research/r0703_vrpimp_structgrid.py @@ -0,0 +1,426 @@ +"""R0703 VRPIMP-STRUCTGRID — griglia ONESTA di struttura short-vol vs VRP01 canonico. + +FILONE 1 (2026-07-03). L'audit ALB-A (r0702_alb_structure) ha testato UNA cella a priori +(z=2.0σ, ali +1σ, tenor 5g). Domanda: nella griglia onesta + distanza z ∈ {1.0, 1.5, 2.0, 2.5}σ × tenor ∈ {3, 5, 7, 10}g × ali dz ∈ {+0.5, +1.0, +2.0}σ + × lato {put-only, call-only, entrambi} × struttura {vertical, condor, diagonal T+1} +esiste una cella che batte VRP01 canonico (put credit spread settimanale Δ-0.28/-0.10, gate +IV-rank) in modo che SOPRAVVIVE a (a) selezione IN-SAMPLE-ONLY, (b) deflated-Sharpe sul numero +TOTALE di celle, (c) banda f, (d) plateau dei vicini, (e) null del de-levering? + +MAPPATURA lato×struttura (9 combo nominali -> 6 DISTINTE, per non contare doppio): + vertical×put = VERT-PUT vertical×call = VERT-CALL vertical×both ≡ CONDOR + condor×put ≡ VERT-PUT condor×call ≡ VERT-CALL condor×both = CONDOR + diagonal×{put,call,both} = DIAG-PUT / DIAG-CALL / DIAG-2 (Albimarini) +Totale celle = 4z × 4tenor × 3dz × 6 strutture = 288 (tutte contate nel deflated-Sharpe). + +REGOLE RISPETTATE: + - Gate IV-rank CANONICO sempre attivo, NON riottimizzato (vrp>0 AND 0.30<=ivr<=0.90, + identico a VRP01 combo e a r0702_alb_structure gated=True). + - Selezione cella SOLO su Sharpe PRE-holdout (2021-03 .. 2024-12); hold-out 2025-26 mai + guardato per scegliere. DSR (Bailey & LdP, altlib.deflated_sharpe) su tutte le 288 celle. + - Pattern "0-perdite = Sharpe implausibile" (CC01, ALB-A): le celle con < MIN_LOSS_IS + perdite attive in-sample sono ESCLUSE DALLA SELEZIONE E DICHIARATE (coda mai campionata + -> Sharpe non informativo). Il criterio usa SOLO dati in-sample (niente peek). + - Banda f {0.6, 0.8, 1.0, 1.3} + scenario skew (f_put=1.3 / f_call=0.7) su ogni claim + (pricing BS FLAT su DVOL-30g: il deep-OTM e' banda, non stima puntuale). + - Banda d'ancora (regola 2026-07-02): la cella scelta si riporta su TUTTE le fasi di + partenza 0..tenor-1 (il costrutto e' ancorato alla cadenza tenor_d). + - NULL DEL DE-LEVERING (lezione TP01×DVOL): ogni vantaggio di DD della cella si confronta + con VRP01 semplicemente scalato allo stesso DD (Sharpe invariante per scala). + - Confronto apples-to-apples: oltre a VRP01 canonico (motore options_vrp_v2, capitale=K_short, + fee=12.5% del premio) si riporta il BRIDGE (stessa struttura di VRP01 nel motore nuovo: + vert-put z=0.583 dz=0.699 7g, capitale=S0, fee per-gamba) — battere solo VRP01 canonico + ma non il bridge = artefatto di convenzione fee/capitale, non struttura. + +MACCHINERIA RIUSATA (non riscritta): motore di r0702_alb_structure (bs_put/bs_call/_fee_frac/ +metrics/to_daily_lumped, VALIDATO vs run_structure cella-per-cella in testa al run), +options_vrp_v2.vrp_spread_weekly (VRP01 canonico esatto), altlib.deflated_sharpe/marginal_vs_tp01. + +Regola standing: NIENTE short-vol da modello in deploy — l'esito massimo e' conoscenza/LEAD. + + uv run python scripts/research/r0703_vrpimp_structgrid.py +""" +from __future__ import annotations +import json +import math +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) +import numpy as np, pandas as pd # noqa: E402 +from scipy.stats import norm # noqa: E402 + +from scripts.research.options_vrp_lab import bs_put, load_series, per_year # noqa: E402 +from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30 # noqa: E402 +from scripts.research.r0702_alb_structure import ( # noqa: E402 + bs_call, _fee_frac, run_structure, metrics, to_daily_lumped, DAY, HOLDOUT) +import altlib as al # noqa: E402 + +SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad") + +F_SWEEP = (0.6, 0.8, 1.0, 1.3) +F_SKEW = dict(f_put=1.3, f_call=0.7) # skew crypto-shaped: put ricche, call povere +Z_GRID = (1.0, 1.5, 2.0, 2.5) +TENOR_GRID = (3, 5, 7, 10) +DZ_GRID = (0.5, 1.0, 2.0) +STRUCTS = (("same", "put", "VERT-PUT"), ("same", "call", "VERT-CALL"), ("same", "both", "CONDOR"), + ("diag", "put", "DIAG-PUT"), ("diag", "call", "DIAG-CALL"), ("diag", "both", "DIAG-2")) +MIN_ACT_IS = 30 # trade attivi minimi in-sample per credere allo Sharpe IS +MIN_LOSS_IS = 3 # perdite attive minime IS: <3 = coda mai campionata (CC01/ALB-A) -> ESCLUSA +CUTS = ("2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01") # persistenza multi-cut + +# bridge = la struttura ESATTA di VRP01 dentro il motore nuovo (delta -0.28/-0.10, 7g) +Z_BRIDGE = float(-norm.ppf(0.28)) # ~0.583 +DZ_BRIDGE = float(-norm.ppf(0.10)) - Z_BRIDGE # ~0.699 + + +# --------------------------------------------------------------------------- dati + gate cache +_S: dict = {} + + +def S(asset): + """(px, dv, idx, skip) con gate canonico VRP01 precomputato (identico a r0702/options_vrp_v2).""" + if asset not in _S: + J = load_series(asset) + px = J["px"].values.astype(float) + dv = (J["dvol"].values / 100.0).astype(float) + n = len(px) + skip = np.zeros(n, dtype=bool) + for i in range(n): + rv = _rv30(px, i) + ivr = _ivrank(dv, i) + skip[i] = ((not np.isnan(rv) and (dv[i] - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + _S[asset] = (px, dv, J.index, skip) + return _S[asset] + + +# --------------------------------------------------------------------------- motore generalizzato +def run_cell(asset, expiry, side, z, dz, tenor_d, f_put=1.0, f_call=1.0, offset=0): + """Vendita sistematica non-overlapping della struttura, cadenza tenor_d, GATE CANONICO SEMPRE. + + expiry: 'same' (long stessa scadenza) | 'diag' (long T+1g, mark BS a exit — vega) + side: 'put' | 'call' | 'both' + Identica contabilita' di r0702_alb_structure.run_structure (validata sotto): capitale = S0, + fee Deribit per gamba 0.03% notional cap 12.5% premio, delivery 0.015% su short ITM, exit fee + sulle long residue del diag. offset = fase d'ancora (banda d'ancora).""" + px, dv, idx, skip = S(asset) + n = len(px) + T = tenor_d / 365.25 + T_long = T + DAY if expiry == "diag" else T + rets = {} + i = 60 + offset + while i + tenor_d < n: + j = i + tenor_d + if skip[i]: + rets[idx[j]] = 0.0 + i = j + continue + S0 = px[i]; sig = dv[i]; m = sig * math.sqrt(T) + S1 = px[j]; sig1 = dv[j] + credit = short_pay = long_val = exit_fee = deliv = 0.0 + legs = [] + if side in ("put", "both"): + Ks = S0 * math.exp(-z * m); Kl = S0 * math.exp(-(z + dz) * m) + ps = bs_put(S0, Ks, T, sig) / S0 * f_put + pl = bs_put(S0, Kl, T_long, sig) / S0 * f_put + credit += ps - pl; legs += [ps, pl] + short_pay += max(0.0, Ks - S1) / S0 + deliv += _fee_frac(max(0.0, Ks - S1) / S0, rate=0.00015) + if expiry == "diag": + lv = bs_put(S1, Kl, DAY, sig1) / S0 * f_put + long_val += lv + exit_fee += _fee_frac(lv, notional_ratio=S1 / S0) + else: + long_val += max(0.0, Kl - S1) / S0 + if side in ("call", "both"): + Ks = S0 * math.exp(+z * m); Kl = S0 * math.exp(+(z + dz) * m) + cs = bs_call(S0, Ks, T, sig) / S0 * f_call + cl = bs_call(S0, Kl, T_long, sig) / S0 * f_call + credit += cs - cl; legs += [cs, cl] + short_pay += max(0.0, S1 - Ks) / S0 + deliv += _fee_frac(max(0.0, S1 - Ks) / S0, rate=0.00015) + if expiry == "diag": + lv = bs_call(S1, Kl, DAY, sig1) / S0 * f_call + long_val += lv + exit_fee += _fee_frac(lv, notional_ratio=S1 / S0) + else: + long_val += max(0.0, S1 - Kl) / S0 + entry_fee = sum(_fee_frac(v) for v in legs) + rets[idx[j]] = credit - short_pay + long_val - entry_fee - exit_fee - deliv + i = j + return pd.Series(rets) + + +def book_cell(expiry, side, z, dz, tenor_d, **kw): + rB = run_cell("BTC", expiry, side, z, dz, tenor_d, **kw) + rE = run_cell("ETH", expiry, side, z, dz, tenor_d, **kw) + return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + + +def vrp01_book(f): + return pd.concat({a[0]: vrp_spread_weekly(a, defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90) + for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1) + + +# --------------------------------------------------------------------------- utilita' report +def _sh_window(r, tenor_d, start=None, end=None): + w = r + if start is not None: + w = w[w.index >= pd.Timestamp(start, tz="UTC")] + if end is not None: + w = w[w.index < pd.Timestamp(end, tz="UTC")] + ppy = 365.25 / tenor_d + return float(w.mean() / w.std() * np.sqrt(ppy)) if len(w) > 5 and w.std() > 0 else float("nan") + + +def dd_of(r, lam=1.0): + eq = np.cumprod(1 + lam * r.values) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) + + +def scale_to_dd(r, dd_target): + """λ tale che il maxDD di λ·r == dd_target (null del de-levering).""" + lo, hi = 0.01, 3.0 + for _ in range(60): + mid = 0.5 * (lo + hi) + if dd_of(r, mid) > dd_target: + hi = mid + else: + lo = mid + return 0.5 * (lo + hi) + + +def validate_engine(): + """run_cell deve riprodurre ESATTAMENTE run_structure (motore ALB-A validato) su 3 kind.""" + print(" [validazione motore] run_cell vs r0702_alb_structure.run_structure (z=2, dz=1, 5g, gated):") + ok = True + for kind, (expiry, side) in (("diag", ("diag", "both")), ("condor", ("same", "both")), + ("vert", ("same", "put"))): + for a in ("BTC", "ETH"): + ref = run_structure(a, kind, z=2.0, dz=1.0, tenor_d=5, gated=True) + new = run_cell(a, expiry, side, 2.0, 1.0, 5) + d = float((ref - new).abs().max()) if len(ref) == len(new) else float("inf") + ok &= d < 1e-12 + print(f" {kind:<7}{a}: n={len(new):>4} max|Δ|={d:.2e}") + if not ok: + raise SystemExit("ENGINE MISMATCH: run_cell non riproduce run_structure — stop.") + print(" -> motore identico (riuso validato, non riscrittura).") + + +def main(): + print("=" * 112) + print(" R0703 VRPIMP-STRUCTGRID — griglia onesta 288 celle di struttura short-vol vs VRP01 canonico") + print(" Gate IV-rank canonico SEMPRE attivo (mai riottimizzato). Selezione IN-SAMPLE-ONLY (pre-2025).") + print(" ⚠️ SKEW: pricing BS FLAT su DVOL-30g -> banda f {0.6,0.8,1.0,1.3} + scenario fp=1.3/fc=0.7.") + print(" Regola standing: NIENTE short-vol da modello in deploy — esito massimo = conoscenza/LEAD.") + print("=" * 112) + + validate_engine() + + # ------------------------------------------------------------- (1) VRP01 canonico + bridge + print("\n (1) BASELINE — VRP01 canonico (options_vrp_v2 COMBO) e bridge motore-nuovo, banda f") + hdr = (f" {'riga':<38} {'ShF':>6} {'ShIS':>6} {'ShH':>6} {'CAGR':>7} {'maxDD':>6} " + f"{'worst':>7} {'win%':>5} {'att%':>5}") + print(hdr) + vrp = {} + for f in F_SWEEP: + vrp[f] = vrp01_book(f) + mm = metrics(vrp[f], 7); mi = metrics(vrp[f][vrp[f].index < HOLDOUT], 7) + print(f" VRP01 canonico f={f:<4} {'':<16} {mm['sh']:>6.2f} {mi['sh']:>6.2f} {mm['sh_h']:>6.2f} " + f"{mm['cagr']*100:>+6.1f}% {mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% " + f"{mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%") + bridge = {} + for f in F_SWEEP: + bridge[f] = book_cell("same", "put", Z_BRIDGE, DZ_BRIDGE, 7, f_put=f, f_call=f) + mm = metrics(bridge[f], 7); mi = metrics(bridge[f][bridge[f].index < HOLDOUT], 7) + print(f" bridge vert-put Δ-0.28/-0.10 7g f={f:<4} {mm['sh']:>6.2f} {mi['sh']:>6.2f} {mm['sh_h']:>6.2f} " + f"{mm['cagr']*100:>+6.1f}% {mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% " + f"{mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%") + print(" (bridge = stessa struttura di VRP01 nel motore-griglia: e' il confronto apples-to-apples;") + print(" battere VRP01 ma non il bridge = artefatto di convenzione fee/capitale, non struttura)") + + # ------------------------------------------------------------- (2) griglia 288 celle, f=1.0 + print(f"\n (2) GRIGLIA {len(STRUCTS)}x{len(Z_GRID)}x{len(DZ_GRID)}x{len(TENOR_GRID)} = " + f"{len(STRUCTS)*len(Z_GRID)*len(DZ_GRID)*len(TENOR_GRID)} celle (f=1.0, gate canonico)") + cells = {} + for expiry, side, lab in STRUCTS: + for z in Z_GRID: + for dz in DZ_GRID: + for tn in TENOR_GRID: + r = book_cell(expiry, side, z, dz, tn) + r_is = r[r.index < HOLDOUT] + act_is = r_is[r_is != 0] + rec = dict(expiry=expiry, side=side, lab=lab, z=z, dz=dz, tn=tn, r=r, + mi=metrics(r_is, tn), mm=metrics(r, tn), + nact_is=int(len(act_is)), nloss_is=int((act_is < 0).sum())) + rec["eligible"] = (rec["nact_is"] >= MIN_ACT_IS and rec["nloss_is"] >= MIN_LOSS_IS) + cells[(lab, z, dz, tn)] = rec + all_full_sh = [c["mm"]["sh"] for c in cells.values()] + all_is_sh = [c["mi"]["sh"] for c in cells.values()] + n_cells = len(cells) + inel = [c for c in cells.values() if not c["eligible"]] + print(f" celle totali {n_cells} | ESCLUSE dalla selezione (implausibili: <{MIN_LOSS_IS} perdite IS " + f"o <{MIN_ACT_IS} trade IS): {len(inel)} — dichiarate sotto, contate comunque nel DSR") + + # il pattern '0-perdite': cosa avremmo scelto SENZA il filtro + naive = max(cells.values(), key=lambda c: c["mi"]["sh"]) + print(f"\n [pattern CC01/ALB-A] best IS SENZA filtro implausibilita': {naive['lab']} z={naive['z']} " + f"dz={naive['dz']} {naive['tn']}g -> ShIS {naive['mi']['sh']:.2f} (perdite IS: {naive['nloss_is']}, " + f"trade IS: {naive['nact_is']}) | suo hold-out ShH {naive['mm']['sh_h']:+.2f}") + top_inel = sorted(inel, key=lambda c: c["mi"]["sh"], reverse=True)[:8] + print(" top celle ESCLUSE (ShIS alto MA coda mai campionata in-sample -> Sharpe non informativo):") + for c in top_inel: + print(f" {c['lab']:<10} z={c['z']} dz={c['dz']} {c['tn']:>2}g ShIS {c['mi']['sh']:>6.2f} " + f"perdite-IS {c['nloss_is']:>2} su {c['nact_is']:>3} trade -> ShH {c['mm']['sh_h']:>+6.2f}") + + # ------------------------------------------------------------- (3) selezione in-sample-only + elig = [c for c in cells.values() if c["eligible"]] + ch = max(elig, key=lambda c: c["mi"]["sh"]) + print(f"\n (3) SELEZIONE IN-SAMPLE-ONLY (fra le {len(elig)} eleggibili) + top-10") + print(f" {'cella':<34} {'ShIS':>6} {'ShF':>6} {'ShH':>6} {'DD':>6} {'worst':>7} {'lossIS':>7}") + for c in sorted(elig, key=lambda c: c["mi"]["sh"], reverse=True)[:10]: + mark = " <== SCELTA" if c is ch else "" + print(f" {c['lab']:<10} z={c['z']} dz={c['dz']} tenor={c['tn']:>2}g{'':<6} {c['mi']['sh']:>6.2f} " + f"{c['mm']['sh']:>6.2f} {c['mm']['sh_h']:>6.2f} {c['mm']['dd']*100:>5.1f}% " + f"{c['mm']['worst']*100:>+6.2f}% {c['nloss_is']:>4}/{c['nact_is']:<4}{mark}") + + tn = ch["tn"] + dsr_full, sr0_full = al.deflated_sharpe(ch["mm"]["sh"], all_full_sh, ch["r"], dpy=365.25 / tn) + r_is = ch["r"][ch["r"].index < HOLDOUT] + dsr_is, sr0_is = al.deflated_sharpe(ch["mi"]["sh"], all_is_sh, r_is, dpy=365.25 / tn) + print(f"\n DSR cella scelta (N={n_cells} trial): FULL {dsr_full:.3f} (null-max ~{sr0_full:.2f}) | " + f"IS {dsr_is:.3f} (null-max ~{sr0_is:.2f}) | PASS richiede >= 0.95") + + # nota anti-hindsight: la cella col miglior HOLD-OUT fra le eleggibili NON e' selezionabile + # (selection-on-holdout, gate 2026-06-29); la riporto solo per chiudere la domanda ovvia. + hb = max(elig, key=lambda c: c["mm"]["sh_h"]) + dsr_hb, _ = al.deflated_sharpe(hb["mm"]["sh"], all_full_sh, hb["r"], dpy=365.25 / hb["tn"]) + rank_is = 1 + sum(1 for c in elig if c["mi"]["sh"] > hb["mi"]["sh"]) + print(f" [anti-hindsight] best HOLD-OUT fra le eleggibili: {hb['lab']} z={hb['z']} dz={hb['dz']} " + f"{hb['tn']}g (ShIS {hb['mi']['sh']:.2f}, rank-IS #{rank_is}, ShH {hb['mm']['sh_h']:.2f}, " + f"DSR {dsr_hb:.3f}) — sceglierla = selection-on-holdout, VIETATO; e comunque DSR<0.95.") + + # ------------------------------------------------------------- (4) cella scelta: banda f vs VRP01 + print(f"\n (4) CELLA SCELTA {ch['lab']} z={ch['z']} dz={ch['dz']} {tn}g — banda f vs VRP01 e bridge") + print(f" {'f':<22} {'cella ShF/ShH':>16} {'VRP01 ShF/ShH':>16} {'bridge ShF/ShH':>16} " + f"{'cella DD/worst':>17} {'VRP01 DD/worst':>17}") + ch_f = {} + beats_vrp = beats_bridge = True + for f in F_SWEEP: + rr = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, f_put=f, f_call=f) + ch_f[f] = rr + m = metrics(rr, tn); v = metrics(vrp[f], 7); b = metrics(bridge[f], 7) + beats_vrp &= (m["sh"] > v["sh"]) and (m["sh_h"] > v["sh_h"]) + beats_bridge &= (m["sh"] > b["sh"]) and (m["sh_h"] > b["sh_h"]) + print(f" f={f:<20} {m['sh']:>7.2f}/{m['sh_h']:>6.2f} {v['sh']:>8.2f}/{v['sh_h']:>6.2f} " + f"{b['sh']:>8.2f}/{b['sh_h']:>6.2f} {m['dd']*100:>7.1f}%/{m['worst']*100:>+6.2f}% " + f"{v['dd']*100:>7.1f}%/{v['worst']*100:>+6.2f}%") + rr = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, **F_SKEW) + m = metrics(rr, tn) + print(f" SKEW fp=1.3/fc=0.7 {m['sh']:>7.2f}/{m['sh_h']:>6.2f} (VRP01/bridge non hanno lato call)") + py_c = per_year(ch_f[1.0]); py_v = per_year(vrp[1.0]) + print(" per-anno cella f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py_c.items()))) + print(" per-anno VRP01 f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py_v.items()))) + + # persistenza multi-cut del presunto vantaggio (ΔSh cella - bridge, finestre [cut, end)) + print(" multi-cut ΔSh (cella - bridge | cella - VRP01) su [cut, fine):") + for cut in CUTS: + d_b = _sh_window(ch_f[1.0], tn, start=cut) - _sh_window(bridge[1.0], 7, start=cut) + d_v = _sh_window(ch_f[1.0], tn, start=cut) - _sh_window(vrp[1.0], 7, start=cut) + print(f" {cut}: {d_b:+.2f} | {d_v:+.2f}") + + # ------------------------------------------------------------- (5) banda d'ancora + print(f"\n (5) BANDA D'ANCORA — cella scelta su tutte le {tn} fasi di partenza (offset 0..{tn-1})") + anch = [] + for o in range(tn): + ro = book_cell(ch["expiry"], ch["side"], ch["z"], ch["dz"], tn, offset=o) + mo = metrics(ro, tn) + anch.append((o, mo["sh"], mo["sh_h"])) + print(f" offset {o}: ShF {mo['sh']:>5.2f} ShH {mo['sh_h']:>+5.2f}") + shf = [a[1] for a in anch]; shh = [a[2] for a in anch] + print(f" banda ShF [{min(shf):.2f}, {max(shf):.2f}] mediana {np.median(shf):.2f} | " + f"ShH [{min(shh):+.2f}, {max(shh):+.2f}] mediana {np.median(shh):+.2f}") + + # ------------------------------------------------------------- (6) plateau dei vicini + print(f"\n (6) PLATEAU — slice {ch['lab']} dz={ch['dz']}: matrice z × tenor (ShIS / ShH)") + print(" " + "".join(f"{t:>14}g" for t in TENOR_GRID)) + for z in Z_GRID: + rowtxt = f" z={z:<4}" + for t in TENOR_GRID: + c = cells[(ch["lab"], z, ch["dz"], t)] + star = "*" if (z == ch["z"] and t == tn) else " " + rowtxt += f" {c['mi']['sh']:>5.2f}/{c['mm']['sh_h']:>+5.2f}{star}" + print(rowtxt) + print(" vicini dz (a z/tenor scelti): " + " ".join( + f"dz={d}: ShIS {cells[(ch['lab'], ch['z'], d, tn)]['mi']['sh']:.2f}/" + f"ShH {cells[(ch['lab'], ch['z'], d, tn)]['mm']['sh_h']:+.2f}" for d in DZ_GRID)) + neigh = [] + zi, ti, di = Z_GRID.index(ch["z"]), TENOR_GRID.index(tn), DZ_GRID.index(ch["dz"]) + for (gi, grid, mk) in ((zi, Z_GRID, "z"), (ti, TENOR_GRID, "tn"), (di, DZ_GRID, "dz")): + for step in (-1, +1): + k = gi + step + if 0 <= k < len(grid): + key = dict(z=ch["z"], tn=tn, dz=ch["dz"]); key[mk] = grid[k] + neigh.append(cells[(ch["lab"], key["z"], key["dz"], key["tn"])]) + n_pos = sum(1 for c in neigh if c["mi"]["sh"] > 0 and c["mm"]["sh_h"] > 0) + print(f" vicini adiacenti (±1 passo per asse): {len(neigh)} | con ShIS>0 E ShH>0: {n_pos}") + + # ------------------------------------------------------------- (7) null del de-levering + print("\n (7) NULL DEL DE-LEVERING — il vantaggio DD della cella e' replicabile scalando VRP01?") + m_c = metrics(ch_f[1.0], tn); m_v = metrics(vrp[1.0], 7) + lam = scale_to_dd(vrp[1.0], m_c["dd"]) + scaled = lam * vrp[1.0] + m_s = metrics(scaled, 7) + print(f" cella: Sh {m_c['sh']:.2f} / ShH {m_c['sh_h']:.2f} DD {m_c['dd']*100:.1f}% " + f"worst {m_c['worst']*100:+.2f}% CAGR {m_c['cagr']*100:+.1f}%") + print(f" VRP01 pieno: Sh {m_v['sh']:.2f} / ShH {m_v['sh_h']:.2f} DD {m_v['dd']*100:.1f}% " + f"worst {m_v['worst']*100:+.2f}% CAGR {m_v['cagr']*100:+.1f}%") + print(f" VRP01 × λ={lam:.2f}: Sh {m_s['sh']:.2f} / ShH {m_s['sh_h']:.2f} DD {m_s['dd']*100:.1f}% " + f"worst {m_s['worst']*100:+.2f}% CAGR {m_s['cagr']*100:+.1f}%") + delever_kills = (m_c["dd"] < m_v["dd"]) and (m_s["sh"] >= m_c["sh"]) and (m_s["sh_h"] >= m_c["sh_h"]) + print(" -> lo scaling lascia lo Sharpe invariato: ogni vantaggio di DD e' replicabile con λ," + f" la cella vale SOLO se Sh/ShH superiori. delever_null_kills={delever_kills}") + + # ------------------------------------------------------------- (8) marginale/corr + print("\n (8) CORRELAZIONE + MARGINALE (informativo, non promozione: regola standing short-vol)") + d_c = to_daily_lumped(ch_f[1.0]); d_v = to_daily_lumped(vrp[1.0]) + w_c = (1 + d_c).resample("168h", origin="epoch").prod() - 1 # origin comune (lezione '7D') + w_v = (1 + d_v).resample("168h", origin="epoch").prod() - 1 + Jw = pd.concat({"c": w_c, "v": w_v}, axis=1, join="inner").dropna() + print(f" corr settimanale cella~VRP01: {Jw['c'].corr(Jw['v']):+.2f} " + f"(alta = stesso trade in altri vestiti)") + mv = al.marginal_vs_tp01(d_c) + print(f" marginal_vs_tp01[cella]: verdict={mv.get('marginal_verdict')} corr={mv.get('corr_full')} " + f"IS-Sh={mv.get('cand_insample_sharpe')} insample_edge={mv.get('has_insample_edge')} " + f"hedge={mv.get('is_hedge')} robust_oos={mv.get('robust_oos')}") + + # ------------------------------------------------------------- (9) verdetto + print("\n (9) VERDETTO") + dsr_pass = bool(np.isfinite(dsr_full) and dsr_full >= 0.95 and np.isfinite(dsr_is) and dsr_is >= 0.95) + wins = beats_vrp and beats_bridge and dsr_pass and not delever_kills + print(f" batte VRP01 su tutta la banda f (ShF e ShH): {beats_vrp}") + print(f" batte il bridge (stesso motore) su tutta la banda f: {beats_bridge}") + print(f" DSR>=0.95 su {n_cells} celle (FULL {dsr_full:.3f} / IS {dsr_is:.3f}): {dsr_pass}") + print(f" sopravvive al null del de-levering: {not delever_kills}") + print(f" ==> {'ESISTE una cella che batte VRP01 onestamente' if wins else 'NESSUNA cella batte VRP01 onestamente'}") + print(" (esecuzione: comunque STAT-MODE — niente short-vol da modello in deploy;") + print(" muri di size Deribit invariati dal filone capital-scaling: spread ETH ~2.6k+, BTC ~4.7k+)") + + # dump metriche per il report + SCRATCH.mkdir(parents=True, exist_ok=True) + dump = {f"{k[0]}|z{k[1]}|dz{k[2]}|t{k[3]}": dict( + is_sh=c["mi"]["sh"], full_sh=c["mm"]["sh"], hold_sh=c["mm"]["sh_h"], dd=c["mm"]["dd"], + worst=c["mm"]["worst"], nact_is=c["nact_is"], nloss_is=c["nloss_is"], eligible=c["eligible"]) + for k, c in cells.items()} + (SCRATCH / "r0703_structgrid_results.json").write_text(json.dumps(dump, indent=1)) + print(f"\n [dump] {SCRATCH / 'r0703_structgrid_results.json'}") + + +if __name__ == "__main__": + main()