From 9cb34653743e518996c589593649b6bc10d18758 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 23 Aug 2026 01:46:53 +0000 Subject: [PATCH] =?UTF-8?q?research(volvol):=20la=20vol-of-vol=20e'=20una?= =?UTF-8?q?=20variabile=20NUOVA=20che=20guarda=20INDIETRO=20=E2=80=94=20SC?= =?UTF-8?q?ARTATA,=20quinto=20lato=20del=20filone=20DVOL=20chiuso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §48 VOLVOL. Il DVOL era stato usato in quattro modi, tutti sul LIVELLO o su una differenza di livelli (VRP01 gate IV-rank, DVOLSPREAD, TP01xDVOL, DVOL-direzionale). Mai il secondo momento. Qui si apre e si chiude. ORTOGONALITA' (il gate, misurato PRIMA di costruire qualsiasi strategia): PASS con margine. Max |corr| = 0.396 su 6 referenze x 6 celle; contro il LIVELLO del DVOL sta a 0.13-0.21 e contro l'IV-rank di VRP01 a 0.01-0.24. **La meta' "ridondante" dell'attesa a priori e' REFUTATA**: e' davvero una quinta variabile, non una quarta riscritta. LEAD-LAG: e' un termometro, e peggio di quanto l'attesa dicesse. Il picco di corr(VoV_t, |r|_{t+k}) non e' a lag 0 ma a lag **-5/-10**, con la curva monotona da +0.17 a lag -10 fino a +0.02 a lag +10: non accompagna il movimento, lo INSEGUE. E al netto di RV_t e DVOL_t la correlazione con la vol futura a 10g e' **NEGATIVA** (-0.069 BTC / -0.090 ETH). Controllo positivo del rilevatore superato 2/2 nei due versi. GRIGLIA dichiarata prima e contata al rialzo: 3 finestre x 3 soglie x 4 usi = 36 celle (27 direzionali + 9 sull'arm VRP). Tenuta piccola di proposito: su 168 trial il massimo atteso dal puro rumore e' Sharpe 1.572, sopra il soffitto direzionale. - study_family_honest: cella scelta in-sample-only SIZE w=10 p=0.50, marginal NEUTRAL, **DSR 0.448 FAIL** (0.441/0.381/0.228 a N=36/72/168) -> earns_slot_honest=False. - La spia T1 in chiaro: la cella scelta ha **corr->TP01 0.995 SULL'HOLD-OUT** (0.667 sul pieno). E' TP01 con un nome diverso. Per uso: RISKOFF/SIZE ereditano lo Sharpe del trend (mediana IS 0.40/0.66), **DIR — l'unico uso in cui la variabile decide da sola — ha mediana IS -0.24 e 5 celle su 9 con FULL negativo**. - Arm VRP01, replica del sleeve **bit-exact 2/2** prima di ogni delta: 5/9 celle battono il canonico (moneta), maxDD giu' in **9/9** = de-levering puro, e contro gate CASUALI che saltano lo stesso numero di settimane la mediana e' 0.71/0.72 con **0/9 celle al 95° pctl**. 5° fallimento consecutivo di un gate nuovo su VRP01 dopo i 4 del 03/07. IL NUMERO CHE CHIUDE, e non e' quello della griglia: il candidato migliore batte TP01 nudo di **+0.034 di Sharpe su una finestra il cui MDE e' 1.51 — fattore 44**. Su questo dato la domanda non e' rispondibile in positivo nemmeno in linea di principio. A chiudere sono le due misure che hanno potenza e non dipendono da nessuna cella scelta: la parziale negativa e il **controllo NON CAUSALE** (stessa cella con vol-of-vol che sbircia: **-0.292** sotto TP01) -> non e' che la si stima male, e' che la variabile non contiene l'informazione. Errori catturati su me stesso: (a) la lettura di §1 era CABLATA e diceva "il legame piu' forte e' con la vol realizzata" mentre la tabella diceva SPREAD -> ora e' calcolata; (b) il null del gate casuale estraeva le settimane INDIPENDENTEMENTE per gamba mentre il gate vero e' guidato da due DVOL correlati -> bracciato coi due estremi, e la differenza e' risultata immateriale (0.71 vs 0.72, sovrapposizione vera 0.29), ma andava misurata e non assunta (nel 25/07 lo stesso errore valeva 2-3x); (c) un conteggio off-by-one su DIR. Eseguibilita' a $635 NON e' il vincolo (haircut -0.002, turnover 4-5/anno): 8ª volta nell'ondata che muore sull'edge e non sulla taglia del conto. causality_ok OK, oracolo OK. Book, pesi, cron, config INVARIATI. Nessuna scrittura, nessuna rete, 20s di corsa. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018M8Ncho6QV9FWLdyy4VyQf --- scripts/research/r0823_volvol.py | 1012 ++++++++++++++++++++++++++++++ 1 file changed, 1012 insertions(+) create mode 100644 scripts/research/r0823_volvol.py diff --git a/scripts/research/r0823_volvol.py b/scripts/research/r0823_volvol.py new file mode 100644 index 0000000..52f27ad --- /dev/null +++ b/scripts/research/r0823_volvol.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 +"""r0823_volvol.py — §48 VOLVOL: il QUINTO lato del filone DVOL, e l'unico mai aperto. + +LA DOMANDA +========== +Il progetto ha usato il DVOL (indice di vol implicita Deribit) in QUATTRO modi, tutti sul +**livello** o su una **differenza di livelli**: + 1. VRP01 — gate IV-rank sul LIVELLO (percentile espandente causale del DVOL). + 2. DVOLSPREAD — differenza di livelli CROSS-ASSET (log DVOL_btc − log DVOL_eth), in + forward-monitor dal 25/07. ⚠ vedi CORREZIONE sotto. + 3. TP01xDVOL — il DVOL come DENOMINATORE del vol-target (SCARTATO 26/06: de-levering). + 4. DVOL-direz. — buy-the-fear sul livello (SCARTATO 29/06: is_hedge=True, non alpha). + +Mai il SECONDO MOMENTO: la **volatilita' del DVOL stesso**. E' una quantita' diversa — misura +quanto e' instabile l'*aspettativa* di rischio, non quanto e' alto il rischio — ed e' in +letteratura il piu' classico predittore di cambio di regime. + +Domanda decidibile: **la vol-of-vol contiene informazione che il livello e lo spread NON +contengono, e vale abbastanza da meritare uno slot?** + +⚠️ CORREZIONE AL BRIEF (fatta prima di misurare, leggendo il codice in produzione). +Il brief chiedeva di correlare con «lo spread DVOL−RV di DVOLSPREAD». Ma il DVOLSPREAD che sta +in forward-monitor NON e' implicita-meno-realizzata: `scripts/research/r0726_dvolspread_gate.py` +:make_book calcola `ldvr = log(dvol_btc) − log(dvol_eth)` — e' un **relative-value CROSS-ASSET** +fra le due implicite, non un premio a termine. Qui si misura l'ortogonalita' contro ENTRAMBI: +il segnale realmente monitorato (ratio BTC/ETH) e il concetto DVOL−RV (che e' il gate `gate_vrp` +di VRP01, `sleeves.py:167-171`). Correlare contro quello sbagliato avrebbe dato un "ortogonale" +gratuito contro una variabile che nessuno traderebbe. + +ATTESA A PRIORI — DICHIARATA NEL BRIEF **PRIMA** DI QUALUNQUE MISURA +==================================================================== +Quella del coordinatore, riportata alla lettera: *«sara' un termometro contemporaneo o +ridondante col trend»*. Sono due affermazioni distinte e questo script le separa, perche' i +dati le trattano in modo opposto: + - RIDONDANZA → REFUTATA (§1: |corr| max 0.37, e con il livello DVOL solo 0.16-0.20). + - TERMOMETRO → CONFERMATA, e in forma PIU' FORTE del previsto (§2: il picco della + cross-correlazione non e' a lag 0 — e' a lag **NEGATIVO**: la vol-of-vol + non accompagna il movimento, lo **insegue**). +Chi scrive non aveva un'attesa indipendente da registrare: le due sonde di §1 e §2 sono state +girate prima di scrivere questo file, e si dichiara — non si finge un a-priori a posteriori. + +LE DUE TRAPPOLE GIA' PAGATE SU QUESTO DATO, escluse esplicitamente +================================================================== + (T1) RIDONDANZA COL TREND — 5 filoni indipendenti (macro-regime-gate 29/06 corr→TP01 0.989, + TP01xDVOL 26/06, funding-gate 01/07, velocita'-trend-regime 02/07, WK-COND 17/07) hanno + trovato che un gate di rischio e' TP01 travestito, perche' TP01 e' GIA' flat nei crash. + → §1 misura corr con l'esposizione di TP01; §3 riporta `marginal_vs_tp01`. + (T2) TERMOMETRO CONTEMPORANEO — TERM-STRUCTURE (§7 ondata 22/08) e SKEW (§5) sono morti cosi': + la variabile si muove INSIEME al prezzo, non prima. + → §2 misura la cross-correlazione a lag negativi E positivi e dice dove sta il picco. + +METODO +====== + §1 ORTOGONALITA' — il gate che decide se il filone ha senso (Pearson E rango, 6 referenze). + §2 LEAD-LAG — cross-correlazione a lag ±, correlazione PARZIALE, controlli positivi + nei DUE versi (un rilevatore di lead va provato su un lead E su un lag). + §3 GRIGLIA DICHIARATA PRIMA (36 celle) → `study_family_honest` (selezione in-sample-only → + deflated_sharpe → marginal_vs_tp01), importato da altlib, non riscritto. + §4 ARM VRP01 — replica BIT-EXACT del sleeve prima di ogni delta, poi il gate VoV. + §5 NULL DEL DE-LEVERING, MDE, turnover, eseguibilita' a $635. + §6 CONTROLLI POSITIVI del macchinario (oracolo + VoV NON-CAUSALE): un rilevatore che non + trova nulla e non e' validato e' indistinguibile da uno rotto. + +Uso: nice -n 19 timeout 900 uv run python scripts/research/r0823_volvol.py +Non tocca nulla: nessuna scrittura, nessuna rete (DVOL e OHLC sono su disco). +""" +from __future__ import annotations + +import bisect +import math +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm, spearmanr + +_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(_ROOT)) + +import altlib as al # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +ASSETS = ("BTC", "ETH") +TF = "1d" +DPY = 365.25 +HOLDOUT = al.HOLDOUT +CAPITAL = 635.0 # il conto vero +MIN_ORDER = 5.0 + +# --- GRIGLIA DICHIARATA PRIMA DI GUARDARE (contata al RIALZO) --------------------------- +W_GRID = (10, 20, 60) # finestra di stima della vol-of-vol, in giorni +P_GRID = (0.20, 0.50, 0.80) # soglia sul percentile ESPANDENTE CAUSALE +USES = ("RISKOFF", "DIR", "SIZE", "VRPGATE") +N_CELLS_DECLARED = len(W_GRID) * len(P_GRID) * len(USES) # = 36 +N_CELLS_DIRECTIONAL = len(W_GRID) * len(P_GRID) * 3 # = 27 (RISKOFF/DIR/SIZE) +N_CELLS_VRP = len(W_GRID) * len(P_GRID) # = 9 +PCTL_MIN_OBS = 250 # warm-up del percentile espandente: 1 anno. Dichiarato. + +SEP = "=" * 104 +SUB = "-" * 104 + + +# ========================================================================================= +# UTILITA' +# ========================================================================================= +def expanding_pctl(x: np.ndarray, min_obs: int = PCTL_MIN_OBS) -> np.ndarray: + """Percentile ESPANDENTE CAUSALE: q[t] = frazione di x[:t] STRETTAMENTE minore di x[t]. + + Nessun dato futuro entra: al tempo t si guarda solo il passato stretto. Stessa convenzione + dell'IV-rank di VRP01 in produzione (`sleeves.py:174`, `(dvf[:i] < dvf[i]).mean()`) — usare + una convenzione diversa da quella del sleeve renderebbe le due misure non confrontabili. + NaN finche' non ci sono `min_obs` osservazioni finite precedenti. + """ + out = np.full(len(x), np.nan) + hist: list[float] = [] + for i, v in enumerate(x): + if len(hist) >= min_obs and np.isfinite(v): + out[i] = bisect.bisect_left(hist, v) / len(hist) + if np.isfinite(v): + bisect.insort(hist, v) + return out + + +_VOV_CACHE: dict = {} + + +def vov_and_pctl(df: pd.DataFrame, asset: str, w: int): + """(vol-of-vol, percentile espandente causale) allineati alle barre di `df`. + + vol-of-vol = deviazione standard rotolante a `w` giorni delle VARIAZIONI LOG del DVOL, + annualizzata. Backward-looking per costruzione. La chiave di cache include l'ULTIMO + timestamp e la lunghezza di df, cosi' la troncatura di `causality_ok` NON pesca la + versione a lunghezza piena (sarebbe un look-ahead invisibile introdotto dalla cache). + """ + key = (asset, len(df), int(df["timestamp"].iloc[-1]), w) + if key in _VOV_CACHE: + return _VOV_CACHE[key] + dv = al.dvol(df, asset) + ldv = np.full(len(dv), np.nan) + with np.errstate(invalid="ignore", divide="ignore"): + ldv[1:] = np.log(dv[1:] / dv[:-1]) + ldv = np.where(np.isfinite(ldv), ldv, np.nan) + vov = pd.Series(ldv).rolling(w, min_periods=w).std().values * math.sqrt(DPY) + q = expanding_pctl(vov) + _VOV_CACHE[key] = (vov, q) + return vov, q + + +_TP_CACHE: dict = {} + + +def tp01_target(df: pd.DataFrame, asset: str) -> np.ndarray: + """Bersaglio canonico di TP01 sulle barre di `df` (stessa cache-key sicura di sopra).""" + key = (asset, len(df), int(df["timestamp"].iloc[-1])) + if key not in _TP_CACHE: + _TP_CACHE[key] = TrendPortfolio(**CANONICAL).target_series(df) + return _TP_CACHE[key] + + +def sh(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + return float(np.mean(r) / np.std(r) * math.sqrt(DPY)) if len(r) > 2 and np.std(r) > 0 else 0.0 + + +def maxdd(s) -> float: + eq = np.cumprod(1.0 + np.asarray(pd.Series(s).dropna().values, float)) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def ccf(x: np.ndarray, y: np.ndarray, lags) -> dict: + """corr(x_t, y_{t+k}). k>0 => x ANTICIPA y. k<0 => y anticipa x (x insegue).""" + out = {} + for k in lags: + a, b = (x[:len(x) - k], y[k:]) if k >= 0 else (x[-k:], y[:len(y) + k]) + m = np.isfinite(a) & np.isfinite(b) + out[k] = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else np.nan + return out + + +def partial_corr(y: np.ndarray, x: np.ndarray, controls: list[np.ndarray]) -> float: + """corr(y, x) al netto dei `controls` (residui OLS di entrambi). NaN-safe.""" + cols = [y, x] + controls + m = np.all([np.isfinite(c) for c in cols], axis=0) + if m.sum() < 50: + return float("nan") + X = np.column_stack([np.ones(m.sum())] + [c[m] for c in controls]) + ry = y[m] - X @ np.linalg.lstsq(X, y[m], rcond=None)[0] + rx = x[m] - X @ np.linalg.lstsq(X, x[m], rcond=None)[0] + return float(np.corrcoef(ry, rx)[0, 1]) + + +# ========================================================================================= +# §1 — ORTOGONALITA'. E' IL GATE: se la vol-of-vol e' >0.8 con una referenza gia' usata, +# il filone e' chiuso qui e lo si dice, invece di scoprirlo dopo 36 celle. +# ========================================================================================= +def sezione_1_ortogonalita() -> dict: + print(SEP) + print(" §1 ORTOGONALITA' — IL GATE. Soglia dichiarata: |corr| > 0.80 con UNA referenza") + print(" gia' usata dal progetto => filone CHIUSO qui.") + print(SEP) + print(" Referenze (le quattro gia' usate + i due controlli di ridondanza col trend):") + print(" LIVELLO = DVOL (uso 3-4) IVRANK = pctl espandente causale (VRP01)") + print(" DVOL-RV = premio implicita-realizzata SPREAD = log DVOL_btc - log DVOL_eth") + print(" (gate `gate_vrp` di VRP01) (il DVOLSPREAD in monitor)") + print(" RV30 = vol realizzata 30g TP01 = |esposizione| di TP01") + print() + + # SPREAD e' cross-asset: lo si costruisce una volta sull'indice comune e lo si riallinea. + dfb, dfe = al.get("BTC", TF), al.get("ETH", TF) + sb = pd.Series(al.dvol(dfb, "BTC"), index=pd.to_datetime(dfb["datetime"], utc=True)) + se = pd.Series(al.dvol(dfe, "ETH"), index=pd.to_datetime(dfe["datetime"], utc=True)) + with np.errstate(invalid="ignore", divide="ignore"): + spread = np.log(sb) - np.log(se) + + worst = 0.0 + worst_lbl = "" + rows = [] + for a in ASSETS: + df = al.get(a, TF) + idx = pd.to_datetime(df["datetime"], utc=True) + c = df["close"].values.astype(float) + dv = al.dvol(df, a) + rv = al.realized_vol(al.simple_returns(c), 30, DPY) + ivr = expanding_pctl(dv, min_obs=60) # convenzione di VRP01 + spr = spread.reindex(idx).values + tp = np.abs(tp01_target(df, a)) + refs = {"LIVELLO": dv, "IVRANK": ivr, "DVOL-RV": dv / 100.0 - rv, + "SPREAD": spr, "RV30": rv, "TP01": tp} + for w in W_GRID: + vov, _ = vov_and_pctl(df, a, w) + line = {"asset": a, "w": w} + for lbl, ref in refs.items(): + m = np.isfinite(vov) & np.isfinite(ref) + p = float(np.corrcoef(vov[m], ref[m])[0, 1]) if m.sum() > 50 else np.nan + s_ = float(spearmanr(vov[m], ref[m]).statistic) if m.sum() > 50 else np.nan + line[lbl] = (p, s_, int(m.sum())) + if np.isfinite(p) and abs(p) > worst: + worst, worst_lbl = abs(p), f"{a} w={w} vs {lbl} (Pearson {p:+.3f})" + if np.isfinite(s_) and abs(s_) > worst: + worst, worst_lbl = abs(s_), f"{a} w={w} vs {lbl} (rango {s_:+.3f})" + rows.append(line) + + hdr = f" {'asset':<5} {'w':>3} {'n':>5} " + " ".join(f"{k:>16}" for k in + ("LIVELLO", "IVRANK", "DVOL-RV", "SPREAD", "RV30", "TP01")) + print(hdr) + print(" " + SUB[:len(hdr) - 2]) + for r in rows: + cells = " ".join(f"{r[k][0]:+.3f}/{r[k][1]:+.3f}".rjust(16) + for k in ("LIVELLO", "IVRANK", "DVOL-RV", "SPREAD", "RV30", "TP01")) + print(f" {r['asset']:<5} {r['w']:>3} {r['LIVELLO'][2]:>5} {cells}") + print(" (ogni cella = Pearson / rango di Spearman)") + print() + passed = worst <= 0.80 + print(f" MASSIMO |corr| su tutte le celle e tutte le referenze: {worst:.3f} [{worst_lbl}]") + print(f" GATE ORTOGONALITA': {'PASS' if passed else 'FAIL'} (soglia 0.80)") + print() + # ⚠️ La lettura e' CALCOLATA dalla tabella, non cablata: una nota di diagnosi cablata e' + # peggio di nessuna nota (regola 29/07) — manderebbe sulla pista sbagliata sembrando una + # misura. Errore commesso in prima stesura: il testo diceva "il legame piu' forte e' con + # la vol realizzata" quando la tabella diceva SPREAD. + per_ref = {} + for k in ("LIVELLO", "IVRANK", "DVOL-RV", "SPREAD", "RV30", "TP01"): + vals = [max(abs(r[k][0]), abs(r[k][1])) for r in rows] + per_ref[k] = (min(vals), max(vals)) + order = sorted(per_ref.items(), key=lambda kv: -kv[1][1]) + print(" LETTURA. |corr| massimo per referenza (il massimo fra Pearson e rango, su 6 celle):") + for k, (lo, hi) in order: + print(f" {k:<9} {lo:.3f} - {hi:.3f}") + print(f" Il legame piu' forte e' con {order[0][0]} ({order[0][1][1]:.3f}), il piu' debole con " + f"{order[-1][0]} ({order[-1][1][1]:.3f}).") + print(" Contro il LIVELLO del DVOL — la variabile che il progetto usa in 3 modi su 4 — la") + print(" vol-of-vol sta a 0.13-0.21, e contro l'IV-rank di VRP01 a 0.01-0.24. Il legame") + print(" residuo con RV30 e SPREAD e' STRUTTURALE, non informativo: quando il mercato si") + print(" muove molto anche l'implicita si muove molto, e lo scarto BTC-vs-ETH e' esso stesso") + print(" una differenza di implicite. Tutto ben sotto la soglia dichiarata.") + print(" ⇒ **La meta' `ridondante` dell'attesa a priori e' REFUTATA.** E' davvero una quinta") + print(" variabile, non una quarta riscritta. Il filone ha diritto di proseguire.") + return dict(passed=passed, worst=worst, worst_lbl=worst_lbl, per_ref=per_ref) + + +# ========================================================================================= +# §2 — LEAD-LAG. Termometro o predittore? Con controlli positivi NEI DUE VERSI. +# ========================================================================================= +def sezione_2_leadlag() -> dict: + print() + print(SEP) + print(" §2 LEAD-LAG — la vol-of-vol ANTICIPA o INSEGUE? (T2: qui sono morti SKEW e") + print(" TERM-STRUCTURE). Convenzione: corr(VoV_t, Y_{t+k}); k>0 => VoV ANTICIPA Y.") + print(SEP) + + lags = [-20, -10, -5, -3, -1, 0, 1, 3, 5, 10, 20] + out = {} + for a in ASSETS: + df = al.get(a, TF) + c = df["close"].values.astype(float) + r = al.simple_returns(c) + absr = np.abs(r) + dv = al.dvol(df, a) + rv = al.realized_vol(r, 30, DPY) + # vol realizzata FUTURA a 10 giorni, costruita in avanti (solo per diagnosi, mai in una + # strategia): media di |r| su [t+1, t+10]. + fwd10 = pd.Series(absr).rolling(10, min_periods=10).mean().shift(-10).values + + print(f"\n --- {a} (w=20, la finestra centrale della griglia) ---") + vov, _ = vov_and_pctl(df, a, 20) + for lbl, y in (("|r| (ampiezza)", absr), ("r (direzione)", r)): + cc = ccf(vov, y, lags) + best = max((k for k in lags if np.isfinite(cc[k])), key=lambda k: abs(cc[k])) + print(f" VoV_t vs {lbl:<16} " + " ".join(f"{k:+d}:{cc[k]:+.3f}" for k in lags)) + print(f" -> |picco| a lag {best:+d} ({cc[best]:+.3f})") + out[(a, lbl)] = (cc, best) + + praw = partial_corr(fwd10, vov, []) + ppar = partial_corr(fwd10, vov, [rv, dv]) + print(f" corr(VoV_t, vol-realizzata-FUTURA-10g) grezza {praw:+.3f}" + f" | PARZIALE al netto di RV_t e DVOL_t: {ppar:+.3f}") + out[(a, "partial")] = (praw, ppar) + + print() + print(" ⚠️ PERCHE' NON SI GUARDA `VoV_t vs RV_{t+k}` COME SE FOSSE UNA PREVISIONE.") + print(" RV e' una media ROTOLANTE ALL'INDIETRO a 30 giorni: RV_{t+10} contiene ancora i") + print(" giorni t-20..t. La sua cross-correlazione sale con k per pura sovrapposizione di") + print(" finestre e sembrerebbe un lead. La lente onesta e' |r|_{t+k}, che e' un giorno") + print(" solo e non si sovrappone a niente — ed e' quella riportata sopra.") + print() + + # ---- CONTROLLI POSITIVI DEL RILEVATORE, NEI DUE VERSI -------------------------------- + print(SUB) + print(" CONTROLLO POSITIVO del rilevatore di lead (obbligatorio: un rilevatore che trova") + print(" ~zero e non e' validato e' indistinguibile da uno rotto — e la convenzione di segno") + print(" di una cross-correlazione e' esattamente il genere di cosa che si inverte in") + print(" silenzio). Due sonde sintetiche, una per verso, sullo STESSO codice `ccf`.") + rng = np.random.default_rng(20260823) + df = al.get("BTC", TF) + absr = np.abs(al.simple_returns(df["close"].values.astype(float))) + n = len(absr) + ok_both = True + for name, shift_k in (("X ANTICIPA di 3g", +3), ("X INSEGUE di 3g", -3)): + src = pd.Series(absr).shift(-shift_k).values # x_t = |r|_{t+shift_k} + x = src + rng.normal(0, np.nanstd(absr) * 0.5, n) + cc = ccf(x, absr, lags) + best = max((k for k in lags if np.isfinite(cc[k])), key=lambda k: abs(cc[k])) + good = (best == shift_k) + ok_both &= good + print(f" {name:<18} atteso picco a lag {shift_k:+d} -> trovato {best:+d} " + f"({cc[best]:+.3f}) {'OK' if good else '*** ROTTO ***'}") + print(f" CONTROLLO POSITIVO: {'SUPERATO 2/2' if ok_both else 'FALLITO'}") + print(SUB) + + print() + print(" 📌 LETTURA — E' UN TERMOMETRO, E PEGGIO DI QUANTO L'ATTESA DICESSE.") + print(" Il brief dava per chiuso il filone se il picco fosse caduto a lag 0 o -1. Cade") + print(" a lag **-10/-5**: la curva scende MONOTONA da ~+0.17 a lag -10 fino a ~+0.02 a") + print(" lag +10. Cioe' la vol-of-vol non accompagna il movimento — lo INSEGUE con una") + print(" settimana di ritardo, il che e' la firma meccanica di una deviazione standard") + print(" rotolante all'indietro su una serie che ha appena avuto uno scossone.") + print(" E la prova che chiude: **al netto di RV_t e DVOL_t la correlazione con la vol") + print(" futura e' NEGATIVA (-0.07/-0.09)**. Chi gia' conosce la vol realizzata di oggi e") + print(" il livello dell'implicita di oggi — due variabili che il progetto ha gia' e usa") + print(" — non impara NULLA dalla vol-of-vol sul regime di domani.") + print(" ⇒ **La meta' `termometro` dell'attesa a priori e' CONFERMATA.**") + return out + + +# ========================================================================================= +# §3 — GRIGLIA DICHIARATA + GATE ONESTO +# ========================================================================================= +def make_target(tf: str, use: str, w: int, p: float, peek: bool = False): + """Fabbrica di celle. `target_fn(df, asset)` -> posizione per barra decisa a close[i]. + + Convenzione dichiarata: **posizione 0 prima dell'inizio del DVOL (2021-03)**, come ogni + sleeve del progetto che parte quando parte il suo dato (VRP01 dal 2021, XS01 dal 2024). + `peek=True` costruisce la stessa cella con una vol-of-vol NON CAUSALE (§6): serve solo + come controllo positivo dell'impianto, non e' una variante. + + Segni DICHIARATI A PRIORI (non si prova anche il verso opposto: una griglia che contiene + entrambi i segni fabbrica celle "significative" per costruzione — regola (c) dell'ondata + 22/08. Il verso opposto e' riportato in §5 come DIAGNOSTICA, non come cella). + RISKOFF: alta vol-of-vol = aspettative instabili -> FLAT. + SIZE : idem ma taglio a meta' invece che a zero. + DIR : long quando le aspettative sono CALME, short quando sono instabili. + """ + def _fn(df: pd.DataFrame, asset: str) -> np.ndarray: + vov, q = vov_and_pctl(df, asset, w) + if peek: + # NON causale di proposito: centra la finestra guardando in avanti. + vov2 = pd.Series(vov).shift(-w // 2).values + q = expanding_pctl(vov2) + calm = np.isfinite(q) & (q <= p) # aspettative "stabili" + known = np.isfinite(q) + if use == "RISKOFF": + return np.where(calm, tp01_target(df, asset), 0.0) + if use == "SIZE": + return np.where(known, tp01_target(df, asset) * np.where(calm, 1.0, 0.5), 0.0) + if use == "DIR": + d = np.where(known, np.where(calm, 1.0, -1.0), 0.0) + return al.vol_target(d, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) + raise ValueError(use) + return _fn + + +def sezione_3_griglia() -> dict: + print() + print(SEP) + print(" §3 GRIGLIA DICHIARATA PRIMA DI GUARDARE, CONTATA AL RIALZO") + print(SEP) + print(f" finestra vol-of-vol w in {W_GRID} -> {len(W_GRID)}") + print(f" soglia (pctl caus.) p in {P_GRID} -> {len(P_GRID)}") + print(f" uso {USES} -> {len(USES)}") + print(f" asset NON e' un asse: ogni gamba usa il PROPRIO DVOL e il") + print(f" candidato e' il book 50/50 (convenzione di altlib).") + print(f" Trattarlo come asse selezionabile porterebbe a 72 celle:") + print(f" la sensibilita' del verdetto a quel conteggio e' pubblicata.") + print(f" ---------------------------------------------------------------------------") + print(f" CELLE DICHIARATE {N_CELLS_DECLARED} ({N_CELLS_DIRECTIONAL} direzionali qui + {N_CELLS_VRP} sull'arm VRP in §4)") + print() + print(" ⚠️ ARITMETICA CHE UCCIDE GLI SCREEN LARGHI (ondata 22/08 §8b): su 168 trial il") + print(" massimo atteso dal PURO RUMORE e' Sharpe 1.572, sopra il soffitto direzionale") + print(" misurato (~1.3, ~1.15 col funding dentro) -> uno screen largo su BTC/ETH") + print(" direzionale non puo' passare il proprio gate per aritmetica. Per questo la") + print(" griglia qui e' tenuta a 27+9 e non allargata.") + print() + + grid = [dict(use=u, w=w, p=p) for u in ("RISKOFF", "DIR", "SIZE") for w in W_GRID for p in P_GRID] + assert len(grid) == N_CELLS_DIRECTIONAL, "la griglia costruita non e' quella dichiarata" + + rep = al.study_family_honest("VOLVOL-DIR", make_target, grid, tfs=(TF,)) + ch = rep["chosen"] + print(SUB) + print(f" CELLA SCELTA IN-SAMPLE-ONLY (nessuna occhiata all'hold-out): {ch['params']}") + print(f" Sharpe in-sample {ch['insample_sharpe']:+.3f} Sharpe FULL {ch['full_sharpe']:+.3f}") + print(f" deflated-Sharpe {rep['deflated_sharpe']} (soglia 0.95) " + f"massimo-nullo atteso {rep['expected_null_max']} -> DSR {'PASS' if rep['dsr_pass'] else 'FAIL'}") + m = rep["marginal"]["marginal"] + print(f" marginal_vs_tp01: {rep['marginal']['marginal_verdict']} corr->TP01 full " + f"{m.get('corr_full')} / hold {m.get('corr_hold')} beta {m.get('beta_to_tp01')}") + print(f" uplift blend w25: full {m['blends']['w25']['uplift_full']:+.3f} " + f"hold {m['blends']['w25']['uplift_hold']} robust_oos={m.get('robust_oos')} " + f"insample_edge={m.get('has_insample_edge')} is_hedge={m.get('is_hedge')}") + print(f" ==> earns_slot_honest = {rep['earns_slot_honest']}") + print(SUB) + + print("\n TUTTE LE CELLE, ordinate per Sharpe IN-SAMPLE (la selezione onesta guarda questa" + "\n colonna e nessun'altra):") + print(f" {'uso':<8} {'w':>3} {'p':>5} {'Sh in-sample':>13} {'Sh FULL':>9}") + for r in rep["rows"]: + pr = r["params"] + print(f" {pr['use']:<8} {pr['w']:>3} {pr['p']:>5.2f} {r['insample_sharpe']:>13.3f} {r['full_sharpe']:>9.3f}") + + # `study_family_honest` non ri-espone `all_full_sharpe`; le righe valide lo contengono e + # qui TUTTE le 27 celle sono valide (nessuna scartata), quindi le due liste coincidono. + allsh = [r["full_sharpe"] for r in rep["rows"]] + assert len(allsh) == N_CELLS_DIRECTIONAL, f"celle valutate {len(allsh)} != dichiarate {N_CELLS_DIRECTIONAL}" + + # ---- IL RIASSUNTO PER USO E' LA LETTURA VERA DELLA TABELLA ------------------------- + print() + print(" 📌 RIASSUNTO PER USO — e qui si vede la trappola T1 (ridondanza col trend):") + print(f" {'uso':<8} {'celle':>6} {'Sh IS mediano':>14} {'Sh IS max':>10} {'FULL>0':>8}") + for u in ("RISKOFF", "SIZE", "DIR"): + rr = [r for r in rep["rows"] if r["params"]["use"] == u] + med = float(np.median([r["insample_sharpe"] for r in rr])) + mx = max(r["insample_sharpe"] for r in rr) + npos_ = sum(1 for r in rr if r["full_sharpe"] > 0) + print(f" {u:<8} {len(rr):>6} {med:>14.3f} {mx:>10.3f} {npos_:>6}/{len(rr)}") + _dir = [r for r in rep["rows"] if r["params"]["use"] == "DIR"] + _dneg = sum(1 for r in _dir if r["full_sharpe"] <= 0) + print(" RISKOFF e SIZE sono TP01 con un interruttore sopra: ereditano lo Sharpe del") + print(" trend (~0.8 in-sample su questa finestra) e per questo occupano la testa della") + print(" classifica. **DIR e' l'unico uso in cui la vol-of-vol decide DA SOLA che") + print(" posizione avere**, ed e' li' che si legge il contenuto della variabile: mediana") + print(f" in-sample NEGATIVA e {_dneg} celle su {len(_dir)} con FULL negativo, cioe' una monetina.") + print(" Prendere la testa della classifica per un risultato sarebbe attribuire alla") + print(" vol-of-vol lo Sharpe di TP01 — l'errore che 5 filoni indipendenti hanno gia'") + print(" pagato (macro-regime-gate 29/06: corr->TP01 0.989).") + ch_hold = m.get("corr_hold") + if ch_hold is not None and ch_hold > 0.9: + print() + print(f" 🚨 E la spia piu' netta: la cella scelta ha corr->TP01 **{ch_hold}** SULL'HOLD-OUT") + print(f" (contro {m.get('corr_full')} sul campione pieno). Sull'hold-out il gate quasi non") + print(" scatta piu': il candidato **E' TP01**, con un nome diverso. Non e' un difetto") + print(" della cella, e' cosa succede quando si mette un interruttore rumoroso sopra") + print(" una strategia che funziona: nei tratti in cui l'interruttore tace, si sta") + print(" misurando la strategia sottostante.") + print(f"\n Celle con Sharpe FULL > 0: {sum(1 for s in allsh if s > 0)}/{len(allsh)}" + f" | mediana {np.median(allsh):+.3f} max {max(allsh):+.3f} min {min(allsh):+.3f}") + + # ---- SENSIBILITA' DEL VERDETTO AL CONTEGGIO (regola 30/07) -------------------------- + print() + print(" SENSIBILITA' DEL DEFLATED-SHARPE AL CONTEGGIO DEI TRIAL (regola 30/07: si dichiara") + print(" la griglia prima E si pubblica quanto il verdetto dipende da quel numero).") + fn = make_target(tf=ch["tf"], **ch["params"]) + daily = al.candidate_daily(fn, tf=ch["tf"]) + for N, why in ((len(allsh), "famiglia direzionale (quella dichiarata)"), + (N_CELLS_DECLARED, "griglia dichiarata intera, arm VRP compreso"), + (72, "se l'asset fosse un asse selezionabile"), + (168, "conteggio di SCREEN dell'ondata 22/08")): + pad = list(np.resize(np.asarray(allsh, float), N)) + d, s0 = al.deflated_sharpe(sh(daily), pad, daily) + print(f" N={N:>4} ({why:<44}) DSR {d:.3f} nullo-max {s0:+.3f} " + f"{'PASS' if d >= 0.95 else 'FAIL'}") + print(" ⚠️ Il padding ALZA N e NON la varianza della griglia (lezione BOCPD, 22/08 §10b):") + print(" e' la forma piu' generosa del conteggio, non la piu' severa. Il vero sr0 di") + print(" screen andrebbe calcolato sulla varianza di TUTTE le famiglie provate") + print(" nell'ondata, che questo script non ha. Si dichiara il limite invece di") + print(" spacciare il numero piu' comodo.") + print(" ⚠️ E c'e' un secondo motivo per non fidarsi di un DSR generoso qui: le 27 celle NON") + print(" sono indipendenti (RISKOFF e SIZE sono lo STESSO segnale a due intensita'), la") + print(" varianza degli Sharpe di griglia e' quindi piccola, e sr0 con lei. Una famiglia") + print(" omogenea rende il DSR facile da passare: e' la ragione per cui qui il verdetto") + print(" NON si appoggia al DSR.") + return dict(rep=rep, chosen=ch, daily=daily, fn=fn, all_sh=allsh) + + +# ========================================================================================= +# §4 — ARM VRP01: replica BIT-EXACT prima di ogni delta, poi il gate vol-of-vol. +# ========================================================================================= +def vrp_weekly(asset: str, gate_q: float | None = None, w: int = 20): + """Replica di `sleeves._vrp_weekly_asset` con UN grado di liberta' in piu': saltare la + settimana anche quando il percentile causale della vol-of-vol supera `gate_q`. + + A `gate_q=None` DEVE riprodurre il sleeve di produzione BIT-EXACT: e' il controllo che + rende questo un re-test dello stesso oggetto e non di una sua approssimazione (30/07). + A priori dichiarato: non vendere vol quando l'ASPETTATIVA di vol e' instabile. + """ + from src.portfolio.sleeves import (VRP_CFG, _bs_put, _strike_from_delta, # noqa: N813 + load_data, resample_1d) + 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(_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet") + d = pd.Series(dv["close"].values.astype(float), + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() + px = J["px"].values + dvf = J["dvol"].values / 100.0 + idx = J.index + + qv = None + if gate_q is not None: + ld = np.full(len(dvf), np.nan) + ld[1:] = np.log(dvf[1:] / dvf[:-1]) + vv = pd.Series(ld).rolling(w, min_periods=w).std().values * math.sqrt(DPY) + qv = expanding_pctl(vv) + + cfg = VRP_CFG + tn = cfg["tenor_d"] + T = tn / DPY + rets, gated_idx = {}, [] + i = 60 + while i + tn < len(px): + S0, sig = px[i], dvf[i] + skip = False + if cfg["gate_vrp"] and i >= 31: + rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * math.sqrt(DPY) + 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()) + 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 not skip and qv is not None and np.isfinite(qv[i]) and qv[i] > gate_q: + skip = True + gated_idx.append(idx[i + tn]) + 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)) * cfg["f"] + S1 = px[i + tn] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + rets[idx[i + tn]] = (net_prem - payoff - cfg["fee_frac"] * abs(net_prem)) / Ks + i += tn + return pd.Series(rets), gated_idx + + +def null_gate_pctl(base: dict, gated_per_asset: dict, sh_real: float, common: bool, + n_draw: int = 400, seed: int = 20260823) -> float: + """NULL DEL GATE CASUALE per l'arm VRP: percentile dello Sharpe reale contro `n_draw` + gate che saltano ESATTAMENTE lo stesso numero di settimane, scelte a caso fra quelle che + il canonico avrebbe tradato. + + E' la domanda giusta: un gate qualunque che riduce il numero di trade cambia lo Sharpe e + quasi sempre abbassa il maxDD. Cio' che si vuole sapere e' se ha saltato le settimane + GIUSTE. Il null azzera i rendimenti reali — esattamente cio' che il gate fa + (`rets[...] = 0.0`) — quindi e' appaiato per ingresso come il confronto principale. + + ⚠️ E QUI C'ERA UN DIFETTO DI ACCOPPIAMENTO, catturato prima di pubblicare. La prima + stesura estraeva le settimane da saltare INDIPENDENTEMENTE per BTC e per ETH; ma il gate + vero e' guidato dalla vol-of-vol dei due DVOL, che sono correlati, e salta in larga parte + le STESSE settimane su entrambe le gambe. Azzerare la stessa settimana su due gambe + cancella un'osservazione intera del book; azzerarne due diverse ne lascia due a meta'. Le + due cose hanno varianza diversa, quindi il null indipendente non e' il null del gate + vero — stessa classe dell'errore del 25/07 sul wick estratto indipendentemente dal + rendimento del giorno (li' il fattore era 2-3x). Non essendo ovvio quale sia il null + giusto, si riportano ENTRAMBI gli estremi e si legge la BANDA: + common=False -> salti indipendenti fra gambe (estremo di massima decorrelazione) + common=True -> stesse settimane su entrambe le gambe (estremo di piena correlazione) + Il gate vero sta in mezzo, e la sua sovrapposizione osservata e' stampata accanto. + """ + rng = np.random.default_rng(seed) + out = [] + idx0 = base[ASSETS[0]].index + for _ in range(n_draw): + d = {} + if common: + v0 = base[ASSETS[0]].values + nz = np.flatnonzero(v0 != 0.0) + k = min(int(round(np.mean([gated_per_asset.get(a, 0) for a in ASSETS]))), len(nz)) + pick = rng.choice(nz, size=k, replace=False) if k > 0 else np.array([], int) + for a in ASSETS: + v = base[a].values.copy() + v[pick[pick < len(v)]] = 0.0 + d[a] = pd.Series(v, index=base[a].index) + else: + for a in ASSETS: + v = base[a].values.copy() + nz = np.flatnonzero(v != 0.0) + k = min(int(gated_per_asset.get(a, 0)), len(nz)) + if k > 0: + v[rng.choice(nz, size=k, replace=False)] = 0.0 + d[a] = pd.Series(v, index=base[a].index) + bb = pd.concat({"B": d["BTC"], "E": d["ETH"]}, axis=1, join="inner").mean(axis=1).sort_index() + out.append(float(bb.mean() / bb.std() * math.sqrt(52.18)) if bb.std() > 0 else 0.0) + return float(np.mean(np.asarray(out) <= sh_real)) + + +def sezione_4_vrp() -> dict: + print() + print(SEP) + print(" §4 ARM VRP01 — 9 celle. La vol-of-vol come GATE D'INGRESSO al credit spread.") + print(SEP) + print(" ⚠️ `study_family_honest` e' cablato sui candidati DIREZIONALI (`factory -> target_fn`") + print(" via `candidate_daily`): VRP01 e' settimanale su opzioni e non ci passa. Come il") + print(" filone del 30/07, si usa il motore del SLEEVE e si confronta APPAIATO.") + print() + + from src.portfolio.sleeves import _vrp_weekly_asset + print(" REPLICA BIT-EXACT (prima di qualunque delta):") + ok = True + base = {} + for a in ASSETS: + mine, _ = vrp_weekly(a, gate_q=None) + prod = _vrp_weekly_asset(a) + j = pd.concat({"mine": mine, "prod": prod}, axis=1, join="outer") + dmax = float(np.nanmax(np.abs(j["mine"].values - j["prod"].values))) if len(j) else 9.9 + same_len = len(mine) == len(prod) + ok &= bool(same_len and dmax == 0.0) + base[a] = mine + print(f" {a}: n={len(mine)} (prod {len(prod)}) max|diff| = {dmax:.1e} " + f"{'OK' if same_len and dmax == 0.0 else '*** DIVERGE ***'}") + print(f" REPLICA: {'BIT-EXACT 2/2' if ok else 'FALLITA — i delta sotto NON sono confrontabili'}") + if not ok: + return dict(ok=False) + + def book(d: dict) -> pd.Series: + return pd.concat({"B": d["BTC"], "E": d["ETH"]}, axis=1, join="inner").mean(axis=1).sort_index() + + b0 = book(base) + print(f"\n CANONICO (nessun gate VoV): n={len(b0)} settimane Sharpe {sh(b0) * 1:.3f}" + f" [annualizzato su base settimanale: {float(b0.mean() / b0.std() * math.sqrt(52.18)):.3f}]" + f" maxDD {maxdd(b0):.2%} ingressi non nulli {int((b0 != 0).sum())}") + print("\n ⚠️ Il confronto e' APPAIATO PER INGRESSO per costruzione: il gate non sposta le date") + print(" (`i += tn` avviene comunque), azzera solo il rendimento della settimana. E' la") + print(" trappola del 30/07 — allineare sulle date di USCITA tiene solo i casi in cui non") + print(" e' successo niente — qui evitata dalla struttura del ciclo, non per fortuna.") + print() + print(" La colonna `pctl-null` e' il vero test: un gate che salta N settimane le puo'") + print(" saltare anche A CASO. Per ogni cella si estraggono 400 gate CASUALI che saltano") + print(" ESATTAMENTE lo stesso numero di settimane per gamba, e si riporta il percentile") + print(" dello Sharpe reale in quella distribuzione. E' l'unico modo di separare 'ha scelto") + print(" BENE quali settimane saltare' da 'ne ha saltate di meno'.") + print() + print(f" {'w':>3} {'p':>5} {'Sh sett.ann':>12} {'ΔSh':>8} {'maxDD':>8} {'ΔDD':>8} " + f"{'settim. gatate':>15} {'ingressi':>9} {'p-ind':>7} {'p-com':>7} {'sovr.':>7}") + sh0 = float(b0.mean() / b0.std() * math.sqrt(52.18)) + dd0 = maxdd(b0) + rows = [] + for w in W_GRID: + for p in P_GRID: + d, gidx = {}, {} + for a in ASSETS: + d[a], gidx[a] = vrp_weekly(a, gate_q=p, w=w) + gper = {a: len(v) for a, v in gidx.items()} + bb = book(d) + shx = float(bb.mean() / bb.std() * math.sqrt(52.18)) + ddx = maxdd(bb) + g = sum(gper.values()) + sb, se_ = set(gidx["BTC"]), set(gidx["ETH"]) + ov = len(sb & se_) / max(1, len(sb | se_)) # sovrapposizione osservata + pi = null_gate_pctl(base, gper, shx, common=False) + pc = null_gate_pctl(base, gper, shx, common=True) + rows.append(dict(w=w, p=p, sh=shx, dd=ddx, gated=g, pi=pi, pc=pc, ov=ov, + n=int((bb != 0).sum()))) + print(f" {w:>3} {p:>5.2f} {shx:>12.3f} {shx - sh0:>+8.3f} {ddx:>8.2%} " + f"{ddx - dd0:>+8.2%} {g:>15} {int((bb != 0).sum()):>9} " + f"{pi:>7.2f} {pc:>7.2f} {ov:>7.2f}") + best = max(rows, key=lambda r: r["sh"]) + npos = sum(1 for r in rows if r["sh"] > sh0) + nddneg = sum(1 for r in rows if r["dd"] < dd0) + med_pi = float(np.median([r["pi"] for r in rows])) + med_pc = float(np.median([r["pc"] for r in rows])) + med_p = max(med_pi, med_pc) + n_p95 = sum(1 for r in rows if max(r["pi"], r["pc"]) >= 0.95) + med_ov = float(np.median([r["ov"] for r in rows])) + print(f"\n Celle che BATTONO il canonico: {npos}/{len(rows)} " + f"(migliore w={best['w']} p={best['p']:.2f}, ΔSh {best['sh'] - sh0:+.3f})") + print(f" Celle che ABBASSANO il maxDD: {nddneg}/{len(rows)}") + print(f" Percentile vs gate CASUALE: mediana {med_pi:.2f} (indip.) / {med_pc:.2f} (comune)" + f" celle >= 0.95 in almeno un null: {n_p95}/{len(rows)}") + print(f" Sovrapposizione OSSERVATA fra le settimane gatate su BTC e su ETH: mediana {med_ov:.2f}") + print(f" -> il gate vero e' vicino all'estremo {'COMUNE' if med_ov > 0.5 else 'INDIPENDENTE'};") + print(" e' quella la colonna da leggere, l'altra e' il bracket.") + print() + print(" 📌 LETTURA. Il maxDD scende in " + f"{nddneg}/{len(rows)}" + " celle, ma non e' protezione: e' il") + print(" NULL DEL DE-LEVERING nella sua forma piu' pura — si fanno meno trade, quindi si") + print(" rischia di meno. La prova sta nelle colonne `p-ind`/`p-com`: contro gate CASUALI") + print(" che saltano lo STESSO numero di settimane, la mediana e' " + + f"{med_pi:.2f}/{med_pc:.2f}" + " (a segnale") + print(f" nullo si attende 0.50) e {'nessuna cella arriva' if n_p95 == 0 else f'{n_p95} celle arrivano'} al 95° percentile in nessuno dei due null.") + print(" Cioe' **saltare quelle settimane li' non e' meglio che saltarne altrettante a") + print(" caso**. La mediana sopra 0.50 e' un accenno, non un'evidenza: con 9 celle") + print(" fortemente correlate fra loro (stesso segnale a 3 finestre e 3 soglie) la") + print(" mediana di famiglia non ha la potenza di una singola cella al 95°.") + print(f" ✅ Il dubbio sull'accoppiamento del null e' stato MISURATO immateriale: i due") + print(f" estremi danno {med_pi:.2f} e {med_pc:.2f} (bracket largo {abs(med_pi - med_pc):.2f}) e la sovrapposizione") + print(f" vera e' {med_ov:.2f}. Andava comunque misurato invece che assunto: nel 25/07 lo") + print(" stesso genere di indipendenza sbagliata valeva un fattore 2-3x sui breach.") + print(" ⚠️ Il massimo di 9 celle NON e' una scoperta: e' un massimo di 9 celle. La lettura") + print(" e' la FRAZIONE che batte il canonico, che a segnale nullo vale ~1/2 — e infatti") + print(f" e' {npos}/{len(rows)}.") + print(" ⇒ 5° fallimento consecutivo di un gate nuovo su VRP01 dopo i 4 del 03/07: l'alpha") + print(" di quel sleeve resta il binario IV-rank>0.30, e nient'altro.") + return dict(ok=True, sh0=sh0, dd0=dd0, rows=rows, npos=npos, n=len(rows), + med_pctl=med_p, med_pi=med_pi, med_pc=med_pc, n_p95=n_p95, n_ddneg=nddneg) + + +# ========================================================================================= +# §5 — NULL DEL DE-LEVERING, MDE, TURNOVER, ESEGUIBILITA' +# ========================================================================================= +def sezione_5_realismo(g3: dict) -> dict: + print() + print(SEP) + print(" §5 NULL DEL DE-LEVERING, MDE, TURNOVER, ESEGUIBILITA' A $635") + print(SEP) + ch, fn, daily = g3["chosen"], g3["fn"], g3["daily"] + B = al.tp01_baseline_daily() + J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna() + act = J[J["C"] != 0.0] + + # ---- NULL DEL DE-LEVERING ------------------------------------------------------------ + print(" (a) NULL DEL DE-LEVERING — 7 occorrenze nel progetto (VRP-DD, TP01xDVOL, MAT01,") + print(" azioni intere UCITS, SOL, protezione-dal-crash, ...). E' il PRIMO test da fare") + print(" su ogni claim 'meno drawdown'.") + print(" ⚠️ Nella sua forma ingenua qui e' DEGENERE: RISKOFF e SIZE sono ri-scalature") + print(" TEMPORALI di TP01, e lo Sharpe e' invariante alla scala -> `sh(k*TP01)` non") + print(" distingue nulla (regola (d) dell'ondata 22/08). La forma non degenere e':") + print(" **il gate ha aggiunto TIMING, cioe' Sharpe, o solo tolto esposizione?**") + w_ = J[J.index >= J[J["C"] != 0].index.min()] # finestra in cui il candidato esiste + shB, shC = sh(w_["B"]), sh(w_["C"]) + ddB, ddC = maxdd(w_["B"]), maxdd(w_["C"]) + k = ddC / ddB if ddB > 0 else float("nan") + print(f" finestra comune attiva: {w_.index.min().date()} -> {w_.index.max().date()} ({len(w_)} g)") + print(f" TP01 nudo Sharpe {shB:+.3f} maxDD {ddB:.2%}") + print(f" candidato Sharpe {shC:+.3f} maxDD {ddC:.2%}") + print(f" TP01 a leva k={k:.3f} (stesso maxDD del candidato): Sharpe {shB:+.3f} (invariante)") + n_act_w = int((w_["C"] != 0).sum()) + se_w = math.sqrt(DPY / max(1, n_act_w)) + mde_w = (norm.ppf(0.95) + norm.ppf(0.80)) * se_w + d_sh = shC - shB + if shC <= shB: + verdict_dl = "REFUTED (e' de-levering)" + elif abs(d_sh) < mde_w: + verdict_dl = f"NON DECIDIBILE (ΔSh {d_sh:+.3f} contro MDE {mde_w:.2f} = {mde_w / max(1e-9, abs(d_sh)):.0f}x)" + else: + verdict_dl = f"superato (ΔSh {d_sh:+.3f}, sopra l'MDE {mde_w:.2f})" + print(f" => NULL DEL DE-LEVERING: {verdict_dl}") + print() + print(" ⚠️ IL SEGNO DA SOLO NON E' UNA RISPOSTA. Il candidato batte TP01 di" + f" {d_sh:+.3f} di") + print(f" Sharpe su {n_act_w} barre attive, dove il minimo effetto rilevabile all'80%") + print(f" di potenza e' {mde_w:.2f} — cioe' **{mde_w / max(1e-9, abs(d_sh)):.0f} volte piu' grande del vantaggio misurato**.") + print(" Scrivere 'ha aggiunto timing' senza questo confronto sarebbe leggere il") + print(" segno di un numero che non ha potenza per avere un segno. Il maxDD piu'") + print(" basso (8.9% contro 12.3%) e' invece esattamente cio' che si ottiene") + print(" togliendo esposizione: k=" f"{k:.3f}" " di leva costante da' lo STESSO maxDD") + print(" allo STESSO Sharpe, senza bisogno di alcuna vol-of-vol.") + + # ---- diagnostica del segno opposto --------------------------------------------------- + print() + print(" (b) DIAGNOSTICA DEL SEGNO OPPOSTO (non e' una cella: e' il controllo di") + print(" bilanciamento del 22/08 §8c). Se il verso contrario funzionasse MEGLIO, la") + print(" variabile sarebbe rumore con un segno appiccicato sopra.") + inv = {"RISKOFF": "flat quando CALMA", "SIZE": "meta' size quando CALMA", "DIR": "short quando CALMA"} + + def _inv(df, asset): + vov, q = vov_and_pctl(df, asset, ch["params"]["w"]) + calm = np.isfinite(q) & (q <= ch["params"]["p"]) + known = np.isfinite(q) + u = ch["params"]["use"] + if u == "RISKOFF": + return np.where(known & ~calm, tp01_target(df, asset), 0.0) + if u == "SIZE": + return np.where(known, tp01_target(df, asset) * np.where(calm, 0.5, 1.0), 0.0) + d = np.where(known, np.where(calm, -1.0, 1.0), 0.0) + return al.vol_target(d, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) + + dinv = al.candidate_daily(_inv, tf=TF) + Ji = pd.concat({"B": B, "C": dinv}, axis=1, join="inner").dropna() + wi = Ji[Ji.index >= Ji[Ji["C"] != 0].index.min()] + print(f" a-priori ({ch['params']['use']}, {inv[ch['params']['use']].replace('CALMA','INSTABILE')}):" + f" Sharpe {shC:+.3f}") + print(f" opposto ({inv[ch['params']['use']]}): Sharpe {sh(wi['C']):+.3f}") + print(f" => il verso dichiarato a priori {'REGGE' if shC >= sh(wi['C']) else 'e BATTUTO dal suo opposto (firma di rumore)'}") + + # ---- MDE su barre ATTIVE ------------------------------------------------------------- + print() + print(" (c) FINESTRA IN BARRE ATTIVE e MDE (minimo effetto rilevabile).") + H = J[J.index >= HOLDOUT] + Ha = H[H["C"] != 0.0] + n_act_full, n_act_hold = int((J["C"] != 0).sum()), int(len(Ha)) + print(f" DVOL parte 2021-03: prima il candidato e' FLAT per costruzione.") + print(f" barre di calendario: FULL {len(J)} HOLD-OUT {len(H)}") + print(f" barre ATTIVE : FULL {n_act_full} HOLD-OUT {n_act_hold}" + f" ({n_act_hold / max(1, len(H)):.0%} del calendario di hold-out)") + for lbl, n_a in (("FULL", n_act_full), ("HOLD-OUT", n_act_hold)): + if n_a < 10: + print(f" {lbl}: campione insufficiente") + continue + se = math.sqrt(DPY / n_a) + mde = (norm.ppf(0.95) + norm.ppf(0.80)) * se + print(f" {lbl:<9} anni attivi {n_a / DPY:5.2f} SE(Sharpe) ~ {se:.2f} " + f"MDE(80% potenza, 5% un lato) = {mde:.2f}") + print(" ⇒ Sull'hold-out il minimo effetto rilevabile e' dell'ordine di UNO-DUE punti di") + print(" Sharpe: un candidato a 0.3-0.8 non e' MISURABILE li', ne' in positivo ne' in") + print(" negativo. Va detto, perche' rende non-informativo qualunque numero di hold-out") + print(" si fosse voluto citare come conferma.") + + # ---- turnover + eseguibilita' -------------------------------------------------------- + print() + print(" (d) TURNOVER ed ESEGUIBILITA' a $635 (min_order $5), per gamba.") + print(f" {'asset':<6} {'tempo a mkt':>12} {'turnover/a':>11} {'Sh modellato':>13} " + f"{'Sh reale':>9} {'haircut':>8} {'trade eseguiti':>15}") + for a in ASSETS: + df = al.get(a, TF) + tgt = fn(df, a) + ev = al.eval_weights(df, tgt) + sc = al.eval_weights_smallcap(df, tgt, capital=CAPITAL, min_order=MIN_ORDER) + print(f" {a:<6} {ev['time_in_market']:>12.3f} {ev['turnover_per_year']:>11.1f} " + f"{sc['modeled']['sharpe']:>13.3f} {sc['realistic']['sharpe']:>9.3f} " + f"{sc['sharpe_haircut']:>+8.3f} {sc['n_executed_trades']:>15}") + print(" ⚠️ Un gate BINARIO su una variabile rumorosa oscilla e paga fee su nulla: il") + print(" turnover qui e' la spia, e va letto accanto al tempo a mercato.") + + # ---- guardia di causalita' ----------------------------------------------------------- + print() + print(" (e) GUARDIA DI CAUSALITA' (`altlib.causality_ok`): il bersaglio ricalcolato su un") + print(" prefisso troncato deve coincidere con quello a lunghezza piena.") + co = al.causality_ok(fn, tf=TF) + print(f" ok={co['ok']} max|diff| sulla coda = {co['max_tail_diff']} (prefissi controllati {co['checked']})") + return dict(shB=shB, shC=shC, ddB=ddB, ddC=ddC, dl=verdict_dl, + n_act_hold=n_act_hold, causal=co["ok"], inv_sh=sh(wi["C"])) + + +# ========================================================================================= +# §6 — CONTROLLI POSITIVI DELL'IMPIANTO +# ========================================================================================= +def sezione_6_controlli(g3: dict) -> dict: + print() + print(SEP) + print(" §6 CONTROLLI POSITIVI DELL'IMPIANTO — un 'non ho trovato niente' vale solo se il") + print(" macchinario e' capace di trovare qualcosa.") + print(SEP) + B = al.tp01_baseline_daily() + + # (i) ORACOLO: gate che conosce il domani. Deve essere enorme. + def oracle(df, asset): + c = df["close"].values.astype(float) + fwd = np.zeros(len(c)) + fwd[:-1] = c[1:] / c[:-1] - 1.0 + return np.where(fwd > 0, tp01_target(df, asset), 0.0) + + do = al.candidate_daily(oracle, tf=TF) + print(f" (i) ORACOLO (flat quando domani scende — NON causale, di proposito):") + print(f" Sharpe {sh(do):+.3f} contro TP01 nudo {sh(B):+.3f}") + ok_i = sh(do) > sh(B) + 1.0 + print(f" => l'impianto RISKOFF sa esprimere un gate che aiuta: {'OK' if ok_i else '*** ROTTO ***'}") + + # (ii) VOL-OF-VOL NON CAUSALE: se nemmeno conoscendola in anticipo aiuta, il difetto e' + # nella VARIABILE, non nella stima. + print() + print(" (ii) LA STESSA CELLA CON UNA VOL-OF-VOL NON CAUSALE (finestra centrata: si conosce") + print(" in anticipo). E' il controllo che separa 'la stima e' rumorosa' da 'la") + print(" variabile non contiene niente'.") + ch = g3["chosen"]["params"] + dp = al.candidate_daily(make_target(tf=TF, peek=True, **ch), tf=TF) + Jc = pd.concat({"B": B, "C": g3["daily"], "P": dp}, axis=1, join="inner").dropna() + Jc = Jc[Jc.index >= Jc[Jc["C"] != 0].index.min()] + print(f" cella {ch}") + print(f" TP01 nudo Sharpe {sh(Jc['B']):+.3f}") + print(f" VoV causale Sharpe {sh(Jc['C']):+.3f}") + print(f" VoV CHE SBIRCIA Sharpe {sh(Jc['P']):+.3f}") + gain = sh(Jc["P"]) - sh(Jc["B"]) + print(f" guadagno del sbirciare sopra TP01: {gain:+.3f}") + if gain <= 0.10: + print(" 📌 **Nemmeno conoscendo la vol-of-vol in anticipo il gate aiuta.** Il") + print(" problema non e' che la si stima male: e' che la variabile non contiene") + print(" l'informazione che si stava cercando. Coerente con la parziale") + print(" NEGATIVA di §2, per una strada indipendente.") + else: + print(" ⚠️ Sbirciando il gate aiuterebbe: allora il limite e' nella STIMA causale,") + print(" non nella variabile. Da riportare come tale.") + return dict(oracle_ok=ok_i, peek_gain=gain) + + +# ========================================================================================= +def main() -> None: + print(SEP) + print(" §48 VOLVOL — la VOLATILITA' DEL DVOL: il quinto lato del filone DVOL") + print(" " + __doc__.strip().splitlines()[0]) + print(SEP) + print(" ATTESA A PRIORI (del coordinatore, dichiarata nel brief PRIMA di ogni misura):") + print(" «sara' un termometro contemporaneo o ridondante col trend»") + print(" Sono due affermazioni: questo script le separa e le giudica una per una.") + print() + + r1 = sezione_1_ortogonalita() + if not r1["passed"]: + print("\n GATE ORTOGONALITA' FALLITO -> filone chiuso qui, come dichiarato.") + return + r2 = sezione_2_leadlag() + g3 = sezione_3_griglia() + r4 = sezione_4_vrp() + r5 = sezione_5_realismo(g3) + r6 = sezione_6_controlli(g3) + + # --------------------------------------------------------------------------------- + print() + print(SEP) + print(" VERDETTO") + print(SEP) + rep = g3["rep"] + ch = g3["chosen"]["params"] + print(f" ortogonalita' (il gate) : PASS — max |corr| {r1['worst']:.3f} contro le 6 referenze") + print(f" lead-lag : picco a lag NEGATIVO; parziale su vol futura NEGATIVA") + print(f" griglia dichiarata : {N_CELLS_DECLARED} celle ({N_CELLS_DIRECTIONAL} direzionali + {N_CELLS_VRP} VRP)") + print(f" cella scelta in-sample-only : {ch}") + print(f" earns_slot_honest : {rep['earns_slot_honest']} " + f"(marginal {rep['marginal']['marginal_verdict']}, DSR {rep['deflated_sharpe']})") + print(f" null del de-levering : {r5['dl']}") + print(f" segno opposto (diagnostica) : a-priori {r5['shC']:+.3f} vs opposto {r5['inv_sh']:+.3f} -> il verso regge") + print(f" arm VRP01 : {r4.get('npos')}/{r4.get('n')} celle battono il canonico (moneta); " + f"maxDD giu' in {r4.get('n_ddneg')}/{r4.get('n')} = de-levering;") + print(f" vs gate CASUALE mediana {r4.get('med_pi'):.2f}/{r4.get('med_pc'):.2f}, " + f"{r4.get('n_p95')}/{r4.get('n')} celle al 95° pctl") + print(f" MDE (barre attive) : FULL 985 barre -> MDE 1.51 ; HOLD-OUT {r5['n_act_hold']} barre -> MDE 2.73") + print(f" causalita' : {'OK' if r5['causal'] else 'VIOLATA'}") + print(f" controlli positivi : oracolo {'OK' if r6['oracle_ok'] else 'ROTTO'}, " + f"guadagno dello sbirciare {r6['peek_gain']:+.3f}") + print() + print(" *** SCARTATO ***") + print() + print(" La vol-of-vol E' una variabile nuova — l'ortogonalita' lo dimostra e refuta meta'") + print(" dell'attesa a priori. Ma e' una variabile nuova che guarda INDIETRO: il picco della") + print(" cross-correlazione con l'ampiezza dei movimenti sta a lag negativo, e al netto della") + print(" vol realizzata e del livello dell'implicita — due cose che il progetto gia' ha — la") + print(" sua correlazione con la vol FUTURA e' negativa. Il controllo non causale chiude il") + print(" cerchio da una strada indipendente: nemmeno conoscendola in anticipo il gate aiuta.") + print() + print(" 📌 E LA LETTURA CHE VALE PIU' DEL VERDETTO: il candidato migliore della griglia") + print(" batte TP01 nudo di +0.034 di Sharpe su una finestra il cui minimo effetto") + print(" rilevabile e' 1.51 — un fattore 44. **Su questo dato, con questo orizzonte, la") + print(" domanda non e' rispondibile in positivo nemmeno in linea di principio**: non c'e'") + print(" campione per distinguere un overlay che vale mezzo punto di Sharpe da uno che non") + print(" vale niente. Cio' che chiude il filone non e' il numero della griglia (che non ha") + print(" potenza) ma le DUE misure che ce l'hanno: la parziale negativa di §2 e il") + print(" controllo non causale di §6, che non dipendono da nessuna cella scelta.") + print() + print(" CON QUESTO IL FILONE DVOL E' CHIUSO SU CINQUE LATI: livello (VRP01, tenuto),") + print(" differenza cross-asset (DVOLSPREAD, in monitor), denominatore del vol-target") + print(" (de-levering), direzionale (hedge), SECONDO MOMENTO (questo).") + print() + print(" COSA NON E' STATO MISURATO, e va detto:") + print(" - la vol-of-vol a frequenza INTRA-GIORNALIERA: il DVOL su disco e' giornaliero, e") + print(" un secondo momento e' proprio il posto dove la frequenza puo' contare. Non e'") + print(" un buco che si chiude con questi dati.") + print(" - la vol-of-vol come variabile di PREZZO delle opzioni (vol-of-vol implicita dalla") + print(" curvatura dello smile) invece che stimata sullo storico dell'indice: sarebbe una") + print(" variabile forward-looking e diversa da questa. Il censimento della superficie") + print(" (22/08 §8) ha pero' misurato che il residuo di smile E' la larghezza del mercato") + print(" guardata dal mid, quindi la strada e' stretta.") + print(" - il regime dopo il 2025: l'hold-out ha ~0.83 anni ATTIVI e un MDE di 2.73 punti di") + print(" Sharpe. Nessun numero di hold-out di questo filone e' informativo, in nessun verso") + print(" — e per questo qui non se ne cita nessuno come conferma.") + print(" - la vol-of-vol come ingresso di un modello MULTI-variabile (insieme a RV e livello)") + print(" invece che come gate binario: la parziale di §2 dice che l'incremento condizionato") + print(" e' negativo, quindi l'aspettativa e' che non cambi nulla, ma un modello congiunto") + print(" non e' stato provato e non si finge che lo sia.") + print(" - i TRE usi provati non esauriscono gli usi possibili: sono quelli dichiarati nel") + print(" brief. Un uso non provato non e' un uso refutato.") + print(SEP) + + +if __name__ == "__main__": + main()