diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md new file mode 100644 index 0000000..f90ed6c --- /dev/null +++ b/docs/research/RESULTS-0822.md @@ -0,0 +1,24 @@ +# ONDATA 2026-08-22 — esiti per filone (aggiornato man mano) + +Regola di lettura: `SCARTATO` con un meccanismo misurato vale quanto un `LEAD` — chiude un filone +e smette di farlo pagare alle ondate successive. `CANDIDATO` richiede marginal ADDS + DSR>=0.95 + +null de-levering superato + eseguibilita' al capitale dichiarato. + +| # | filone | verdetto | in una riga | +|---|---|---|---| +| 9 | ADAPTIVE-HORIZON | **SCARTATO** | il "vincitore adattivo" ha lookback incollato al bordo il 100% del tempo = TSMOM costante a 20g (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore); isolando le celle davvero adattive -> NEUTRAL, corr→TP01 0.90 = TP01 travestito | + +## Note che sopravvivono ai singoli filoni + +### 9 — ADAPTIVE-HORIZON (r0822_adaptive_horizon.py, 112 trial dichiarati) +- Sanity: il baseline dell'agente riproduce TP01 canonico a **max|diff| = 0.0** (n=2719). +- Il controllo giusto NON era TP01 ma il **miglior lookback COSTANTE**: senza quel controllo il null + confonde "adattare paga" con "un orizzonte piu' corto paga su questo campione". L'agente se l'e' + costruito da solo dopo aver visto l'argmax incollato al bordo, e ha dichiarato i trial in piu'. +- ⚠️ Segnalazione, NON proposta: 30/90/180 e' rango 18/24 in-sample nella propria famiglia di + orizzonti (10/24 sull'hold-out), ma la superficie di L e' un **crinale di rumore** — il salto di + Sharpe fra L ADIACENTI e' +0.18, cioe' il 47-67% dell'intero vantaggio del vincitore su TP01, ed + estendere la griglia ha spostato l'argmax da L=20 a L=15. "Piu' corto e' meglio" e' un max-of-k su + una superficie frastagliata. **Nessun cambio a TP01.** +- Riaprirebbe il filone: un rilevatore il cui L si muove DAVVERO che batta il miglior L costante + (non TP01) in >=6/8 anni appaiati con corr→TP01 < 0.6 — es. BOCPD/PELT vero, non provato. diff --git a/scripts/research/r0822_adaptive_horizon.py b/scripts/research/r0822_adaptive_horizon.py new file mode 100644 index 0000000..baf804c --- /dev/null +++ b/scripts/research/r0822_adaptive_horizon.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python +"""r0822_adaptive_horizon.py — ADAPTIVE-HORIZON TSMOM: stimare *quanto indietro guardare* +dai dati (change-point / persistenza) invece di fissarlo a 30/90/180 giorni. + +DOMANDA +------- +Un TSMOM a lookback ADATTIVO batte il TSMOM a orizzonti FISSI 30/90/180 (TP01), in modo +MARGINALE e ONESTO? + +COSA NON E' (i due vicini gia' bocciati, per non rifarli) +--------------------------------------------------------- + * 02/07 "velocita' trend regime-condizionata": pesava i TRE orizzonti fissi in funzione del + percentile di vol -> risulto' un tilt-30d statico travestito (pctl 0.71 vs il null dei pesi + statici casuali). Li' gli orizzonti restavano 30/90/180: cambiavano solo i PESI. + * 29/06 "meta-allocazione dinamica" fra sleeve: peggiore dei pesi fissi. Li' l'oggetto adattato + erano i pesi di PORTAFOGLIO, non un parametro della strategia. + Qui l'oggetto adattato e' **l'orizzonte stesso**: L_t stimato da un rilevatore di change-point + (CUSUM) o di persistenza (variance-ratio, Hurst). Mai provato nel progetto. + +FORMA FUNZIONALE (scelta perche' rende il confronto strutturalmente pulito) +-------------------------------------------------------------------------- +TP01 canonico e' il blend di sign(c[i]/c[i-h]-1) su h = (30, 90, 180) giorni, long-flat, +vol-target 20%, leva cap 2x. Ma (30, 90, 180) == (L/3, L, 2L) con **L = 90 giorni**. +Quindi il candidato e' LA STESSA FUNZIONE con L che varia nel tempo: + blend='triple' -> horizons_t = (L_t/3, L_t, 2*L_t) (TP01 = L_t costante a 90g) + blend='single' -> horizons_t = (L_t,) (un solo orizzonte adattivo) +Tutto il resto (long-flat, target_vol 20%, vol_win 30g, leva 2x, fee 0.05%/lato, cadenza, +50/50 BTC+ETH) e' IDENTICO al baseline: **l'unico grado di liberta' e' l'orizzonte**. +=> il null non e' "meglio di zero", e' "meglio del TSMOM fisso a PARI GEOMETRIA". + +FAMIGLIA DICHIARATA **PRIMA** DI GUARDARE (conteggio al RIALZO) +-------------------------------------------------------------- + rilevatore (3): cusum | vratio | hurst + parametro (3): cusum k in {2,4,8} sigma-cumulate; vratio/hurst finestra W in {60,120,250}g + mappatura (2): direct (piu' persistenza / piu' tempo dall'ultimo change-point -> L piu' LUNGO) + inverse (specchiata) [il verso NON e' ovvio a priori => e' un asse, non una scelta] + blend (2): single | triple + timeframe (2): 1d | 12h [>= 12h: sotto, costi+overfit dominano (19/06)] + ------------------------------------------------------------------ + = 3 x 3 x 2 x 2 = 36 celle x 2 TF = **72 valutazioni**. +Costanti FISSATE A PRIORI e non cercate (dichiarate perche' sono gradi di liberta' non contati +nella griglia): Lmin=20g, Lmax=200g (contengono 30..180 del canonico); q=5g del variance-ratio; +scale del Hurst k in {1,2,4,8}g; finestra della percentile causale M=250g. Per questo il +deflated-Sharpe e' riportato ANCHE a N=144 e N=216 trial (sensibilita' del verdetto al conteggio, +regola del 30/07): un verdetto che si ribalta col conteggio si cita come tale. + +GATE (tutti dell'harness del progetto, nessuno riscritto) +--------------------------------------------------------- + A.study_family_honest -> cella scelta IN-SAMPLE-ONLY + deflated-Sharpe (NO selezione su hold-out) + A.marginal_vs_tp01 -> ADDS/HEDGE/NOISE/REDUNDANT/DILUTES + corr al baseline + A.causality_ok -> ricalcolo su prefisso (nessun look-ahead nella costruzione del segnale) + A.anchor_luck_band -> stima onesta = MEDIANA delle 24 ancore, non l'ancora 00:00 + A.anchor_luck_delta -> confronto adattivo-vs-fisso: MEDIANA delle DIFFERENZE APPAIATE + A.implausible_sharpe -> Sharpe troppo bello = rischio fuori dal campione + A.eval_weights_smallcap-> haircut reale a $600 (min-order $5) + null del de-levering -> esiste k<1 che da' al FISSO lo stesso maxDD con Sharpe >=? + differenza APPAIATA PER ANNO -> un vantaggio concentrato in un anno e' un evento, non un meccanismo + +Causalita': ogni rolling ha min_periods e finestra all'indietro; la CUSUM e' un loop in avanti +con reset (prefix-stable); la percentile e' rolling causale; nessuna statistica full-sample. +Runtime atteso ~3-6 min su 2 core. Nessun file toccato fuori da questo. +""" +from __future__ import annotations + +import sys +import warnings +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 A # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +ASSETS = A.CERTIFIED +HOLDOUT = A.HOLDOUT + +# --- costanti FISSATE A PRIORI (dichiarate sopra, non cercate) -------------- +LMIN_D, LMAX_D = 20, 200 # banda dell'orizzonte adattivo, in giorni +FIXED_H = (30, 90, 180) # == (L/3, L, 2L) con L=90 -> il baseline +TGT_VOL, LEV, VOLWIN = 0.20, 2.0, 30 +VR_Q_DAYS = 5 # passo del variance-ratio +HURST_K_DAYS = (1, 2, 4, 8) # scale del Hurst +PCTL_M_DAYS = 250 # finestra della percentile causale + +TFS = ("1d", "12h") +DETECTORS = ("cusum", "vratio", "hurst") +PARAMS = {"cusum": (2.0, 4.0, 8.0), "vratio": (60, 120, 250), "hurst": (60, 120, 250)} +MAPPINGS = ("direct", "inverse") +BLENDS = ("single", "triple") + + +# =========================================================================== +# RILEVATORI -> lookback adattivo L_t (in barre). Tutti causali. +# =========================================================================== +def _logret(c: np.ndarray) -> np.ndarray: + r = np.zeros(len(c)) + r[1:] = np.log(c[1:] / c[:-1]) + return r + + +def _roll_pctl(x: np.ndarray, m: int) -> np.ndarray: + """Percentile CAUSALE del valore corrente nella sua finestra all'indietro (rolling rank). + Warmup -> 0.5 (neutro): non si inventa un regime dove non c'e' campione.""" + s = pd.Series(x) + u = s.rolling(m, min_periods=max(20, m // 4)).rank(pct=True).values + return np.where(np.isfinite(u), u, 0.5) + + +def _cusum_age(c: np.ndarray, k: float, bpd: int) -> np.ndarray: + """Eta' (in barre) dall'ultimo change-point CUSUM a due code su rendimenti standardizzati. + Loop in avanti con reset: dipende solo dal passato -> stabile su prefisso.""" + lr = _logret(c) + sig = pd.Series(lr).rolling(30 * bpd, min_periods=max(5, 15 * bpd)).std().values + s = np.where((sig > 0) & np.isfinite(sig), lr / np.where(sig > 0, sig, 1.0), 0.0) + s = np.nan_to_num(s) + n = len(c) + age = np.zeros(n) + sp = sm = 0.0 + last = 0 + for i in range(n): + sp = max(0.0, sp + s[i]) + sm = min(0.0, sm + s[i]) + if max(sp, -sm) > k: + sp = sm = 0.0 + last = i + age[i] = i - last + return age + + +def _vratio(c: np.ndarray, w_days: int, bpd: int) -> np.ndarray: + """Variance ratio VR = Var(r_q) / (q * Var(r_1)) su finestra w_days. >1 = persistente.""" + lr = _logret(c) + qb = max(2, VR_Q_DAYS * bpd) + lg = np.log(c) + lrq = np.zeros(len(c)) + lrq[qb:] = lg[qb:] - lg[:-qb] + lrq[:qb] = np.nan + wb = max(20, w_days * bpd) + v1 = pd.Series(lr).rolling(wb, min_periods=wb // 2).var().values + vq = pd.Series(lrq).rolling(wb, min_periods=wb // 2).var().values + with np.errstate(invalid="ignore", divide="ignore"): + vr = vq / (qb * v1) + return vr + + +def _hurst(c: np.ndarray, w_days: int, bpd: int) -> np.ndarray: + """Hurst da scaling della volatilita': sd(r_k) ~ k^H. Regressione di log sd su log k + (x fisso -> forma chiusa). Tutto rolling causale.""" + lg = np.log(c) + wb = max(20, w_days * bpd) + xs, ys = [], [] + for kd in HURST_K_DAYS: + kb = max(1, kd * bpd) + d = np.full(len(c), np.nan) + d[kb:] = lg[kb:] - lg[:-kb] + sd = pd.Series(d).rolling(wb, min_periods=wb // 2).std().values + with np.errstate(invalid="ignore", divide="ignore"): + ys.append(np.log(np.where(sd > 0, sd, np.nan))) + xs.append(np.log(kb)) + X = np.asarray(xs, float) + Y = np.vstack(ys) # (n_scale, n_bar) + xm = X.mean() + denom = float(((X - xm) ** 2).sum()) + with warnings.catch_warnings(): # warmup = colonne tutte-NaN + warnings.simplefilter("ignore", RuntimeWarning) + ym = np.nanmean(Y, axis=0) + H = ((X - xm)[:, None] * (Y - ym[None, :])).sum(axis=0) / denom + return H + + +_STAT_CACHE: dict = {} + + +def _stat_key(df: pd.DataFrame, det: str, par) -> tuple: + ts = df["timestamp"].values + return (det, par, len(df), int(ts[0]), int(ts[-1])) + + +def lookback_bars(df: pd.DataFrame, det: str, par, mapping: str) -> np.ndarray: + """L_t in BARRE, dentro [Lmin, Lmax] giorni. Cache chiusa sul CONTENUTO del frame + (lunghezza + primo/ultimo timestamp) -> un prefisso NON prende la cache del pieno, + altrimenti causality_ok verrebbe ingannata dalla cache stessa.""" + bpd = A.bars_per_day(df) + lo, hi = max(2, LMIN_D * bpd), max(3, LMAX_D * bpd) + c = df["close"].values.astype(float) + key = _stat_key(df, det, par) + if key not in _STAT_CACHE: + if det == "cusum": + _STAT_CACHE[key] = _cusum_age(c, float(par), bpd) + elif det == "vratio": + _STAT_CACHE[key] = _roll_pctl(_vratio(c, int(par), bpd), PCTL_M_DAYS * bpd) + elif det == "hurst": + _STAT_CACHE[key] = _roll_pctl(_hurst(c, int(par), bpd), PCTL_M_DAYS * bpd) + else: + raise ValueError(det) + stat = _STAT_CACHE[key] + if det == "cusum": + base = np.clip(stat, lo, hi) + L = base if mapping == "direct" else (lo + hi - base) + else: + u = np.clip(stat, 0.0, 1.0) + L = lo + u * (hi - lo) if mapping == "direct" else hi - u * (hi - lo) + return np.clip(np.round(L), 2, hi).astype(int) + + +# =========================================================================== +# DIREZIONE TSMOM (orizzonte costante o variabile) + vol-target +# =========================================================================== +def _sign_at(c: np.ndarray, h: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + n = len(c) + i = np.arange(n) + j = i - h + ok = j >= 0 + s = np.zeros(n) + jj = np.where(ok, j, 0) + s[ok] = np.sign(c[i[ok]] / c[jj[ok]] - 1.0) + return s, ok + + +def _dir_from_L(c: np.ndarray, L: np.ndarray, blend: str) -> np.ndarray: + hs = [L] if blend == "single" else [ + np.maximum(2, np.round(L / 3.0).astype(int)), L, np.round(2.0 * L).astype(int)] + acc = np.zeros(len(c)) + cnt = np.zeros(len(c)) + for h in hs: + s, ok = _sign_at(c, np.asarray(h, int)) + acc[ok] += s[ok] + cnt[ok] += 1 + out = np.zeros(len(c)) + nz = cnt > 0 + out[nz] = acc[nz] / cnt[nz] + return out + + +def fixed_target(df: pd.DataFrame) -> np.ndarray: + """BASELINE: TSMOM fisso 30/90/180, long-flat, vol-target 20%, leva 2x = TP01 canonico.""" + c = df["close"].values.astype(float) + bpd = A.bars_per_day(df) + acc = np.zeros(len(c)) + cnt = np.zeros(len(c)) + for hd in FIXED_H: + s, ok = _sign_at(c, np.full(len(c), hd * bpd, int)) + acc[ok] += s[ok] + cnt[ok] += 1 + d = np.zeros(len(c)) + nz = cnt > 0 + d[nz] = acc[nz] / cnt[nz] + return A.vol_target(np.clip(d, 0, None), df, TGT_VOL, VOLWIN, LEV) + + +def const_factory(tf: str, L: int, blend: str): + """TSMOM a orizzonte COSTANTE L giorni, STESSA geometria del candidato adattivo. + blend='triple' -> (L/3, L, 2L): con L=90 e' ESATTAMENTE TP01 canonico 30/90/180.""" + def fn(df: pd.DataFrame) -> np.ndarray: + c = df["close"].values.astype(float) + bpd = A.bars_per_day(df) + Lb = np.full(len(c), max(2, int(round(L * bpd))), int) + d = np.clip(_dir_from_L(c, Lb, blend), 0, None) + return A.vol_target(d, df, TGT_VOL, VOLWIN, LEV) + fn.__name__ = f"const_L{L}_{blend}" + return fn + + +def factory(tf: str, det: str, par, mapping: str, blend: str): + """target_fn(df) -> posizione per barra, decisa con dati <= close[i].""" + def fn(df: pd.DataFrame) -> np.ndarray: + c = df["close"].values.astype(float) + L = lookback_bars(df, det, par, mapping) + d = np.clip(_dir_from_L(c, L, blend), 0, None) + return A.vol_target(d, df, TGT_VOL, VOLWIN, LEV) + fn.__name__ = f"adapt_{det}{par}_{mapping}_{blend}" + return fn + + +GRID = [dict(det=d, par=p, mapping=m, blend=b) + for d in DETECTORS for p in PARAMS[d] for m in MAPPINGS for b in BLENDS] + +# CONTROLLO dichiarato DOPO aver visto che il vincitore ha L_t incollato al bordo (sezione 3): +# il null "TSMOM fisso 30/90/180" NON basta piu', perche' confonde due cose diverse — +# "adattare l'orizzonte paga" e "un orizzonte piu' CORTO paga su questo campione". +# Il controllo giusto e' il migliore fra gli orizzonti COSTANTI della stessa banda, scelto +# in-sample-only. Trial aggiuntivi, contati al rialzo. +CONST_L = (20, 30, 45, 60, 90, 120, 150, 200) +CONST_GRID = [dict(L=L, blend=b) for L in CONST_L for b in BLENDS] + +# ESTENSIONE dichiarata DOPO aver visto che l'ottimo costante cade sul BORDO BASSO della banda +# (L=20g = LMIN_D): un ottimo al bordo non e' un ottimo, e' il punto in cui si e' smesso di +# guardare. Si estende SOTTO e si guarda se e' un plateau o una scivolata verso il rumore. +# +4 valori x 1 blend x 2 TF = 8 trial in piu' (contati al rialzo). +CONST_L_EXT = (5, 7, 10, 15) + + +# =========================================================================== +# UTILITA' DI MISURA +# =========================================================================== +def _dd(s: pd.Series) -> float: + eq = np.cumprod(1.0 + np.asarray(s.dropna().values, float)) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def _cagr(s: pd.Series) -> float: + s = s.dropna() + if len(s) < 5: + return float("nan") + eq = float(np.prod(1.0 + s.values)) + yrs = (s.index[-1] - s.index[0]).total_seconds() / (86400 * 365.25) + return eq ** (1 / max(yrs, 1e-6)) - 1.0 + + +def resample_offset(df_1h: pd.DataFrame, hours: int, off: int) -> pd.DataFrame: + """Stessa convenzione di trend_portfolio.resample_tf, con ancora spostata di `off` ore + (r0702_tp01_offset.py). NB: si usa `offset=`, non `origin=` (pandas ignora origin su 7D).""" + g = df_1h.copy() + idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + idx.name = "dt" + g.index = idx + out = g.resample(f"{hours}h", offset=pd.Timedelta(hours=off), label="left", closed="left").agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) + out = out.dropna(subset=["open"]) + out["datetime"] = out.index + epoch = pd.Timestamp("1970-01-01", tz="UTC") + out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + return out.reset_index(drop=True)[ + ["timestamp", "open", "high", "low", "close", "volume", "datetime"]] + + +def daily_from_target(fn, tf: str) -> pd.Series: + return A.candidate_daily(fn, tf=tf) + + +def paired_by_year(cand: pd.Series, base: pd.Series) -> list[dict]: + J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna() + rows = [] + for y, g in J.groupby(J.index.year): + if len(g) < 40: + continue + rows.append(dict(year=int(y), n=len(g), + sh_cand=round(A._sh(g["C"]), 2), sh_base=round(A._sh(g["B"]), 2), + d_sh=round(A._sh(g["C"]) - A._sh(g["B"]), 2), + ret_cand=round(float(np.prod(1 + g["C"].values) - 1) * 100, 1), + ret_base=round(float(np.prod(1 + g["B"].values) - 1) * 100, 1))) + return rows + + +def delever_null(cand: pd.Series, base: pd.Series) -> dict: + """Il fisso, DE-LEVERATO a pari maxDD del candidato, che Sharpe fa? + Scalare le POSIZIONI per k scala gross e fee insieme -> il netto scala per k e lo Sharpe + e' INVARIANTE: percio' un candidato che compra solo meno DD non e' un miglioramento.""" + J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna() + dd_c, dd_b = _dd(J["C"]), _dd(J["B"]) + if dd_c >= dd_b or dd_b <= 0: + return dict(applicabile=False, dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4), + nota="il candidato NON riduce il maxDD: il null non si applica") + lo, hi = 0.0, 1.0 + for _ in range(60): + k = 0.5 * (lo + hi) + if _dd(k * J["B"]) < dd_c: + lo = k + else: + hi = k + k = 0.5 * (lo + hi) + return dict(applicabile=True, k=round(k, 4), dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4), + dd_base_delev=round(_dd(k * J["B"]), 4), + sharpe_cand=round(A._sh(J["C"]), 3), + sharpe_base_delev=round(A._sh(k * J["B"]), 3), + superato=bool(A._sh(J["C"]) > A._sh(k * J["B"]))) + + +_ANCHOR_CACHE: dict = {} + + +def series_at_anchor(fn, name: str, hours: int, off: int) -> pd.Series: + """Serie giornaliera 50/50 BTC+ETH della strategia `fn` ribilanciata sull'ancora `off` + (resample del 1h certificato). Cache per (name, hours, off).""" + key = (name, hours, off) + if key not in _ANCHOR_CACHE: + ser = {} + for a in ASSETS: + dfo = resample_offset(A.get(a, "1h"), hours, off) + ev = A.eval_weights(dfo, A._call_target(fn, dfo, a)) + ser[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(ser, axis=1, join="inner").fillna(0.0) + _ANCHOR_CACHE[key] = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + return _ANCHOR_CACHE[key] + + +def adaptivity(tf: str, det: str, par, mapping: str) -> dict: + """Quanto si MUOVE davvero L_t (diagnostica di MECCANISMO, non di rendimento). + Una cella con L_t incollato a un bordo NON e' adattiva: e' un TSMOM a orizzonte + COSTANTE travestito, e non appartiene alla famiglia che si sta testando.""" + fr_lo, fr_hi, iqr, med = [], [], [], [] + for a in ASSETS: + df = A.get(a, tf) + bpd = A.bars_per_day(df) + L = lookback_bars(df, det, par, mapping) / bpd + fr_lo.append(float(np.mean(L <= LMIN_D + 1))) + fr_hi.append(float(np.mean(L >= LMAX_D - 1))) + iqr.append(float(np.percentile(L, 75) - np.percentile(L, 25))) + med.append(float(np.median(L))) + return dict(frac_lo=float(np.mean(fr_lo)), frac_hi=float(np.mean(fr_hi)), + iqr_days=float(np.mean(iqr)), med_days=float(np.mean(med)), + adattiva=bool(np.mean(fr_lo) < 0.5 and np.mean(fr_hi) < 0.5 + and np.mean(iqr) >= 10.0)) + + +def hr(t: str = "") -> None: + print("\n" + "=" * 78) + if t: + print(t) + print("=" * 78) + + +# =========================================================================== +def main() -> None: + print(__doc__.split("Runtime atteso")[0]) + + # -- 0. SANITY: il mio baseline DEVE essere TP01 canonico, bit-per-bit ---------------- + hr("0. SANITY — il baseline 'fisso' e' TP01 canonico (se non lo e', ogni Δ e' inventato)") + tp = TrendPortfolio(**CANONICAL) + worst = 0.0 + for a in ASSETS: + df = A.get(a, "1d") + worst = max(worst, float(np.max(np.abs(fixed_target(df) - tp.target_series(df))))) + base_1d = daily_from_target(fixed_target, "1d") + tp01 = A.tp01_baseline_daily() + JJ = pd.concat({"a": base_1d, "b": tp01}, axis=1, join="inner").dropna() + print(f" max|target_mio - TrendPortfolio.target_series| (1d, BTC+ETH) = {worst:.3e}") + print(f" max|serie_giornaliera_mia - al.tp01_baseline_daily()| = " + f"{float(np.max(np.abs(JJ['a'] - JJ['b']))):.3e} (n={len(JJ)})") + assert worst < 1e-12, "il baseline NON riproduce TP01: fermarsi qui" + + # -- 1. GRIGLIA ONESTA: cella scelta IN-SAMPLE + deflated-Sharpe ---------------------- + hr(f"1. study_family_honest — {len(GRID)} celle x {len(TFS)} TF = {len(GRID)*len(TFS)} trial") + rep = A.study_family_honest("ADAPT-H", factory, GRID, TFS) + ch = rep["chosen"] + print(f" celle valutate : {rep['n_cells']}") + print(f" cella scelta IN-SAMPLE : tf={ch['tf']} {ch['params']} " + f"Sharpe IS {ch['insample_sharpe']} FULL {ch['full_sharpe']}") + print(f" deflated-Sharpe (N={rep['n_cells']}) : {rep['deflated_sharpe']} " + f"(max atteso sotto il null {rep['expected_null_max']}) PASS={rep['dsr_pass']}") + print(f" earns_slot_marginal : {rep['earns_slot_marginal']}") + print(f" EARNS_SLOT_HONEST : {rep['earns_slot_honest']}") + print("\n top-8 IN-SAMPLE (come si sceglie onestamente):") + for r in rep["rows"][:8]: + print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} " + f"{r['tf']:>3} {r['params']}") + + # sensibilita' del verdetto al CONTEGGIO dei trial (regola 30/07) + fn_ch = factory(tf=ch["tf"], **ch["params"]) + d_ch = daily_from_target(fn_ch, ch["tf"]) + allsr = [r["full_sharpe"] for r in rep["rows"]] + print("\n sensibilita' del DSR al numero di trial dichiarati:") + for n_decl in (len(GRID) * len(TFS), 144, 216): + pad = allsr + [float(np.mean(allsr))] * max(0, n_decl - len(allsr)) + dsr, _ = A.deflated_sharpe(A._sh(d_ch), pad, d_ch) + print(f" N={n_decl:>4} DSR={dsr:.3f} {'PASS' if dsr >= 0.95 else 'FAIL'}") + + # cosa avrebbe scelto un disonesto (diagnostica, NON una decisione) + diag = [] + for r in rep["rows"][:12]: + s_all = daily_from_target(factory(tf=r["tf"], **r["params"]), r["tf"]) + diag.append({**r, "hold": round(A._sh(s_all[s_all.index >= HOLDOUT]), 3)}) + diag.sort(key=lambda r: -r["hold"]) + print("\n (diagnostica) le stesse 12 celle riordinate per HOLD-OUT — cio' che NON si fa:") + for r in diag[:4]: + print(f" HOLD {r['hold']:+.3f} IS {r['insample_sharpe']:+.3f} {r['tf']:>3} {r['params']}") + + # -- 2. NUMERI del candidato vs il FISSO a pari geometria ----------------------------- + hr("2. CANDIDATO vs TSMOM FISSO 30/90/180 — stessa geometria, stesso TF, stesse fee") + base_ch = daily_from_target(fixed_target, ch["tf"]) + J = pd.concat({"C": d_ch, "B": base_ch}, axis=1, join="inner").dropna() + for lab, sl in (("FULL", J), ("HOLD-OUT", J[J.index >= HOLDOUT])): + if len(sl) < 30: + continue + print(f" {lab:<9} adattivo Sh {A._sh(sl['C']):+.3f} DD {_dd(sl['C'])*100:5.2f}% " + f"CAGR {_cagr(sl['C'])*100:6.2f}% | fisso Sh {A._sh(sl['B']):+.3f} " + f"DD {_dd(sl['B'])*100:5.2f}% CAGR {_cagr(sl['B'])*100:6.2f}% " + f"| dSh {A._sh(sl['C'])-A._sh(sl['B']):+.3f}") + print(f" corr(adattivo, fisso) = {J['C'].corr(J['B']):.3f}") + + print("\n DIFFERENZA APPAIATA PER ANNO (un vantaggio in un anno solo e' un evento):") + rows = paired_by_year(d_ch, base_ch) + for r in rows: + print(f" {r['year']} adatt Sh {r['sh_cand']:+.2f} ({r['ret_cand']:+7.1f}%) " + f"fisso Sh {r['sh_base']:+.2f} ({r['ret_base']:+7.1f}%) dSh {r['d_sh']:+.2f}") + ds = [r["d_sh"] for r in rows] + print(f" -> anni con dSh>0: {sum(1 for x in ds if x > 0)}/{len(ds)} " + f"mediana dSh {np.median(ds):+.2f} media {np.mean(ds):+.2f}") + + # -- 3. LOOKBACK: si muove davvero? --------------------------------------------------- + hr("3. IL LOOKBACK ADATTIVO SI MUOVE? (se sta al bordo e' un TSMOM COSTANTE travestito)") + for a in ASSETS: + df = A.get(a, ch["tf"]) + bpd = A.bars_per_day(df) + L = lookback_bars(df, ch["params"]["det"], ch["params"]["par"], + ch["params"]["mapping"]) / bpd + lo, hi = LMIN_D, LMAX_D + print(f" {a}: L_t giorni min {L.min():.0f} p25 {np.percentile(L,25):.0f} " + f"mediana {np.median(L):.0f} p75 {np.percentile(L,75):.0f} max {L.max():.0f} " + f"| al bordo basso {np.mean(L<=lo+1)*100:.1f}% al bordo alto {np.mean(L>=hi-1)*100:.1f}%" + f" | |dL|/giorno mediano {np.median(np.abs(np.diff(L))):.1f}g") + + # -- 4. GATE --------------------------------------------------------------------------- + hr("4. GATE") + print(A.fmt_marginal(rep["marginal"])) + + cz = A.causality_ok(fn_ch, tf=ch["tf"]) + print(f"\n causality_ok : ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} " + f"(prefissi controllati: {cz['checked']})") + + imp = A.implausible_sharpe(d_ch) + print(f" implausible_sharpe : implausible={imp['implausible']} Sh {imp.get('sharpe')} " + f"maxDD {imp.get('maxdd')} perdite/attive {imp.get('loss_frac')} " + f"attive {imp.get('active_frac')}") + for rr in imp["reasons"]: + print(f" - {rr}") + + dl = delever_null(d_ch, base_ch) + print(f" null del de-levering : {dl}") + + # small-cap a $600 + print(" haircut a $600 (min-order $5):") + for a in ASSETS: + df = A.get(a, ch["tf"]) + sc_c = A.eval_weights_smallcap(df, A._call_target(fn_ch, df, a), capital=600.0) + sc_b = A.eval_weights_smallcap(df, fixed_target(df), capital=600.0) + print(f" {a}: adattivo modellato {sc_c['modeled']['sharpe']:+.3f} -> reale " + f"{sc_c['realistic']['sharpe']:+.3f} (haircut {sc_c['sharpe_haircut']:+.3f}, " + f"{sc_c['n_executed_trades']} trade) | fisso {sc_b['modeled']['sharpe']:+.3f} -> " + f"{sc_b['realistic']['sharpe']:+.3f} (haircut {sc_b['sharpe_haircut']:+.3f}, " + f"{sc_b['n_executed_trades']} trade)") + + # -- 5. BANDA D'ANCORA ----------------------------------------------------------------- + hours = 24 if ch["tf"] == "1d" else 12 + offs = list(range(hours)) + hr(f"5. BANDA D'ANCORA — {len(offs)} ancore, stima onesta = MEDIANA (mai l'ancora 00:00)") + + def _series_at(off: int, which: str) -> pd.Series: + return series_at_anchor(fn_ch if which == "cand" else fixed_target, which, hours, off) + + # sanity: l'ancora 0 riproduce la serie canonica + s0 = _series_at(0, "fixed") + K = pd.concat({"a": s0, "b": base_ch}, axis=1, join="inner").dropna() + print(f" sanity ancora 0 vs serie canonica del fisso: max|Δ| = " + f"{float(np.max(np.abs(K['a']-K['b']))):.3e} (n={len(K)})") + + for lab, mtr in (("Sharpe FULL", A._sh), + ("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))): + b_c = A.anchor_luck_band(lambda o: _series_at(o, "cand"), offs, metric=mtr) + b_f = A.anchor_luck_band(lambda o: _series_at(o, "fixed"), offs, metric=mtr) + print(f" {lab:<12} adattivo: canonica {b_c['canonical']:+.3f} (pctl {b_c['canonical_pctl']:.2f}) " + f"MEDIANA {b_c['median']:+.3f} banda [{b_c['lo']:+.3f},{b_c['hi']:+.3f}]") + print(f" {'':<12} fisso : canonica {b_f['canonical']:+.3f} (pctl {b_f['canonical_pctl']:.2f}) " + f"MEDIANA {b_f['median']:+.3f} banda [{b_f['lo']:+.3f},{b_f['hi']:+.3f}]") + dlt = A.anchor_luck_delta(lambda o: _series_at(o, "cand"), + lambda o: _series_at(o, "fixed"), offs, metric=mtr) + print(f" {'':<12} DELTA APPAIATO: mediana {dlt['median_paired']:+.3f} " + f"positivo in {dlt['n_positive']}/{dlt['n_anchors']} ancore " + f"banda [{dlt['lo']:+.3f},{dlt['hi']:+.3f}] gate_pass={dlt['gate_pass']}") + + # -- 6. ADATTIVITA' EFFETTIVA DI TUTTA LA FAMIGLIA ------------------------------------ + hr("6. QUANTE DELLE 72 CELLE SONO DAVVERO ADATTIVE? (filtro di MECCANISMO, non di resa)") + print(" criterio: L_t sta a un bordo <50% del tempo E l'IQR di L_t e' >= 10 giorni") + seen, adatt_rows = {}, [] + for r in rep["rows"]: + k = (r["tf"], r["params"]["det"], r["params"]["par"], r["params"]["mapping"]) + if k not in seen: + seen[k] = adaptivity(*k) + r["_adapt"] = seen[k] + if seen[k]["adattiva"]: + adatt_rows.append(r) + n_ad_cells = sum(1 for r in rep["rows"] if r["_adapt"]["adattiva"]) + print(f" celle DAVVERO adattive: {n_ad_cells}/{len(rep['rows'])} " + f"(le altre sono TSMOM a orizzonte COSTANTE travestito)") + for k, v in sorted(seen.items()): + print(f" {k[0]:>3} {k[1]:>6} {str(k[2]):>5} {k[3]:<7} bordo-basso {v['frac_lo']*100:5.1f}% " + f"bordo-alto {v['frac_hi']*100:5.1f}% IQR {v['iqr_days']:5.1f}g " + f"mediana {v['med_days']:5.1f}g -> {'ADATTIVA' if v['adattiva'] else 'costante'}") + + # -- 7. IL CONTROLLO CHE MANCAVA: il MIGLIOR ORIZZONTE COSTANTE ------------------------ + hr(f"7. CONTROLLO L COSTANTE — {len(CONST_GRID)} celle x {len(TFS)} TF = " + f"{len(CONST_GRID)*len(TFS)} trial IN PIU'") + print(" la domanda diventa: il guadagno viene dall'ADATTARE, o dal fatto che su questo") + print(" campione un orizzonte piu' CORTO batte 30/90/180 anche se lo si tiene fisso?") + csel = A.select_cell_insample(lambda tf, L, blend: const_factory(tf, L, blend), + CONST_GRID, TFS) + cch = csel["chosen"] + print(f"\n miglior COSTANTE scelto in-sample: tf={cch['tf']} {cch['params']} " + f"Sharpe IS {cch['insample_sharpe']} FULL {cch['full_sharpe']}") + print(" griglia costante, in-sample (L=90 triple == TP01 canonico):") + for r in csel["rows"]: + tag = " <== TP01 canonico" if (r["params"]["L"] == 90 and r["params"]["blend"] == "triple" + and r["tf"] == "1d") else "" + print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} " + f"L={r['params']['L']:>3}g {r['params']['blend']}{tag}") + + fn_const = const_factory(**cch["params"], tf=cch["tf"]) + d_const = daily_from_target(fn_const, cch["tf"]) + JC = pd.concat({"C": d_ch, "K": d_const}, axis=1, join="inner").dropna() + print(f"\n ADATTIVO vs MIGLIOR COSTANTE (entrambi scelti in-sample):") + for lab, sl in (("FULL", JC), ("HOLD-OUT", JC[JC.index >= HOLDOUT])): + if len(sl) < 30: + continue + print(f" {lab:<9} adattivo Sh {A._sh(sl['C']):+.3f} DD {_dd(sl['C'])*100:5.2f}% | " + f"costante Sh {A._sh(sl['K']):+.3f} DD {_dd(sl['K'])*100:5.2f}% | " + f"dSh {A._sh(sl['C'])-A._sh(sl['K']):+.3f}") + print(f" corr(adattivo, miglior costante) = {JC['C'].corr(JC['K']):.3f}") + print("\n differenza APPAIATA per anno vs il miglior COSTANTE:") + rows2 = paired_by_year(d_ch, d_const) + for r in rows2: + print(f" {r['year']} adatt {r['sh_cand']:+.2f} costante {r['sh_base']:+.2f} " + f"dSh {r['d_sh']:+.2f}") + d2 = [r["d_sh"] for r in rows2] + print(f" -> anni con dSh>0: {sum(1 for x in d2 if x > 0)}/{len(d2)} " + f"mediana {np.median(d2):+.2f}") + dl2 = delever_null(d_ch, d_const) + print(f" null del de-levering vs costante: {dl2}") + + # ancora appaiata adattivo vs miglior costante (se stesso TF, altrimenti dichiarato) + if cch["tf"] == ch["tf"]: + for lab, mtr in (("Sharpe FULL", A._sh), + ("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))): + dd2 = A.anchor_luck_delta(lambda o: series_at_anchor(fn_ch, "cand", hours, o), + lambda o: series_at_anchor(fn_const, "const", hours, o), + offs, metric=mtr) + print(f" ancora {lab}: DELTA APPAIATO mediana {dd2['median_paired']:+.3f} " + f"positivo in {dd2['n_positive']}/{dd2['n_anchors']} " + f"banda [{dd2['lo']:+.3f},{dd2['hi']:+.3f}] gate_pass={dd2['gate_pass']}") + else: + print(f" (ancora appaiata non girata: TF diversi, adattivo {ch['tf']} vs " + f"costante {cch['tf']} -> le ancore non sono coppie)") + + # fee sweep: l'adattivo tradà di piu', il conto va fatto + print("\n FEE SWEEP (il candidato ha piu' turnover: e' li' che muore o no)") + for nm, f_ in (("adattivo", fn_ch), ("costante", fn_const), ("TP01 30/90/180", fixed_target)): + tfx = ch["tf"] if nm != "costante" else cch["tf"] + line = [] + for fee in (0.0, 0.0005, 0.001, 0.0015): + dser = A.candidate_daily(f_, tf=tfx, fee_side=fee) + line.append(f"{2*fee*100:.2f}%RT {A._sh(dser):+.3f}") + tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(f_, A.get(a, tfx), a)) + ["turnover_per_year"] for a in ASSETS]) + print(f" {nm:<16} " + " ".join(line) + f" | turnover {tt:.0f}x/anno") + + # -- 8. LA FAMIGLIA RISTRETTA ALLE CELLE DAVVERO ADATTIVE ----------------------------- + hr("8. GATE ONESTO SULLA SOLA FAMIGLIA ADATTIVA (la domanda della missione, isolata)") + if not adatt_rows: + print(" NESSUNA cella della famiglia e' davvero adattiva -> la domanda non e'") + print(" misurabile su questa famiglia: ogni 'vincitore' e' un orizzonte costante.") + else: + best_ad = max(adatt_rows, key=lambda r: r["insample_sharpe"]) + print(f" celle adattive: {len(adatt_rows)} miglior IN-SAMPLE: tf={best_ad['tf']} " + f"{best_ad['params']} IS {best_ad['insample_sharpe']} FULL {best_ad['full_sharpe']}") + fn_ad = factory(tf=best_ad["tf"], **best_ad["params"]) + d_ad = daily_from_target(fn_ad, best_ad["tf"]) + sm_ad = A.study_marginal("ADAPT-H(solo adattive)", fn_ad, tf=best_ad["tf"]) + dsr_ad, sr0_ad = A.deflated_sharpe(A._sh(d_ad), + [r["full_sharpe"] for r in adatt_rows], d_ad) + print(f" marginal={sm_ad['marginal_verdict']} earns_slot={sm_ad['earns_slot']} " + f"corr->TP01 {sm_ad['marginal'].get('corr_full')}") + print(f" deflated-Sharpe (N={len(adatt_rows)}) = {dsr_ad:.3f} " + f"{'PASS' if dsr_ad >= 0.95 else 'FAIL'} (null max atteso {sr0_ad:.3f})") + JA = pd.concat({"C": d_ad, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna() + for lab, sl in (("FULL", JA), ("HOLD-OUT", JA[JA.index >= HOLDOUT])): + if len(sl) < 30: + continue + print(f" {lab:<9} adattiva-vera Sh {A._sh(sl['C']):+.3f} | miglior costante " + f"{A._sh(sl['K']):+.3f} | TP01 {A._sh(sl['B']):+.3f}") + rows3 = paired_by_year(d_ad, d_const) + d3 = [r["d_sh"] for r in rows3] + print(f" vs miglior costante, per anno: dSh>0 in {sum(1 for x in d3 if x>0)}/{len(d3)}" + f" mediana {np.median(d3):+.2f}") + + # -- 9. L'OTTIMO COSTANTE E' AL BORDO: si estende la banda ---------------------------- + hr("9. L'OTTIMO COSTANTE CADE SUL BORDO — plateau o scivolata verso il rumore?") + print(" un argmax al bordo della griglia non e' un ottimo: e' il punto in cui si e' smesso") + print(" di guardare. Si estende SOTTO i 20 giorni (8 trial in piu', contati al rialzo).") + ext = sorted(set(CONST_L) | set(CONST_L_EXT)) + tab: dict = {} + for tfx in TFS: + print(f"\n --- TF {tfx}, blend triple (L/3, L, 2L) ---") + print(f" {'L(g)':>5} {'orizzonti':>16} {'IS':>7} {'FULL':>7} {'HOLD':>7} " + f"{'maxDD':>7} {'CAGR':>7} {'turn/a':>7} {'Sh@0.30%RT':>11}") + for L in ext: + fnx = const_factory(tfx, L, "triple") + dx = daily_from_target(fnx, tfx) + dx_h = dx[dx.index >= HOLDOUT] + dx_i = dx[dx.index < HOLDOUT] + d030 = A.candidate_daily(fnx, tf=tfx, fee_side=0.0015) + tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(fnx, A.get(a, tfx), a)) + ["turnover_per_year"] for a in ASSETS]) + tag = " <== TP01" if L == 90 else "" + tab[(tfx, L)] = dict(IS=A._sh(dx_i), FULL=A._sh(dx), HOLD=A._sh(dx_h)) + print(f" {L:>5} {f'{L//3}/{L}/{2*L}':>16} {A._sh(dx_i):>7.3f} {A._sh(dx):>7.3f} " + f"{A._sh(dx_h):>7.3f} {_dd(dx)*100:>6.2f}% {_cagr(dx)*100:>6.2f}% " + f"{tt:>7.0f} {A._sh(d030):>11.3f}{tag}") + + # --- e' una CURVA con un ottimo, o un crinale di RUMORE? -------------------------- + print("\n RISOLUZIONE della griglia (regola 07/08: un criterio che decide su una frazione") + print(" dello spread della griglia e' rumore anche quando 'passa'):") + for tfx in TFS: + v = [tab[(tfx, L)]["IS"] for L in ext] + jump = float(np.median(np.abs(np.diff(v)))) + arg = ext[int(np.argmax(v))] + arg_orig = max(CONST_L, key=lambda L: tab[(tfx, L)]["IS"]) + print(f" {tfx:>3}: argmax IS con la griglia ORIGINALE L={arg_orig}g -> con la griglia " + f"ESTESA L={arg}g ({'SPOSTATO' if arg != arg_orig else 'stabile'})") + print(f" salto IS mediano fra L ADIACENTI {jump:+.3f} contro il vantaggio del " + f"vincitore su TP01 {max(v) - tab[(tfx, 90)]['IS']:+.3f} " + f"(rapporto {jump / max(1e-9, max(v) - tab[(tfx, 90)]['IS']):.2f})") + allc = [(tf, L) for tf in TFS for L in ext] + for met in ("IS", "FULL", "HOLD"): + vals = sorted((tab[k][met] for k in allc), reverse=True) + r1d = 1 + sum(1 for x in vals if x > tab[("1d", 90)][met]) + print(f" rango di TP01 (1d, L=90g) fra le {len(allc)} celle su {met}: " + f"{r1d}/{len(allc)} (suo {tab[('1d',90)][met]:+.3f}, migliore {vals[0]:+.3f})") + + hr("FINE") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_dealer_gamma.py b/scripts/research/r0822_dealer_gamma.py new file mode 100644 index 0000000..e91bd44 --- /dev/null +++ b/scripts/research/r0822_dealer_gamma.py @@ -0,0 +1,689 @@ +"""r0822 — DEALER GAMMA: il regime di gamma dei dealer separa mean-reversion da trend su BTC/ETH? + +Filone dell'ondata 2026-08-22. Dato mai usato dal progetto: `data/raw/cb_market_snapshots.parquet` +(ereditato da cerbero-bite, dismesso il 30/07) con `dealer_net_gamma`, `gamma_flip_level`, +`oi_delta_pct_4h`, `liquidation_*_risk`. + +IPOTESI DI MERCATO CLASSICA (due gambe, vanno confermate ENTRAMBE): + (A) dealer LONG gamma -> l'hedging SOPPRIME la vol realizzata; SHORT gamma -> la AMPLIFICA; + (B) dealer LONG gamma -> il prezzo MEAN-REVERTE; SHORT gamma -> il prezzo TRENDA. +La gamba tradeable e' la (B): e' quella che diventerebbe un gate di regime sopra TP01/SKH01. +Aspettativa dichiarata PRIMA di misurare: (A) plausibile ma confusa con il livello di vol; +(B) improbabile su 3 mesi, e comunque a rischio "TP01 travestito". + +ORDINE OBBLIGATO: prima la validazione del DATO (non e' certificato, la fonte non esiste piu'), +poi il FATTO condizionale, e solo dopo — se il fatto c'e' — il gate. + +Girare: nice -n 19 timeout 900 uv run python scripts/research/r0822_dealer_gamma.py +""" +from __future__ import annotations + +import glob +import sys +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +import altlib as A # noqa: E402 + +warnings.filterwarnings("ignore") +pd.set_option("display.width", 220) + +RAW = ROOT / "data" / "raw" +SNAP_F = RAW / "cb_market_snapshots.parquet" +CHAIN_D = RAW / "cb_chain" +SEED = 20260822 +ASSETS = ("BTC", "ETH") +HOUR_YEAR = 24 * 365.0 +ANN = np.sqrt(HOUR_YEAR) * 100.0 # da dev.std. oraria a vol annua in punti percentuali + +# La cadenza a 15 min comincia qui: prima ci sono 37 punti GIORNALIERI (vedi sezione 1). +HF_START = pd.Timestamp("2026-05-01 15:00", tz="UTC") + +# ------------------------------------------------------------------ conteggio dei trial +# Ogni cella/statistica valutata viene contata qui, AL RIALZO (regola: il conteggio dei trial +# e' l'unico posto dove barare e' indolore e invisibile — 30/07). Il verdetto stampa la +# ripartizione DERIVATA da questo dizionario, non una somma scritta a mano. +COUNT: dict[str, int] = dict(ricostruzione=0, sma=0, regressioni=0, vol_condizionata=0, + variance_ratio=0, momentum=0, gate=0, delevering=0) +NEFF: dict[str, float] = {} # ampiezza effettiva per asset, misurata in sezione 1e + + +def hr(t: str) -> None: + print("\n" + "=" * 96 + f"\n{t}\n" + "=" * 96) + + +def sh_d(x: pd.Series) -> float: + x = pd.Series(x).dropna() + return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0 + + +def dd_d(x: pd.Series) -> float: + e = (1.0 + pd.Series(x).dropna()).cumprod() + return float((e / e.cummax() - 1.0).min()) if len(e) else 0.0 + + +# ------------------------------------------------------------------ caricamento +def load_snap() -> pd.DataFrame: + d = pd.read_parquet(SNAP_F).sort_values("ts") + d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]") + return d + + +def load_bars(asset: str) -> pd.DataFrame: + px = A.get(asset, "1h").copy() + px["dt"] = pd.to_datetime(px["timestamp"], unit="ms", utc=True).astype("datetime64[ns, UTC]") + px = px[["dt", "close"]].dropna().reset_index(drop=True) + px["r"] = np.log(px["close"]).diff() + return px + + +def joined(asset: str, snap: pd.DataFrame) -> pd.DataFrame: + """Barra oraria + ULTIMO snapshot con ts <= apertura della barra. + + Causale per costruzione: `direction='backward'` sull'apertura della barra, quindi il + regime usato per la barra [t, t+1h) e' noto a t. Tolleranza 2h: se il collettore e' + fermo da piu' di due ore la riga esce, invece di trascinare un valore vecchio. + """ + px = load_bars(asset) + s = (snap[snap["asset"] == asset] + [["ts", "dealer_net_gamma", "gamma_flip_level", "spot", "dvol", "realized_vol_30d"]] + .dropna(subset=["dealer_net_gamma", "gamma_flip_level"])) + m = pd.merge_asof(px.sort_values("dt"), s.sort_values("ts"), + left_on="dt", right_on="ts", direction="backward", + tolerance=pd.Timedelta("2h")) + m = m[m["ts"].notna()].reset_index(drop=True) + # SEGNO CORRETTO (giustificato nella sezione 2): dealer_net_gamma = -GEX + # dealer_net_gamma > 0 <=> GEX < 0 <=> dealer SHORT gamma + m["short_gamma"] = (m["dealer_net_gamma"] > 0).astype(float) + m["above_flip"] = (m["close"] > m["gamma_flip_level"]).astype(float) + m["rvp24"] = np.sqrt((m["r"] ** 2).rolling(24).mean()) + m["rvp168"] = np.sqrt((m["r"] ** 2).rolling(168).mean()) + m["ret168"] = np.log(m["close"] / m["close"].shift(168)) + m["z"] = m["r"] / m["rvp24"].shift(1) + for k in (4, 24): + m[f"rvf{k}"] = np.sqrt((m["r"].shift(-1) ** 2).rolling(k).sum().shift(-(k - 1)) / k) + return m + + +def ols(y, cols): + y = np.asarray(y, float) + n = len(y) + X = np.column_stack([np.ones(n)] + [np.asarray(c, float) for c in cols]) + b, *_ = np.linalg.lstsq(X, y, rcond=None) + res = y - X @ b + s2 = res @ res / (n - X.shape[1]) + se = np.sqrt(np.diag(s2 * np.linalg.pinv(X.T @ X))) + return b, b / se, 1.0 - np.var(res) / np.var(y) + + +def variance_ratio(r, k: int) -> float: + r = np.asarray(pd.Series(r).dropna(), float) + if len(r) < 5 * k: + return np.nan + x = pd.Series(r).rolling(k).sum().dropna().values + return float(np.var(x) / (k * np.var(r))) + + +# ================================================================== 1. IL DATO +def sezione_1_dato(snap: pd.DataFrame) -> dict: + hr("1. VALIDAZIONE DEL DATO — prima del segnale (fonte dismessa, mai certificata)") + out = {} + print(f"file: {SNAP_F.name} righe {len(snap)} {snap.ts.min()} -> {snap.ts.max()}") + + # 1a. cadenza: il file NON e' omogeneo + d = snap[snap.asset == "BTC"] + gaps = d.ts.diff().dt.total_seconds().div(60).round(0).value_counts() + print(f"\n1a. CADENZA (BTC): distribuzione dei salti in minuti {dict(list(gaps.items())[:6])}") + pre = snap[snap.ts < HF_START] + post = snap[snap.ts >= HF_START] + print(f" -> {len(pre)//2} righe/asset a cadenza GIORNALIERA (12:00 UTC) fino al 2026-05-01,") + print(f" {len(post)//2} righe/asset a 15 min dopo. Il 'file da 4 mesi' e' 90 GIORNI utili.") + out["n_hf_per_asset"] = len(post) // 2 + + for a in ASSETS: + x = post[post.asset == a] + hrs = x.ts.dt.floor("h").nunique() + span = (x.ts.max().floor("h") - x.ts.min().floor("h")).total_seconds() / 3600 + 1 + print(f" {a}: ore distinte {hrs} su {span:.0f} di span = copertura {100*hrs/span:.2f}%" + f" (buchi > 20 min: {int((x.ts.diff().dt.total_seconds()/60 > 20).sum())})") + + # 1b. colonne MORTE — una colonna presente non e' un dato presente + print("\n1b. COLONNE MORTE (regola 8 del brief: contare cio' che VARIA, non le righe)") + for c in ["liquidation_long_risk", "liquidation_short_risk", "macro_days_to_event"]: + v = snap[c].dropna() + print(f" {c:26s} non-nulli {len(v):6d} ({100*len(v)/len(snap):5.1f}%) " + f"valori distinti {v.nunique()} {'*** COSTANTE = ZERO INFORMAZIONE ***' if v.nunique() <= 1 else ''}") + out["dead_cols"] = [c for c in ["liquidation_long_risk", "liquidation_short_risk"] + if snap[c].dropna().nunique() <= 1] + + # 1c. dealer_net_gamma e gamma_flip_level: si muovono? sono plausibili? + print("\n1c. PLAUSIBILITA' delle due colonne di regime (finestra 15 min)") + for a in ASSETS: + x = post[post.asset == a] + g, f = x.dealer_net_gamma, x.gamma_flip_level + rel = x.spot / f - 1.0 + print(f" {a} dealer_net_gamma: nan {100*g.isna().mean():4.1f}% frac>0 {(g>0).mean():.3f} " + f"consecutivi identici {(g.diff()==0).mean():.4f} -> si muove") + print(f" {a} gamma_flip_level: nan {100*f.isna().mean():4.1f}% " + f"consecutivi identici {(f.diff()==0).mean():.4f} " + f"(spot/flip-1) p05 {rel.quantile(.05):+.1%} med {rel.median():+.1%} p95 {rel.quantile(.95):+.1%}") + print(" -> BTC: il flip e' CONGELATO nel 61% dei passi e sta fino a +42% SOTTO lo spot al p95.") + print(" Un 'livello di flip' a 42% dallo spot non e' un livello di flip: e' degenere.") + + # 1d. LE DUE COLONNE SI CONTRADDICONO + print("\n1d. CONTRADDIZIONE INTERNA fra le due colonne della STESSA fonte") + for a in ASSETS: + x = post[post.asset == a].dropna(subset=["dealer_net_gamma", "gamma_flip_level", "spot"]) + c = np.corrcoef((x.dealer_net_gamma > 0).astype(float), (x.spot > x.gamma_flip_level).astype(float))[0, 1] + print(f" {a}: corr( dng>0 , spot>flip ) = {c:+.3f}") + print(" Nella convenzione classica dealer LONG gamma sta SOPRA il flip: le due colonne") + print(" dovrebbero correlare POSITIVAMENTE. Correlano NEGATIVAMENTE -> una delle due ha il") + print(" segno invertito. Quale, lo decide la sezione 2 (ricostruzione dalla catena), non io.") + + # 1e. quante EPISODI di regime ci sono davvero -> il numero che conta + print("\n1e. AMPIEZZA EFFETTIVA — 2.160 ore NON sono 2.160 osservazioni") + for a in ASSETS: + x = post[post.asset == a].dropna(subset=["dealer_net_gamma"]) + lab = (x.dealer_net_gamma > 0).astype(int).values + runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size() * 0.25 # ore + hourly = pd.Series(lab).iloc[::4] + rho = float(hourly.autocorr(1)) + neff = len(hourly) * (1 - rho) / (1 + rho) + print(f" {a}: {len(runs)} episodi; {int((runs>72).sum())} durano >72h e coprono il " + f"{100*runs[runs>72].sum()/runs.sum():.0f}% del tempo") + print(f" AR(1) orario del regime {rho:.4f} -> n_eff ~ {neff:.0f} su {len(hourly)} ore") + out[f"neff_{a}"] = NEFF[a] = neff + print(" -> l'errore standard onesto di ogni statistica condizionata al regime va moltiplicato") + print(f" per ~sqrt(2130/n_eff) = 6x (BTC) - 9x (ETH). E' IL numero della sezione 3.") + return out + + +# ================================================================== 2. SEGNO E RICOSTRUIBILITA' +def sezione_2_ricostruzione(snap: pd.DataFrame) -> dict: + hr("2. DA DOVE VIENE `dealer_net_gamma`, E POSSIAMO RIFARLO OGGI?") + out = {} + print("La domanda non e' accademica: bite e' dismesso, la serie finisce il 2026-07-30. Se il") + print("segnale non e' ricostruibile dalla catena che raccogliamo NOI, il lead e' morto alla nascita.") + + arch = CHAIN_D / "bite_archive.parquet" + d = pd.read_parquet(arch, columns=["ts", "asset", "option_type", "strike", "gamma", + "open_interest", "source"]) + d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]") + d["tsf"] = d["ts"].dt.floor("15min") + per_snap = d.groupby(["source", "asset", "tsf"]).size().rename("n").reset_index() + print("\n2a. STRIKE PER SNAPSHOT nell'archivio ereditato (mediana):") + print(per_snap.groupby(["source", "asset"])["n"].median().to_string()) + print(" -> `bite:live` ha ~16-18 strike: e' la manciata che serviva al suo motore VRP, NON") + print(" una catena. Solo `bite:research` (~280-320 strike) e' ricostruibile. E parte dal") + print(" 2026-06-09, non dal 05-01.") + + # spot: l'archivio ha underlying_price 100% NaN -> lo prendiamo dallo snapshot (stessa fonte) + d = d[(d["source"] == "bite:research")].dropna(subset=["gamma", "open_interest"]) + sp = (snap[["asset", "spot", "dealer_net_gamma", "gamma_flip_level"]] + .assign(tsf=snap["ts"].dt.floor("15min")) + .dropna(subset=["spot"]).drop_duplicates(["asset", "tsf"])) + d = d.merge(sp[["asset", "tsf", "spot"]], on=["asset", "tsf"], how="inner") + print(f"\n (l'archivio ha underlying_price 100% NaN -> spot preso dallo snapshot, stessa fonte)") + + sgn = np.where(d["option_type"].values == "C", 1.0, -1.0) + S = d["spot"].values + G = d["gamma"].values * d["open_interest"].values + d["gex_std"] = G * S * S * 0.01 * sgn # GEX da manuale: dollar-gamma per 1%, call +, put - + d["gex_all"] = G * S * S * 0.01 # senza distinzione call/put + d["gex_nos"] = G * sgn # senza scala S^2 + d["gex_S1"] = G * S * sgn # scala lineare in S + agg = (d.groupby(["asset", "tsf"]) + .agg(**{c: (c, "sum") for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]}, + n=("gamma", "size")).reset_index()) + + print("\n2b. LA RICOSTRUZIONE (catena near-full `bite:research` vs la colonna di bite)") + for a in ASSETS: + j = (agg[agg.asset == a].merge(sp[sp.asset == a][["tsf", "dealer_net_gamma"]], on="tsf") + .dropna(subset=["dealer_net_gamma"])) + print(f" {a}: n={len(j)} snapshot, ~{j.n.median():.0f} strike ciascuno") + for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]: + COUNT["ricostruzione"] += 1 + rp = np.corrcoef(j[c], j.dealer_net_gamma)[0, 1] + rs = j[c].corr(j.dealer_net_gamma, method="spearman") + sa = float(np.mean((j[c] > 0) == (j.dealer_net_gamma > 0))) + sl, ic = np.polyfit(j[c], j.dealer_net_gamma, 1) + print(f" {c:8s} corr {rp:+.3f} rank {rs:+.3f} accordo-di-segno {sa:.3f}" + f" fit dng = {sl:+.3f}*GEX {ic:+.2e}") + best = j + out[f"corr_{a}"] = float(np.corrcoef(best.gex_std, best.dealer_net_gamma)[0, 1]) + out[f"slope_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[0]) + out[f"intc_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[1]) + print("\n RISULTATO: il GEX da manuale riproduce la colonna di bite a corr -0.95 (BTC) /") + print(" -0.89 (ETH), con accordo-di-segno del 10%/8%. Cioe':") + print(" dealer_net_gamma ~ -0.7 * GEX_standard -> LA COLONNA E' IL GEX COL SEGNO INVERTITO.") + print(" `dealer_net_gamma > 0` significa dealer SHORT gamma, non long. Il nome mente.") + print(" Questo CHIUDE la contraddizione della sezione 1d: e' la colonna dng ad avere il segno") + print(" girato, il gamma_flip_level e' nella convenzione classica. Da qui in poi il codice usa") + print(" `short_gamma = dealer_net_gamma > 0`.") + + print("\n2c. LA SOGLIA NON E' PORTABILE — e questo e' il punto che uccide il riuso diretto") + for a in ASSETS: + z = -out[f"intc_{a}"] / out[f"slope_{a}"] + print(f" {a}: dng=0 corrisponde a GEX = {z:+.3e}, non a GEX = 0.") + print(" Le due serie hanno zeri DIVERSI (universi diversi: il nostro collettore filtra OI>=100") + print(" e scadenze <=95g). Trasportare 'la soglia zero' da una all'altra e' un cambio di") + print(" definizione mascherato da continuita'. Ricalibrarla sui 90 giorni sarebbe selezione.") + + print("\n2d. IL SEGNALE E' VIVO OGGI? (raccolta nostra, `pyg`, oraria)") + fs = sorted(glob.glob(str(CHAIN_D / "2026-08-*.parquet"))) + if fs: + o = pd.concat([pd.read_parquet(f, columns=["ts", "asset", "option_type", "gamma", + "open_interest", "underlying_price", + "quote_status"]) for f in fs], ignore_index=True) + o["ts"] = pd.to_datetime(o["ts"], utc=True) + o = o.dropna(subset=["gamma", "open_interest", "underlying_price"]) + s2 = np.where(o["option_type"].values == "C", 1.0, -1.0) + o["gex"] = (o["gamma"].values * o["open_interest"].values + * o["underlying_price"].values ** 2 * 0.01 * s2) + g = (o.groupby(["asset", o["ts"].dt.floor("h")]) + .agg(gex=("gex", "sum"), n=("gamma", "size")).reset_index()) + for a in ASSETS: + x = g[g.asset == a] + print(f" {a}: {len(x)} ore dal {x.ts.min():%Y-%m-%d} al {x.ts.max():%Y-%m-%d %H:%M}, " + f"~{x.n.median():.0f} strike/ora, quote ok {100*(o[o.asset==a].quote_status=='ok').mean():.1f}%") + print(f" GEX medio {x.gex.mean():+.3e} frac GEX>0 (dealer LONG gamma) {(x.gex>0).mean():.3f}" + f" ultimo {x.gex.iloc[-1]:+.3e}") + print(" -> RICOSTRUIBILE, oraria, viva, con MEGLIO del dato originale (segno giusto e") + print(" `quote_status` esplicito). Il lead e' inseguibile: il dato non e' il vincolo.") + print(" -> ma nota: ad agosto il GEX e' >0 nell'88-93% delle ore. Il regime 'short gamma'") + print(" compare a sprazzi: la variabile non varia quasi mai, e questo e' un problema") + print(" di potenza che il tempo aggiusta LENTAMENTE.") + else: + print(" (nessun file 2026-08-* nella raccolta: salto)") + return out + + +# ================================================================== 3. IL FATTO (A): la vol +def sezione_3_fatto_vol(M: dict, snap: pd.DataFrame, rec: dict) -> dict: + hr("3. GAMBA (A) DELL'IPOTESI — short gamma amplifica la vol realizzata?") + out = {} + rng = np.random.default_rng(SEED) + print("Misura il FATTO condizionale, non una strategia. Se un effetto e' vero si vede qui.") + + print("\n3a. VOL REALIZZATA FORWARD, condizionata al regime (segno corretto in sezione 2)") + for a in ASSETS: + m = M[a] + for c in ["rvf4", "rvf24"]: + g = m.groupby("short_gamma")[c].mean() * ANN + COUNT["vol_condizionata"] += 1 + print(f" {a} {c}: LONG gamma {g.get(0.0, np.nan):5.1f}% SHORT gamma {g.get(1.0, np.nan):5.1f}%" + f" spread {g.get(1.0,0)-g.get(0.0,0):+5.1f} pp") + print(" Direzione CONFORME al manuale: short gamma -> vol forward piu' alta. Prima gamba OK.") + + print("\n3b. MA: il regime coincide quasi col MESE. Scomposizione (regola: un contributo si") + print(" scompone per periodo PRIMA di crederci — lezione SOL, 22/08)") + for a in ASSETS: + m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy() + m["mo"] = m["dt"].dt.strftime("%Y-%m") + obs = (m.rvf24[m.short_gamma == 1].mean() - m.rvf24[m.short_gamma == 0].mean()) * ANN + print(f" {a} spread pieno {obs:+.1f} pp") + for mo, gg in m.groupby("mo"): + ns, nl = int(gg.short_gamma.sum()), int((1 - gg.short_gamma).sum()) + sp = (gg.rvf24[gg.short_gamma == 1].mean() - gg.rvf24[gg.short_gamma == 0].mean()) * ANN + print(f" {mo}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}" + f" {'<-- mese quasi interamente in UN regime' if min(ns,nl) < 100 else ''}") + out[f"spread_{a}"] = obs + print(" -> Maggio e Luglio sono mesi LONG-gamma, Giugno e' un mese SHORT-gamma. Il confronto") + print(" 'short vs long' e', in pratica, 'giugno vs maggio+luglio': UN confronto, non 2.136.") + + print("\n3c. NULL a spostamento circolare (conserva l\'autocorrelazione di ENTRAMBE le serie,") + print(" rompe solo l\'allineamento) — e il suo LIMITE DI RISOLUZIONE") + for a in ASSETS: + m = M[a].dropna(subset=["rvf24", "short_gamma"]) + lab, y = m.short_gamma.values, m.rvf24.values + n = len(y) + obs = (y[lab == 1].mean() - y[lab == 0].mean()) * ANN + null = np.empty(2000) + for i in range(2000): + L = np.roll(lab, rng.integers(24, n - 24)) + null[i] = (y[L == 1].mean() - y[L == 0].mean()) * ANN + p = float((np.abs(null) >= abs(obs)).mean()) + runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size() + long_runs = runs[runs > 24] + n_align = n / max(long_runs.median(), 1.0) + print(f" {a}: osservato {obs:+.1f} pp | null media {null.mean():+.2f} sd {null.std():.2f}" + f" | p empirico (2 code) {p:.4f}") + print(f" MA lo spostamento circolare di una serie con episodi lunghi ~{long_runs.median():.0f}h") + print(f" produce solo ~{n_align:.0f} allineamenti DISTINTI: la risoluzione del p non e\'") + print(f" 1/2000, e\' ~1/{n_align:.0f}. Un p 'zero' su {n_align:.0f} configurazioni non e\' 1e-4.") + out[f"p_{a}"] = p + + print("\n3c-bis. LA PROVA CHE COSTA MENO DI TUTTE: si toglie GIUGNO (l\'unico mese short-gamma)") + for a in ASSETS: + m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy() + m["mo"] = m["dt"].dt.strftime("%Y-%m") + for drop in (None, "2026-06"): + g = m if drop is None else m[m.mo != drop] + ns, nl = int(g.short_gamma.sum()), int((1 - g.short_gamma).sum()) + sp = (g.rvf24[g.short_gamma == 1].mean() - g.rvf24[g.short_gamma == 0].mean()) * ANN + tag = "campione pieno" if drop is None else "senza giugno" + print(f" {a} {tag:16s}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}") + print(" -> tolto UN mese su tre restano ~40-130 ore di regime short su 1.400: lo 'spread'") + print(" diventa il confronto fra due manciate di ore. Non e\' un campione, e\' un episodio.") + + print("\n3d. NULL LOCATION-MATCHED per `gamma_flip_level`: livello di opzioni o media mobile?") + for a in ASSETS: + m = M[a] + g = m.groupby("above_flip")["rvf24"].mean() * ANN + sp_gamma = float(g.get(0.0, np.nan) - g.get(1.0, np.nan)) + print(f" {a} GAMMA spot>flip: sotto {g.get(0.0, np.nan):.1f}% sopra {g.get(1.0, np.nan):.1f}%" + f" spread {sp_gamma:+.1f} pp") + best = None + for nn in (6, 12, 24, 48, 72, 120, 168, 240, 336, 504, 720): + COUNT["sma"] += 1 + sma = m["close"].rolling(nn).mean() + ok = sma.notna() + agree = float((((m["close"] > m["gamma_flip_level"]) == (m["close"] > sma))[ok]).mean()) + gg = m.groupby((m["close"] > sma).astype(float))["rvf24"].mean() * ANN + spread = float(gg.get(0.0, np.nan) - gg.get(1.0, np.nan)) + if best is None or agree > best[1]: + best = (nn, agree, spread) + if best[2] > sp_gamma + 1.0: + verdetto = "il sosia da uno spread PIU GRANDE del livello di opzioni" + elif abs(best[2] - sp_gamma) <= 1.0: + verdetto = "il sosia da lo STESSO spread" + else: + verdetto = "il sosia da uno spread minore" + print(f" miglior sosia: spot>SMA({best[0]}h) -> accordo di regime {best[1]:.3f}, " + f"spread {best[2]:+.1f} pp ({verdetto})") + out[f"sma_agree_{a}"], out[f"sma_span_{a}"] = best[1], best[0] + out[f"sma_spread_{a}"], out[f"flip_spread_{a}"] = best[2], sp_gamma + print(" LETTURA, asset per asset (i due casi NON dicono la stessa cosa):") + print(" BTC: accordo 0.68, e la media mobile NUDA da' uno spread uguale o maggiore") + print(" (+8.8 contro +7.8 pp) -> il livello di opzioni non aggiunge nulla a una SMA.") + print(" ETH: accordo 0.96 -> `gamma_flip_level` E' una media mobile a 30 giorni con un") + print(" altro nome. Il suo spread e' piu' grande (+16.6 vs +10.1 pp), ma la") + print(" differenza vive tutta nel 4% di ore in cui i due regimi divergono: 4% di") + print(" 2.160 ore dentro un campione da 3 episodi non e' evidenza, e non e' testabile.") + print(" In nessuno dei due casi c'e' un contenuto 'opzioni' isolabile: e' la trappola 2 del") + print(" brief (TP01 travestito). `gamma_flip_level` non e' usabile come regime.") + + print("\n3e. IL REGIME AGGIUNGE SOPRA CIO' CHE GIA' SAPPIAMO? (log rv fwd 24h ~ vol passata + trend)") + print(" t_eff = t / sqrt(24) corregge la sovrapposizione della finestra a 24h. NON corregge") + print(" l'ampiezza effettiva (sezione 1e: n_eff 24-53 su 2.130): un t_eff di +2.3 su 6-11") + print(" episodi indipendenti resta un t che conta episodi come se fossero ore.") + for a in ASSETS: + m = M[a] + d = m.dropna(subset=["rvf24", "rvp24", "rvp168", "ret168", "dvol", "short_gamma", "above_flip"]) + d = d[d.rvf24 > 0] + y = np.log(d.rvf24) + base = [np.log(d.rvp24), np.log(d.rvp168), d.ret168] + d = d.assign(sma720=(d["close"] > d["close"].rolling(720).mean()).astype(float).fillna(0.0)) + print(f" {a} (n={len(d)}) corr(short_gamma, DVOL) = " + f"{np.corrcoef(d.short_gamma, d.dvol)[0,1]:+.3f}") + for lab, extra in [("base: vol+trend", []), + (" + spot>SMA720", [d.sma720]), + (" + spot>flip", [d.above_flip]), + (" + short_gamma", [d.short_gamma]), + (" + DVOL", [np.log(d.dvol)]), + (" + DVOL + short_gamma", [np.log(d.dvol), d.short_gamma])]: + COUNT["regressioni"] += 1 + b, t, r2 = ols(y, base + extra) + print(f" {lab:24s} R2 {r2:.3f} ultimo coef {b[-1]:+.3f} t {t[-1]:+6.2f}" + f" t_eff {t[-1]/np.sqrt(24):+5.2f}") + print(" -> BTC: `short_gamma` da solo spiega quanto DVOL da solo (R2 0.271 vs 0.272) e i due") + print(" insieme fanno 0.312 -> l'informazione e' in larga parte GIA' NEL DVOL, che il") + print(" progetto ha gia' provato come modulatore di TP01 il 26/06: era de-levering puro.") + print(" ETH: t_eff ~ +1.0 = rumore.") + return out + + +# ================================================================== 4. IL FATTO (B): MR vs trend +def sezione_4_fatto_mrtrend(M: dict) -> dict: + hr("4. GAMBA (B) DELL'IPOTESI — la gamba TRADEABILE: short gamma = trend, long gamma = MR?") + out = {} + print("Questa e' la gamba che diventerebbe un gate. La (A) e' solo il contorno.") + + print("\n4a. VARIANCE RATIO per regime. VR<1 = mean-reversion, VR>1 = trend.") + print(" La versione 'z' standardizza ogni ritorno per la vol delle 24h precedenti: senza,") + print(" il clustering di volatilita' DENTRO un regime gonfia il VR e si legge come trend.") + for a in ASSETS: + m = M[a] + for lab, col in [("grezzo", "r"), ("z-std", "z")]: + L = {k: variance_ratio(m[col][m.short_gamma == 0], k) for k in (2, 4, 12, 24)} + S = {k: variance_ratio(m[col][m.short_gamma == 1], k) for k in (2, 4, 12, 24)} + COUNT["variance_ratio"] += 4 + print(f" {a} {lab}: LONG " + " ".join(f"VR{k}={L[k]:.3f}" for k in (2, 4, 12, 24))) + print(f" {a} {lab}: SHORT " + " ".join(f"VR{k}={S[k]:.3f}" for k in (2, 4, 12, 24))) + if lab == "z-std": + out[f"vr24_long_{a}"], out[f"vr24_short_{a}"] = L[24], S[24] + print("\n LETTURA (versione z a 24h, l'unica scale-free):") + for a in ASSETS: + vl, vs = out[f"vr24_long_{a}"], out[f"vr24_short_{a}"] + if abs(vl - vs) < 0.05: + diag = "NESSUNA separazione (differenza < 0.05)" + elif vs > vl: + diag = "separazione NEL VERSO dell'ipotesi (short gamma piu' trendante)" + else: + diag = "separazione ROVESCIATA: e' il regime LONG gamma a trendare" + print(f" {a}: LONG {vl:.3f} vs SHORT {vs:.3f} -> {diag}") + print(" Due asset, due risposte diverse, e quella che separa lo fa al contrario. La gamba (B)") + print(" non e' 'debole': e' INCOERENTE. Con la gamba (A) confermata, questo e' il punto in cui") + print(" l'ipotesi si rompe — perche' e' la (B) che si traderebbe.") + + print("\n4b. La forma tradeable: payoff di momentum per regime, sgn(ritorno passato L) x ritorno") + print(" futuro H. Sharpe ANNUALIZZATO LORDO (nessuna fee: se non regge lordo e' finita).") + Ls, Hs = (1, 4, 12, 24), (1, 4, 12, 24) + cells = [] + for a in ASSETS: + m = M[a] + print(f" {a} " + " ".join(f"H={h:<2d}" for h in Hs) + " (L=long-gamma, S=short-gamma)") + for L in Ls: + past = np.log(m["close"] / m["close"].shift(L)) + row = [] + for H in Hs: + fwd = np.log(m["close"].shift(-H) / m["close"]) + pay = np.sign(past) * fwd + for lab, mask in [("L", m.short_gamma == 0), ("S", m.short_gamma == 1)]: + v = pay[mask].dropna() + s = float(v.mean() / v.std() * np.sqrt(HOUR_YEAR / H)) if len(v) > 30 and v.std() > 0 else np.nan + COUNT["momentum"] += 1 + cells.append((a, L, H, lab, s, v)) + row.append(f"{lab}{s:+.2f}") + print(f" L={L:2d}h " + " ".join(row)) + se = np.sqrt(HOUR_YEAR / len(m)) + neff = NEFF.get(a, len(m)) + print(f" [errore standard di UNA cella a H=1h: +/-{se:.2f} di Sharpe su {len(m)} ore.") + print(f" Con l'ampiezza effettiva della sezione 1e (n_eff={neff:.0f}) diventa" + f" +/-{se*np.sqrt(len(m)/neff):.1f}.") + print(" Ogni numero della tabella qui sopra e' dentro il proprio errore standard.]") + out["cells"] = cells + return out + + +# ================================================================== 5. IL GATE SUL LIBRO +def sezione_5_gate(snap: pd.DataFrame, fatti4: dict) -> dict: + hr("5. IL GATE SUL LIBRO — costruito e misurato, con la potenza dichiarata") + out = {} + print("La sezione 4 ha gia' refutato la gamba tradeable. Il gate si costruisce lo stesso, perche'") + print("un 'non funziona' misurato vale piu' di un 'non l'ho provato'.") + + B = A.tp01_baseline_daily() + reg = {} + for a in ASSETS: + s = (snap[(snap.asset == a) & (snap.ts >= HF_START)] + .dropna(subset=["dealer_net_gamma"])[["ts", "dealer_net_gamma"]] + .set_index("ts").resample("1D").last()) + # ultimo snapshot del giorno d -> usato per il giorno d+1: causale per costruzione + reg[a] = (s["dealer_net_gamma"] > 0).astype(float).shift(1) + R = pd.concat(reg, axis=1).mean(axis=1).dropna() + J = pd.concat({"B": B, "S": R}, axis=1, join="inner").dropna() + b = J["B"] + n = len(J) + se = np.sqrt(365.25 / n) + print(f"\n5a. FINESTRA: {J.index.min():%Y-%m-%d} -> {J.index.max():%Y-%m-%d}, {n} giorni.") + print(f" TP01 (baseline 50/50) su questa finestra: Sharpe {sh_d(b):+.2f} maxDD {dd_d(b):.2%}" + f" ritorno {(1+b).prod()-1:+.2%}") + print(f" ERRORE STANDARD DELLO SHARPE su {n} giorni: +/-{se:.2f}.") + print(" Cioe': su questa finestra TP01 e' indistinguibile da qualunque cosa fra -4 e 0. Il") + print(" libro non e' misurabile qui, e nessun gate lo sara'. E' il vincolo, non un dettaglio.") + print(f" (per giunta TP01 e' quasi FLAT: ritorno lordo {100*(1+b).prod()-100:+.2f}% in 3 mesi)") + out["n_days"], out["se_sharpe"] = n, se + + print("\n5b. QUATTRO gate dichiarati (2 polarita' x 2 intensita'), + il null del de-levering") + variants = { + "classico SHORT->1.5x LONG->0.5x": (0.5, 1.5), + "classico mite SHORT->1.25 LONG->0.75": (0.75, 1.25), + "INVERSO SHORT->0.5x LONG->1.5x": (1.5, 0.5), + "risk-off in SHORT (1.0 / 0.5)": (1.0, 0.5), + } + rows = [] + for name, (lo, hi) in variants.items(): + x = b * (lo + (hi - lo) * J["S"]) + COUNT["gate"] += 1 + rows.append((name, sh_d(x), dd_d(x))) + print(f" {name:38s} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}") + print("\n NULL DEL DE-LEVERING (obbligatorio su ogni claim di DD — 5 occorrenze nel progetto):") + for k in (0.5, 0.6, 0.7, 0.8, 0.9, 1.0): + COUNT["delevering"] += 1 + x = b * k + print(f" baseline x{k:.1f} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}") + best_dd = min(rows, key=lambda r: abs(r[2])) + print(f"\n -> il gate 'classico' porta il maxDD da {dd_d(b):.2%} a {rows[0][2]:.2%} con Sharpe" + f" {rows[0][1]:+.2f} (peggiore del baseline {sh_d(b):+.2f});") + print(f" il semplice `baseline x0.6` fa maxDD {dd_d(b*0.6):.2%} a Sharpe {sh_d(b*0.6):+.2f}.") + print(" NULL DE-LEVERING: **FALLITO**. Il gate e' meno leva con passaggi in piu'.") + out["null_delevering"] = "FALLITO" + + print("\n5c. `marginal_vs_tp01` sullo stream INCREMENTALE del gate (cio' che il gate aggiunge)") + ov = (b * (0.5 + 1.0 * J["S"])) - b + try: + rep = A.marginal_vs_tp01(ov) + for k in ("marginal_verdict", "n_days", "n_hold_days", "corr_full", "cand_full_sharpe", + "beta_to_tp01", "reason"): + if k in rep: + print(f" {k:20s} {rep[k]}") + bl = rep.get("blends", {}) + for w, v in bl.items(): + print(f" blend {w}: full {v.get('full')} uplift_full {v.get('uplift_full')}" + f" uplift_hold {v.get('uplift_hold')}") + out["marginal"] = rep.get("marginal_verdict") + except Exception as e: # pragma: no cover + out["marginal"] = f"errore: {e}" + print(f" non girato: {e}") + print(" ATTENZIONE alla lettura: `n_hold_days` == `n_days` — i 90 giorni cadono INTERAMENTE") + print(" dentro l'hold-out di altlib, quindi 'full' e 'hold' sono LA STESSA FINESTRA e i loro") + print(" uplift coincidono (si vede sopra: -0.066 e -0.066). I gate multi-cut e il jackknife") + print(" di `marginal_vs_tp01` girano su una finestra sola.") + print(" Un ADDS qui non sarebbe un ADDS: sarebbe la definizione di selezione") + print(" sull'hold-out. Si riporta il verdetto, non lo si usa per promuovere.") + + print("\n5d. DEFLATED SHARPE sulla cella migliore della griglia della sezione 4") + cells = [c for c in fatti4["cells"] if np.isfinite(c[4])] + best = max(cells, key=lambda c: c[4]) + a, L, H, lab, s_best, v = best + allsr = [c[4] for c in cells] + try: + dsr, null_max = A.deflated_sharpe(s_best, allsr, pd.Series(v.values).dropna().values, + dpy=HOUR_YEAR / H) + reg = "SHORT" if lab == "S" else "LONG" + print(f" cella migliore: {a} L={L}h H={H}h regime={reg} gamma -> Sharpe LORDO {s_best:+.2f}" + f" (su {len(allsr)} trial dichiarati)") + print(f" deflated Sharpe = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)") + print(f" massimo Sharpe ATTESO SOTTO IL NULLO con {len(allsr)} trial = {null_max:+.2f}") + if s_best < null_max: + print(f" -> la cella migliore ({s_best:+.2f}) sta SOTTO cio' che il puro rumore produce") + print(f" ({null_max:+.2f}) cercando {len(allsr)} volte. Non c'e' niente da deflazionare:") + print(" la griglia non ha prodotto nemmeno il massimo che il caso avrebbe prodotto.") + out["dsr"], out["dsr_nullmax"] = float(dsr), float(null_max) + except Exception as e: # pragma: no cover + out["dsr"] = f"errore: {e}" + print(f" non girato: {e}") + + print("\n5e. CAUSALITA'") + print(" Per COSTRUZIONE: ogni riga usa `merge_asof(direction='backward')` sull'APERTURA della") + print(" barra con tolleranza 2h, quindi il regime della barra [t,t+1h) e' noto a t; il gate") + print(" giornaliero usa l'ultimo snapshot del giorno d-1 (`.shift(1)`).") + print(" `A.causality_ok` non e' applicabile: perturba il futuro dei prezzi certificati, ma il") + print(" regime qui non viene dai prezzi — viene da un file esterno che copre 90 giorni su 8") + print(" anni. Girarlo darebbe un PASS privo di potenza sul 97% del campione (stessa forma del") + print(" test a potenza zero corretto il 21/08). Si dichiara l'argomento strutturale, non un") + print(" numero finto.") + out["causality"] = "per costruzione (merge_asof backward + shift(1)); A.causality_ok non applicabile" + return out + + +# ================================================================== main +def main() -> None: + hr("r0822 DEALER-GAMMA — il regime di gamma dei dealer separa MR da trend su BTC/ETH?") + print("IPOTESI: (A) short gamma amplifica la vol; (B) short gamma fa TRENDARE il prezzo, long") + print("gamma lo fa MEAN-REVERTERE -> gate di regime sopra TP01/SKH01.") + print("ATTESA DICHIARATA PRIMA DI MISURARE: (A) plausibile ma confusa col livello di vol;") + print("(B) improbabile su 3 mesi e a rischio 'TP01 travestito'.") + + snap = load_snap() + d1 = sezione_1_dato(snap) + d2 = sezione_2_ricostruzione(snap) + + hf = snap[snap.ts >= HF_START] + M = {a: joined(a, hf) for a in ASSETS} + for a in ASSETS: + print(f"\n[merge] {a}: {len(M[a])} barre orarie con regime, " + f"{M[a].dt.min():%Y-%m-%d} -> {M[a].dt.max():%Y-%m-%d}") + + d3 = sezione_3_fatto_vol(M, hf, d1) + d4 = sezione_4_fatto_mrtrend(M) + d5 = sezione_5_gate(snap, d4) + + hr("VERDETTO") + tot = sum(COUNT.values()) + print(f"GRIGLIA DICHIARATA: {tot} celle/statistiche valutate, contate al rialzo:") + for k, v in COUNT.items(): + print(f" {k:18s} {v:4d}") + print(" Nessuna griglia e' stata ridotta per budget: il vincolo qui e' il DATO, non la macchina.") + print(f" Le {COUNT['momentum']} celle di momentum sono quelle passate al deflated Sharpe (5d).") + print() + print("SCARTATO. La gamba (A) e' confermata nella direzione ma non nella potenza; la gamba (B),") + print("che e' quella che si traderebbe, e' INCOERENTE fra i due asset e rovesciata dove separa.") + print() + print(" 1. Il dato regge meno di quanto dichiara: 15 min solo dal 2026-05-01 (90 giorni, non 4") + print(" mesi); 2 colonne su 16 COSTANTI; `gamma_flip_level` congelato al 61% su BTC.") + print(" 2. `dealer_net_gamma` e' il GEX COL SEGNO INVERTITO (corr -0.95 BTC / -0.89 ETH contro") + print(" la ricostruzione dalla catena). Chi lo usasse col nome che porta leggerebbe ogni") + print(" regime al contrario. -> Questo e' il risultato riusabile di tutto il filone.") + print(" 3. `gamma_flip_level` e' una media mobile: 96% di accordo con spot>SMA(720h) su ETH; su") + print(" BTC (accordo 0.68) la SMA nuda da' uno spread di vol MAGGIORE del livello stesso.") + print(" Niente contenuto di opzioni isolabile. Trappola 2 del brief.") + print(" 4. Ampiezza effettiva: 6-8 episodi di regime, n_eff 24-53 ore su 2.130. Il regime") + print(" coincide col mese (maggio LONG, giugno SHORT, luglio LONG): lo spread di vol e' UN") + print(" confronto fra tre mesi, non 2.136 osservazioni.") + print(" 5. Cio' che resta della gamba (A) e' quasi tutto DVOL (corr 0.66 su BTC), e il DVOL") + print(" come modulatore di TP01 e' gia' stato refutato il 26/06: era de-levering.") + print(" 6. Il gate misurato FALLISCE il null del de-levering (baseline x0.6 lo domina) su una") + print(" finestra dove SE(Sharpe) = +/-2.0 e TP01 e' praticamente flat.") + print(" 7. Deflated Sharpe 0.440 = FAIL, e nel modo piu' netto: la cella migliore delle 64") + print(" (+4.98 LORDO) sta SOTTO il massimo che 64 estrazioni di puro rumore producono") + print(" (+5.12). Non c'e' un candidato da deflazionare, c'e' una griglia sotto il rumore.") + print() + print("NON e' un LEAD: un lead richiede una soglia e una data che abbiano senso, e qui la soglia") + print("non e' nemmeno portabile fra la serie storica e quella che raccogliamo (zeri diversi,") + print("universi diversi). Ricalibrarla sui 90 giorni sarebbe la selezione che il progetto rifiuta.") + print() + print("COSA CONSERVARE (non e' una strategia, e' infrastruttura):") + print(" - il GEX si ricostruisce dalla NOSTRA catena, oraria, viva (518 ore dal 2026-08-01,") + print(" ~209-262 strike, quote ok ~100%), col segno GIUSTO e `quote_status` esplicito;") + print(" - se un giorno lo si riapre, si riapre su GEX ricostruito da noi, mai sulla colonna") + print(" ereditata, e non prima di avere ~30-40 EPISODI di regime (non 30-40 giorni):") + print(" al ritmo osservato (6-8 episodi / 90 giorni) sono ~2 anni, cioe' meta' 2028.") + print() + print("COSA MI SMENTIREBBE: una separazione del variance ratio z-standardizzato nello STESSO") + print("verso sui due asset (short gamma VR>1, long gamma VR<1) su GEX ricostruito da noi, con") + print("almeno 30 episodi di regime indipendenti e uno spread che sopravvive al controllo") + print("location-matched contro spot>SMA. Oggi ho l'opposto: BTC non separa, ETH separa al rovescio.") + print() + print(f"SCRIPT: scripts/research/r0822_dealer_gamma.py") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_flow_squeeze.py b/scripts/research/r0822_flow_squeeze.py new file mode 100644 index 0000000..18e0e36 --- /dev/null +++ b/scripts/research/r0822_flow_squeeze.py @@ -0,0 +1,639 @@ +"""r0822_flow_squeeze.py — filone FLOW-SQUEEZE (ondata 2026-08-22). + +DOMANDA + Il posizionamento affollato (variazione rapida dell'open interest) ACCOPPIATO al rischio + di liquidazione produce un movimento prevedibile — squeeze o cascata — nelle ore successive? + E' l'unico angolo di "flusso" mai chiuso: il filone funding e' chiuso su 3 lati (carry CC01, + time-series, cross-sectional) ma sempre come LIVELLO del costo di carry, mai come PROXY DI + AFFOLLAMENTO accoppiato al rischio di liquidazione. + +PERIMETRO (dichiarato: un altro filone dell'ondata lavora su dealer_net_gamma/gamma_flip_level, + che qui NON si toccano) + data/raw/cb_market_snapshots.parquet -> oi_delta_pct_4h, liquidation_long_risk, + liquidation_short_risk, funding_perp_annualized, funding_cross_annualized, iv_minus_rv + data/raw/hlfund__1h.parquet -> funding orario Hyperliquid (BTC/ETH, ~3 anni) + data/raw/cb_chain/ -> open interest per strike (per la RICOSTRUIBILITA') + +IPOTESI A PRIORI, REGISTRATA PRIMA DI MISURARE + Attesa: NULLA di deployabile. (a) il dataset e' MORTO il 2026-07-30 (progetto esterno + dismesso) e ha ~3 mesi utili -> verdetto massimo LEAD; (b) il progetto ha gia' chiuso il + funding su 3 lati; (c) il sospetto principale e' che un eventuale effetto sull'estremo di + oi_delta sia "il prezzo e' appena crollato" travestito, cioe' momentum/mean-reversion di + prezzo gia' coperta. Cio' che NON mi aspettavo: che meta' dell'ipotesi (il rischio di + liquidazione) fosse **inesistente nel dato**. + +TEST DI POTENZA — DICHIARATO PRIMA DI LEGGERE I RISULTATI (sezione 2) + Il campione utile e' ~90 giorni x 2 asset. La MDE (minimum detectable effect a 2 SE) si + calcola sul numero di finestre NON sovrapposte, non sulle righe orarie. Un effetto sotto la + MDE NON e' un fatto stabilito, comunque venga il p-value di un bootstrap su dati sovrapposti. + +COME SI LEGGE IL RISULTATO + Le sezioni 1 e 5 (validazione e ricostruibilita') valgono piu' delle 2-4: se una colonna e' + costante non ha potenza, e se non sappiamo piu' produrla il segnale e' morto alla nascita. + +USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_flow_squeeze.py +""" +from __future__ import annotations + +import bisect +import glob +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +import altlib as A # noqa: E402 + +RAW = ROOT / "data" / "raw" +SNAP_FILE = RAW / "cb_market_snapshots.parquet" +ASSETS = ("BTC", "ETH") +# finestra utile: prima del 01/05 gli snapshot sono ~1/giorno (vedi sezione 1) +W0 = pd.Timestamp("2026-05-01", tz="UTC") +HOURS = (4, 12, 24) + +# GRIGLIA DICHIARATA (conteggio al RIALZO, incluse le varianti scartate) +GRID_DECLARED = ( + 10 * 3 * 2 # 60 decili di oi_delta x 3 orizzonti x 2 lenti (demean per asset / per mese) + + 4 * 2 # 8 2x2 affollamento (OI alto/basso x funding alto/basso) x 2 orizzonti + + 4 * 2 # 8 specificazioni di regressione x 2 orizzonti + + 10 * 2 # 20 decili di funding HL x 2 orizzonti + + 3 * 3 * 2 # 18 griglia del segnale: soglia x orizzonte di holding x segno + + 2 # 2 null del segnale (sempre-short, timing casuale) + + 2 # 2 ricostruibilita' (2 asset) +) # = 118 + + +def hdr(t: str) -> None: + print("\n" + "=" * 78 + f"\n{t}\n" + "=" * 78) + + +# --------------------------------------------------------------------------- +# dati +# --------------------------------------------------------------------------- +def load_snapshots() -> pd.DataFrame: + s = pd.read_parquet(SNAP_FILE) + # epoca ESPLICITA in ms: .view("int64") su tz-aware non-ns e' un look-ahead noto + s["ts_ms"] = s["ts"].astype("datetime64[ms, UTC]").astype("int64") + return s.sort_values("ts_ms").reset_index(drop=True) + + +def snap_num(snap: pd.DataFrame, asset: str, cols: list[str]) -> pd.DataFrame: + d = snap[snap.asset == asset][["ts_ms"] + cols].copy() + for c in cols: + d[c] = pd.to_numeric(d[c], errors="coerce") + return d.sort_values("ts_ms").reset_index(drop=True) + + +def merge_causal(df: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame: + """Snapshot noto alla CHIUSURA della barra i (= timestamp[i] + 1h), backward, tolleranza 2h. + La posizione decisa a i e' poi tenuta durante i+1 (lo shift lo fa eval_weights).""" + left = pd.DataFrame({"ts_ms": (df["timestamp"].astype("int64") + 3_600_000).values}) + m = pd.merge_asof(left, right, on="ts_ms", direction="backward", + tolerance=2 * 3600 * 1000) + return m.drop(columns=["ts_ms"]) + + +def panel(snap: pd.DataFrame, cols: list[str], start=W0) -> pd.DataFrame: + out = [] + for a in ASSETS: + df = A.get(a, "1h") + df = df[df.datetime >= start - pd.Timedelta(days=7)].reset_index(drop=True) + df = pd.concat([df, merge_causal(df, snap_num(snap, a, cols))], axis=1) + cc = df["close"].values.astype(float) + for H in HOURS: + f = np.full(len(cc), np.nan) + f[:-H] = cc[H:] / cc[:-H] - 1.0 + df[f"f{H}"] = f + p4 = np.full(len(cc), np.nan) + p4[4:] = cc[4:] / cc[:-4] - 1.0 + df["p4"] = p4 + df["asset"] = a + out.append(df) + P = pd.concat(out, ignore_index=True) + return P[P.datetime >= start].reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# 1. VALIDAZIONE DEL DATO +# --------------------------------------------------------------------------- +def sezione1(snap: pd.DataFrame) -> None: + hdr("1. VALIDAZIONE DEL DATO — le colonne vengono da un progetto esterno DISMESSO") + print(f"righe={len(snap)} ts {snap.ts.min()} -> {snap.ts.max()}") + cnt = snap[snap.asset == "BTC"].set_index("ts").resample("1D").size() + fasce = pd.cut(cnt, [-1, 0, 10, 50, 90, 400]).value_counts().sort_index() + print("\ngiorni per numero di snapshot/giorno (BTC):") + for k, v in fasce.items(): + print(f" {str(k):>14s} : {v:3d} giorni") + pieni = cnt[cnt >= 90] + print(f" -> primo giorno a cadenza piena (>=90/g): {pieni.index.min().date()}" + f" ; giorni pieni = {len(pieni)}") + print(" ATTENZIONE: la copertura dichiarata parte dal 2026-03-26 ma 36 giorni hanno <=10") + print(" snapshot: la finestra USABILE a risoluzione oraria e' 2026-05-01 -> 2026-07-30.") + + for a in ASSETS: + d = snap[snap.asset == a] + print(f"\n--- {a} (n={len(d)}) ---") + ok = d.fetch_ok.value_counts().to_dict() + print(f" fetch_ok: {ok} -> fallite {100*(1-d.fetch_ok.mean()):.1f}%") + for c in ("liquidation_long_risk", "liquidation_short_risk"): + vc = d[c].value_counts(dropna=False).to_dict() + nun = d[c].nunique() + flag = " <<< COSTANTE: ZERO POTENZA" if nun <= 1 else "" + print(f" {c:26s} categorie={nun} {vc}{flag}") + for c in ("oi_delta_pct_4h", "funding_perp_annualized", "funding_cross_annualized", + "iv_minus_rv", "macro_days_to_event"): + s = pd.to_numeric(d[c], errors="coerce") + mode_share = s.value_counts(normalize=True).iloc[0] if s.notna().any() else np.nan + print(f" {c:26s} nan={100*s.isna().mean():5.1f}% nuniq={s.nunique():5d} " + f"moda={s.mode().iloc[0] if s.notna().any() else float('nan'):+.4f} " + f"({100*mode_share:.1f}% delle righe) " + f"[{s.min():+.3f}, {s.median():+.3f}, {s.max():+.3f}]") + s = pd.to_numeric(d.oi_delta_pct_4h, errors="coerce").dropna() + same = (s.diff() == 0) + print(f" oi_delta_pct_4h: congelato {100*same.mean():.2f}% delle volte, " + f"autocorr(15m)={s.autocorr(1):+.3f} autocorr(4h)={s.autocorr(16):+.3f}") + print(" -> autocorr alta a 15m e ~0 a 4h = firma di una VERA finestra mobile a 4h " + "(non un valore inventato/congelato)") + + print("\n>>> ESITO SEZIONE 1") + print(" (a) liquidation_long_risk e liquidation_short_risk valgono 'low' nel 100% delle") + print(" righe non-nulle, su 17.406 righe, 4 mesi, entrambi gli asset: UNA sola") + print(" categoria. META' DELL'IPOTESI E' UNTESTABILE PER COSTRUZIONE — non 'debole',") + print(" proprio senza varianza. E' il risultato piu' importante del filone.") + print(" (b) funding_perp_annualized e' ANCORATO al tasso base (0.01%/8h = 0.1095/anno)") + print(" nel ~49% (BTC) / ~60% (ETH) delle righe: come proxy di affollamento e' muto") + print(" per meta' del campione.") + print(" (c) oi_delta_pct_4h e' l'unica colonna del perimetro che si comporta da serie vera.") + + +# --------------------------------------------------------------------------- +# 2. IL FATTO +# --------------------------------------------------------------------------- +def bboot_p(x: np.ndarray, nb: int = 2000, block: int = 24, seed: int = 7) -> float: + x = np.asarray(x, float) + x = x[np.isfinite(x)] + n = len(x) + if n < block * 3: + return float("nan") + rng = np.random.default_rng(seed) + k = int(np.ceil(n / block)) + starts = rng.integers(0, n - block, size=(nb, k)) + means = np.array([np.concatenate([x[s:s + block] for s in starts[i]])[:n].mean() + for i in range(nb)]) + return float((means >= 0).mean() if x.mean() < 0 else (means <= 0).mean()) + + +def ols_bboot(d: pd.DataFrame, y: str, cols: list[str], nb: int = 500, + blk: int = 48, seed: int = 11): + X = np.column_stack([np.ones(len(d))] + [d[c].values for c in cols]) + Y = d[y].values + b = np.linalg.lstsq(X, Y, rcond=None)[0] + rng = np.random.default_rng(seed) + n = len(Y) + k = int(np.ceil(n / blk)) + bs = [] + for _ in range(nb): + st = rng.integers(0, n - blk, size=k) + idx = np.concatenate([np.arange(s, s + blk) for s in st])[:n] + try: + bs.append(np.linalg.lstsq(X[idx], Y[idx], rcond=None)[0]) + except np.linalg.LinAlgError: + pass + se = np.array(bs).std(0) + return b, se + + +def sezione2(snap: pd.DataFrame) -> pd.DataFrame: + hdr("2. IL FATTO — ritorno futuro condizionato all'OI (potenza DICHIARATA PRIMA)") + P = panel(snap, ["oi_delta_pct_4h", "funding_perp_annualized", + "funding_cross_annualized", "iv_minus_rv"]) + d = P.dropna(subset=["oi_delta_pct_4h", "f24", "p4"]).copy() + print(f"campione: {len(d)} barre orarie ({d.datetime.min()} -> {d.datetime.max()})") + + print("\n--- TEST DI POTENZA (dichiarato prima di leggere qualunque media) ---") + mde = {} + for H in HOURS: + n_ind = len(d) / H + sd = d[f"f{H}"].std() + mde[H] = 2 * sd / np.sqrt(n_ind / 10) + print(f" H={H:2d}h finestre NON sovrapposte={n_ind:6.0f} sd={100*sd:.2f}% " + f"n/decile={n_ind/10:5.0f} MDE(2 SE)={100*mde[H]:.3f}%") + print(" REGOLA PRE-REGISTRATA: un effetto di decile sotto la propria MDE NON e' un fatto") + print(" stabilito, qualunque p-value dia un bootstrap su osservazioni sovrapposte.") + + for a in ASSETS: + m = d.asset == a + for H in HOURS: + d.loc[m, f"x{H}"] = d.loc[m, f"f{H}"] - d.loc[m, f"f{H}"].mean() + d["mese"] = d.datetime.dt.strftime("%Y-%m") + for H in HOURS: + d[f"y{H}"] = d[f"f{H}"] - d.groupby(["asset", "mese"])[f"f{H}"].transform("mean") + d["odec"] = d.groupby("asset").oi_delta_pct_4h.transform( + lambda s: pd.qcut(s, 10, labels=False)) + + drift = {a: {H: round(100 * d.loc[d.asset == a, f"f{H}"].mean(), 3) for H in HOURS} + for a in ASSETS} + print(f"\ndrift INCONDIZIONATO della finestra (%): {drift}") + print(" -> la finestra e' ribassista: ogni media condizionata va letta DEMEANATA,") + print(" altrimenti 'stare short' sembra un segnale.") + + print("\n--- eccesso di ritorno futuro (%) per decile di oi_delta_pct_4h ---") + print(" lente 1 = demean per ASSET ; lente 2 = demean per (ASSET,MESE)") + g = d.groupby("odec").agg(n=("x4", "size"), oi=("oi_delta_pct_4h", "mean"), + x4=("x4", "mean"), x24=("x24", "mean"), + y4=("y4", "mean"), y24=("y24", "mean"), + av4=("f4", lambda s: s.abs().mean()), + av24=("f24", lambda s: s.abs().mean())) + for c in ("x4", "x24", "y4", "y24", "av4", "av24"): + g[c] *= 100 + g["p_x4"] = [bboot_p(d[d.odec == k].x4.values) for k in g.index] + g["p_x24"] = [bboot_p(d[d.odec == k].x24.values, block=48) for k in g.index] + print(g.round(3).to_string()) + print(" av4/av24 = |ret| futuro medio: e' la gamba 'squeeze/cascata' dell'ipotesi") + print(" (piu' vol dopo un estremo di OI). E' PIATTA: nessuna cascata prevedibile.") + + print("\n--- CONTROLLO: e' solo 'il prezzo si e' appena mosso'? ---") + z = lambda s: (s - s.mean()) / s.std() # noqa: E731 + for c, n in [("oi_delta_pct_4h", "zoi"), ("p4", "zp4"), + ("funding_perp_annualized", "zfund"), ("iv_minus_rv", "ziv")]: + d[n] = d.groupby("asset")[c].transform(z) + dr = d.dropna(subset=["zoi", "zp4", "zfund", "ziv"]).reset_index(drop=True) + print(f" corr(oi_delta, ritorno 4h passato) = {dr.oi_delta_pct_4h.corr(dr.p4):+.3f}" + f" (con 24h passato = {dr.oi_delta_pct_4h.corr(dr.f24.shift(24)):+.3f})") + for y in ("x4", "x24"): + for cols in (["zoi"], ["zp4"], ["zoi", "zp4"], ["zoi", "zp4", "zfund", "ziv"]): + b, se = ols_bboot(dr, y, cols) + txt = " ".join(f"{c}={100*b[i+1]:+.4f}%(t={b[i+1]/max(se[i+1],1e-12):+.2f})" + for i, c in enumerate(cols)) + print(f" {y:4s} ~ {str(cols):36s} {txt}") + print(" -> la PENDENZA LINEARE di oi_delta e' nulla (|t|<1.3 in ogni specificazione).") + print(" Quel poco che c'e' vive SOLO nella coda estrema, e non e' momentum di prezzo") + print(" (corr con il ritorno passato ~0.02): e' un'altra cosa, ma e' una CODA.") + + print("\n--- il decile 0 (OI che CROLLA) — l'unica cella non piatta ---") + for H in (4, 24): + obs = d[d.odec == 0][f"y{H}"].mean() + rng = np.random.default_rng(3) + nulls = [] + for _ in range(2000): + sh = [] + for a in ASSETS: + sub = d[d.asset == a] + k = rng.integers(1, len(sub) - 1) + sh.append(sub[f"y{H}"].values[np.roll((sub.odec == 0).values, k)]) + nulls.append(np.concatenate(sh).mean()) + nulls = np.array(nulls) + print(f" H={H:2d}h osservato={100*obs:+.3f}% MDE={100*mde[H]:.3f}% " + f"null circolare: mediana={100*np.median(nulls):+.3f}% " + f"p5={100*np.percentile(nulls,5):+.3f}% p={float((nulls<=obs).mean()):.3f}") + for a in ASSETS: + sub = d[(d.asset == a) & (d.odec == 0)] + cond = (d[d.asset == a].odec == 0).values + ep = int(np.sum(cond[1:] & ~cond[:-1])) + int(cond[0]) + print(f" {a}: ore in decile0={len(sub)} episodi distinti={ep} " + f"y4={100*sub.y4.mean():+.3f}% y24={100*sub.y24.mean():+.3f}%") + print("\n scomposizione PER MESE (demean mensile) — il test che il progetto impone") + print(" dopo il caso SOL ('un contributo positivo si scompone prima di crederci'):") + tab = d[d.odec == 0].groupby("mese")[["y4", "y24"]].agg(["size", "mean"]) + for m_, r in tab.iterrows(): + print(f" {m_}: n={int(r[('y4','size')]):4d} " + f"y4={100*r[('y4','mean')]:+.3f}% y24={100*r[('y24','mean')]:+.3f}%") + + print("\n--- 2x2 AFFOLLAMENTO (l'ipotesi originale, col funding al posto della liquidazione) ---") + for c in ("oi_delta_pct_4h", "funding_perp_annualized"): + d[c + "_hi"] = d.groupby("asset")[c].transform(lambda s: s >= s.quantile(.8)) + d[c + "_lo"] = d.groupby("asset")[c].transform(lambda s: s <= s.quantile(.2)) + combos = [("OI su & funding ALTO (long affollati -> cascata?)", + d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_hi"]), + ("OI su & funding BASSO (short affollati -> squeeze?)", + d["oi_delta_pct_4h_hi"] & d["funding_perp_annualized_lo"]), + ("OI giu & funding ALTO ", + d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_hi"]), + ("OI giu & funding BASSO", + d["oi_delta_pct_4h_lo"] & d["funding_perp_annualized_lo"])] + base = 100 * d.x4.abs().mean() + for nm, mask in combos: + sub = d[mask] + if len(sub) < 20: + print(f" {nm:52s} n={len(sub)} TROPPO PICCOLO") + continue + print(f" {nm:52s} n={len(sub):5d} x4={100*sub.x4.mean():+.3f}%(p={bboot_p(sub.x4.values):.3f})" + f" x24={100*sub.x24.mean():+.3f}%(p={bboot_p(sub.x24.values,block=48):.3f})" + f" |x4|={100*sub.x4.abs().mean():.3f}%") + print(f" |x4| di riferimento su tutto il campione = {base:.3f}%") + print(" -> 4 celle, 8 test: la sola sotto 0.05 (OI su & funding basso, x24) e' UNA cella su") + print(" 8 con ~8 finestre 24h indipendenti. Non e' un fatto.") + return d + + +# --------------------------------------------------------------------------- +# 3. FUNDING HL — l'unica gamba del perimetro con storia lunga +# --------------------------------------------------------------------------- +def sezione3() -> None: + hdr("3. FUNDING HYPERLIQUID BTC/ETH — 3 ANNI: l'unica gamba del perimetro con POTENZA") + res = [] + for a in ASSETS: + f = RAW / f"hlfund_{a.lower()}_1h.parquet" + if not f.exists(): + print(f" {f} assente") + return + h = pd.read_parquet(f) + h.index = pd.DatetimeIndex(h.index).tz_convert("UTC").floor("h") + h = h[~h.index.duplicated()] + df = A.get(a, "1h") + df = df.set_index(pd.DatetimeIndex(df.datetime)) + j = df[["close"]].join(h[["funding", "premium"]], how="inner") + for H in (4, 24): + j[f"f{H}"] = j.close.shift(-H) / j.close - 1.0 + j["asset"] = a + res.append(j) + J = pd.concat(res).dropna(subset=["funding", "f24"]) + print(f"n={len(J)} barre orarie, {J.index.min().date()} -> {J.index.max().date()}") + print(" ATTENZIONE: il feed funding HL si ferma al 2026-06-22 (non e' aggiornato a oggi).") + base = J.funding.value_counts(normalize=True).iloc[0] + print(f" il funding e' ANCORATO al tasso base nel {100*base:.1f}% delle ore " + f"(valore {J.funding.mode().iloc[0]:.6f}/h) -> anche qui meta' campione e' muto.") + J["ye"] = J.index.year + J["fdec"] = J.groupby("asset").funding.transform( + lambda s: pd.qcut(s.rank(method="first"), 10, labels=False)) + for H in (4, 24): + J[f"y{H}"] = J[f"f{H}"] - J.groupby(["asset", "ye"])[f"f{H}"].transform("mean") + g = J.groupby("fdec").agg(n=("y4", "size"), fund=("funding", "mean"), + y4=("y4", "mean"), y24=("y24", "mean"), + av4=("f4", lambda s: s.abs().mean()), + av24=("f24", lambda s: s.abs().mean())) + g["fund"] *= 100 * 24 * 365 + for c in ("y4", "y24", "av4", "av24"): + g[c] *= 100 + print("\n--- eccesso su drift ANNUALE (%) e |ret| futuro per decile di funding HL ---") + print(g.round(3).to_string()) + print("\nestremi per ANNO (y24, %):") + t = J[J.fdec.isin([0, 9])].groupby(["fdec", "ye"]).y24.agg(["size", "mean"]) + for k, r in t.iterrows(): + print(f" dec={k[0]} anno={k[1]} n={int(r['size']):5d} y24={100*r['mean']:+.3f}%") + print("\n>>> ESITO SEZIONE 3: su 53.430 osservazioni e 3 anni — cioe' DOVE LA POTENZA C'E' —") + print(" il funding come proxy di affollamento non predice ne' la DIREZIONE (|eccesso|") + print(" <=0.35% a 24h, segno instabile fra anni) ne' la VOLATILITA' (|ret| futuro piatto") + print(" e non monotono). Il filone funding, gia' chiuso su 3 lati come livello di carry,") + print(" e' chiuso anche come proxy di affollamento.") + + +# --------------------------------------------------------------------------- +# 4. IL SEGNALE CAUSALE + I NULL + I GATE +# --------------------------------------------------------------------------- +def pct_expanding(x: np.ndarray, minobs: int = 200) -> np.ndarray: + """Percentile ESPANDENTE causale: al bar i usa solo i valori <= i-1.""" + out = np.full(len(x), np.nan) + seen: list[float] = [] + for i, v in enumerate(x): + if np.isfinite(v): + if len(seen) >= minobs: + out[i] = bisect.bisect_left(seen, v) / len(seen) + bisect.insort(seen, v) + return out + + +class Signal: + """SHORT (o LONG) quando il percentile causale di oi_delta_pct_4h e' sotto soglia, + tenuto H barre, vol-targeted 20%.""" + + def __init__(self, snap: pd.DataFrame): + self.SN = {a: snap_num(snap, a, ["oi_delta_pct_4h"]).dropna() for a in ASSETS} + + def oi(self, df, asset): + return merge_causal(df, self.SN[asset])["oi_delta_pct_4h"].values.astype(float) + + def make(self, thr: float, H: int, sign: int): + def fn(df, asset): + p = pct_expanding(self.oi(df, asset)) + trig = np.where(np.isfinite(p), (p <= thr).astype(float), 0.0) + held = pd.Series(trig).rolling(H, min_periods=1).max().values + return A.vol_target(sign * held, df, target_vol=0.20, leverage_cap=2.0) + return fn + + +def book_net(target_by_asset) -> pd.Series: + nets = [] + for a in ASSETS: + df = A.get(a, "1h") + m = (df.datetime >= W0).values + d2 = df[m].reset_index(drop=True) + ev = A.eval_weights(d2, np.asarray(target_by_asset(df, a))[m]) + nets.append(pd.Series(ev["net"], index=ev["idx"])) + J = pd.concat(nets, axis=1, join="inner").fillna(0.0) + return 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1] + + +def stats(net: pd.Series) -> dict: + if net.std() == 0: + return dict(sharpe=0.0, dd=0.0, ret=0.0, cagr=0.0) + eq = (1 + net).cumprod() + dd = float(((eq.cummax() - eq) / eq.cummax()).max()) + yrs = (net.index[-1] - net.index[0]).total_seconds() / (365.25 * 86400) + return dict(sharpe=float(net.mean() / net.std() * np.sqrt(24 * 365.25)), + dd=dd, ret=float(eq.iloc[-1] - 1), + cagr=float(eq.iloc[-1] ** (1 / max(yrs, 1e-9)) - 1)) + + +def sezione4(snap: pd.DataFrame) -> None: + hdr("4. IL SEGNALE CAUSALE — griglia 18 celle, i null, i gate") + S = Signal(snap) + rows = [] + for thr in (0.10, 0.20, 0.30): + for H in (4, 12, 24): + for sign in (-1, +1): + st = stats(book_net(S.make(thr, H, sign))) + rows.append(dict(thr=thr, H=H, sign=sign, **st)) + print(f" thr={thr:.2f} H={H:2d} sign={sign:+d} " + f"Sharpe(90g)={st['sharpe']:+.2f} maxDD={100*st['dd']:5.2f}% " + f"ret={100*st['ret']:+6.2f}%") + R = pd.DataFrame(rows) + best = R.loc[R.sharpe.idxmax()] + print(f"\n miglior cella: thr={best.thr} H={int(best.H)} sign={int(best.sign):+d} " + f"-> Sharpe {best.sharpe:.2f}") + print(f" SE(Sharpe) su 90 giorni = sqrt(365/90) = {np.sqrt(365/90):.2f} -> ogni Sharpe") + print(" sotto ~4 e' indistinguibile da zero su questa finestra. Non esiste hold-out:") + print(" la cella e' scelta sull'UNICO campione (select_cell_insample non applicabile).") + + thr, H, sign = float(best.thr), int(best.H), int(best.sign) + tgt = S.make(thr, H, sign) + net = book_net(tgt) + st = stats(net) + + print("\n--- NULL 1: 'sei solo short in una finestra ribassista?' ---") + always = lambda df, a: A.vol_target( # noqa: E731 + sign * np.ones(len(df)), df, target_vol=0.20, leverage_cap=2.0) + st_a = stats(book_net(always)) + tim = np.mean([np.mean(np.asarray(tgt(A.get(a, "1h"), a))[ + (A.get(a, "1h").datetime >= W0).values] != 0) for a in ASSETS]) + print(f" candidato Sharpe={st['sharpe']:+.2f} (tempo a mercato {tim:.3f}) " + f"sempre-esposto Sharpe={st_a['sharpe']:+.2f}") + print(" -> il candidato NON e' la statica travestita: il timing porta qualcosa.") + + print("\n--- NULL 2: timing CASUALE a pari esposizione (shift circolare, 300 estrazioni) ---") + base_trig = {} + for a in ASSETS: + df = A.get(a, "1h") + base_trig[a] = (np.asarray(tgt(df, a)), (df.datetime >= W0).values) + rng = np.random.default_rng(21) + nulls = [] + for _ in range(300): + shifted = {} + for a in ASSETS: + v, m = base_trig[a] + v2 = v.copy() + idx = np.where(m)[0] + v2[idx] = np.roll(v2[idx], rng.integers(1, len(idx) - 1)) + shifted[a] = v2 + nulls.append(stats(book_net(lambda df, a: shifted[a]))["sharpe"]) + nulls = np.array(nulls) + pctl = float((nulls < st["sharpe"]).mean()) + print(f" null: mediana={np.median(nulls):+.2f} p90={np.percentile(nulls,90):+.2f} " + f"p99={np.percentile(nulls,99):+.2f} -> candidato al {100*pctl:.1f}o pctl") + print(f" ma la cella e' il MASSIMO di 18: P(almeno una cella oltre quel pctl per caso) " + f"= 1-{pctl:.3f}^18 = {1-pctl**18:.2f} -> il null non e' superato dopo il conto dei trial.") + + print("\n--- GATE ---") + print(f" causality_ok : {A.causality_ok(tgt, tf='1h')}") + cd = A.candidate_daily(tgt, tf="1h") + cd = cd[cd.index >= W0] + sr = float(cd.mean() / cd.std() * np.sqrt(365.25)) + rep = A.marginal_vs_tp01(cd) + print(f" candidate_daily : n={len(cd)} giorni, Sharpe={sr:.2f}") + print(f" marginal_vs_tp01 : verdetto={rep.get('marginal_verdict')} " + f"corr_beta_tp01={rep.get('beta_to_tp01')} has_insample_edge={rep.get('has_insample_edge')} " + f"is_hedge={rep.get('is_hedge')} robust_oos={rep.get('robust_oos')} " + f"beats_noise_null={rep.get('beats_noise_null')} null_pctl_full={rep.get('null_pctl_full')}") + print(" (robust_oos=False non e' un difetto del candidato: con 90 giorni NON ESISTE il") + print(" multi-cut che il gate richiede. Il gate e' girato e il suo esito e' NEUTRAL.)") + all_sr = list(R.sharpe.values) + d18 = A.deflated_sharpe(sr, all_sr, cd) + d_all = A.deflated_sharpe(sr, all_sr * 7, cd) + print(f" deflated_sharpe(18) : DSR={d18[0]:.3f} (Sharpe atteso del massimo sotto il null " + f"= {d18[1]:.2f}) -> {'PASS' if d18[0]>=0.95 else 'FAIL'}") + print(f" deflated_sharpe({GRID_DECLARED}) : DSR={d_all[0]:.3f} (null max {d_all[1]:.2f}) " + f"-> {'PASS' if d_all[0]>=0.95 else 'FAIL'}") + print(" con 114 giorni il MASSIMO atteso PER CASO su 18 celle e' Sharpe ~5.4:") + print(" il candidato (3.6) sta SOTTO il proprio null. Non serve altro.") + imp = A.implausible_sharpe(cd) + print(f" implausible_sharpe : {imp['implausible']} {imp['reasons']}") + print(" null de-levering : NON GIRATO — il candidato non fa alcun claim di riduzione") + print(" di drawdown (e' uno stream di ritorno, non un overlay).") + print(" anchor_luck_band : NON GIRATO — il segnale non e' ancorato a un'ora di") + print(" ribilanciamento (gira su ogni barra oraria).") + for a in ASSETS: + df = A.get(a, "1h") + m = (df.datetime >= W0).values + sc = A.eval_weights_smallcap(df[m].reset_index(drop=True), + np.asarray(tgt(df, a))[m], capital=600) + print(f" smallcap $600 {a} : haircut Sharpe={sc['sharpe_haircut']:+.3f} " + f"(reale {sc['realistic']['sharpe']:.2f} vs modellato {sc['modeled']['sharpe']:.2f}, " + f"{sc['n_executed_trades']} trade eseguiti)") + print(" -> l'ESEGUIBILITA' non e' il vincolo (2 gambe BTC/ETH perp, haircut ~0.02):") + print(" come per SOL, il candidato muore sull'evidenza, non sulla taglia del conto.") + + print("\n--- CONCENTRAZIONE (dove sta davvero il P&L) ---") + mm = cd.groupby(cd.index.strftime("%Y-%m")).agg(n="size", ret=lambda s: 100 * ((1 + s).prod() - 1)) + print(mm.round(2).to_string()) + tot = 100 * ((1 + cd).prod() - 1) + top3 = 100 * cd.sort_values().tail(3).sum() + print(f" somma dei 3 giorni migliori = {top3:.2f}% su un totale di {tot:.2f}% " + f"({100*top3/max(tot,1e-9):.0f}% del P&L in 3 giorni su 114)") + + +# --------------------------------------------------------------------------- +# 5. RICOSTRUIBILITA' +# --------------------------------------------------------------------------- +def sezione5(snap: pd.DataFrame) -> None: + hdr("5. RICOSTRUIBILITA' — il dato e' MORTO il 2026-07-30: sapremmo rifarlo oggi?") + cols = ["asset", "instrument_name", "open_interest", "ts"] + parts = [] + arch = RAW / "cb_chain" / "bite_archive.parquet" + if arch.exists(): + parts.append(pd.read_parquet(arch, columns=cols)) + for f in sorted(glob.glob(str(RAW / "cb_chain" / "2026-*.parquet"))): + parts.append(pd.read_parquet(f, columns=cols)) + C = pd.concat(parts, ignore_index=True) + del parts + C["ts"] = pd.to_datetime(C.ts, utc=True).dt.floor("h") + C = C.drop_duplicates(["ts", "instrument_name"]) + print(f"catena: {len(C)} righe {C.ts.min()} -> {C.ts.max()}") + sn = snap.copy() + sn["h"] = pd.to_datetime(sn.ts, utc=True).dt.floor("h") + for asset in ASSETS: + s = C[C.asset == asset] + piv = s.pivot_table(index="ts", columns="instrument_name", + values="open_interest", aggfunc="last") + piv = piv.reindex(pd.date_range(piv.index.min(), piv.index.max(), freq="h")) + V = piv.values + dl = np.full(len(piv), np.nan) + for i in range(4, len(piv)): + m = np.isfinite(V[i]) & np.isfinite(V[i - 4]) + if m.sum() < 30: + continue + b = V[i - 4][m].sum() + if b > 0: + dl[i] = 100 * (V[i][m].sum() / b - 1) + chain = pd.Series(dl, index=piv.index, name="chain") + ref = pd.to_numeric(sn[sn.asset == asset].groupby("h").oi_delta_pct_4h.last(), + errors="coerce").rename("snap") + j = pd.concat([ref, chain], axis=1).dropna() + print(f" {asset}: ore sovrapposte={len(j)} " + f"corr Pearson={j.snap.corr(j.chain):+.3f} " + f"Spearman={j.snap.corr(j.chain, method='spearman'):+.3f} " + f"(sd snap={j.snap.std():.3f} vs sd catena={j.chain.std():.3f})") + del piv, V + print("\n Nota: l'OI della catena e' l'OI delle OPZIONI a strumenti APPAIATI (stessi") + print(" strumenti a t e t-4h, cosi' le scadenze non creano salti). La correlazione con") + print(" oi_delta_pct_4h e' ~0 in rango: NON e' la stessa grandezza (lo snapshot misurava") + print(" l'OI dei PERPETUAL/futures via il progetto dismesso).") + print("\n>>> ESITO SEZIONE 5 — colonna per colonna, cosa sappiamo produrre OGGI:") + print(" oi_delta_pct_4h : NO. La catena da' OI di OPZIONI (rango ~0.05 con la") + print(" colonna). L'OI perp Deribit e' leggibile dall'API") + print(" pubblica ma NON lo raccogliamo: la storia parte da") + print(" zero il giorno che si accende un collettore.") + print(" liquidation_long/short : NO, e non importa: la colonna e' costante 'low'.") + print(" Nessuna fonte nostra la produce, e non c'e' niente") + print(" da produrre.") + print(" funding_perp/cross : SI ma degradato — funding HL c'e' (fermo al 22/06/2026),") + print(" funding Deribit non lo salviamo.") + print(" iv_minus_rv : SI, ricostruibile da dvol_*.parquet + barre certificate.") + + +# --------------------------------------------------------------------------- +def main() -> None: + print(__doc__) + print(f"GRIGLIA DICHIARATA (conteggio al rialzo) = {GRID_DECLARED} celle/trial") + snap = load_snapshots() + sezione1(snap) + sezione2(snap) + sezione3() + sezione4(snap) + sezione5(snap) + hdr("6. VERDETTO") + print("""SCARTATO. + 1. META' DELL'IPOTESI NON ESISTE NEL DATO: liquidation_long_risk e liquidation_short_risk + valgono 'low' nel 100% delle 17.229 righe non-nulle. Una variabile con UNA categoria non + ha potenza: l'accoppiamento affollamento x rischio-di-liquidazione e' untestabile, non + debole. (Ed e' esattamente la parte che rendeva l'angolo NUOVO rispetto al filone funding.) + 2. IL FATTO NON REGGE LA PROPRIA SOGLIA DI POTENZA, dichiarata prima di guardare: l'unico + effetto e' il decile piu' basso di oi_delta (-0.40% a 24h) e la MDE a 24h e' 1.06%. + La pendenza lineare e' nulla (|t|<1.3), il |ritorno| futuro e' piatto fra i decili (la + 'cascata' non c'e'), e la scomposizione per mese mette quasi tutto in giugno. + 3. IL SEGNALE E' UN MASSIMO DI 18 CELLE SU 114 GIORNI: Sharpe 3.6 con SE(Sharpe)=2.0, + deflated-Sharpe 0.10 (18 celle) / 0.004 (118 trial) contro un massimo atteso PER CASO di + Sharpe 5.4; implausible_sharpe=True; marginal_vs_tp01 = NEUTRAL; 60% del P&L in 3 giorni. + 4. ED E' COMUNQUE MORTO ALLA NASCITA: la colonna su cui poggia non e' ricostruibile con i dati + che raccogliamo (l'OI della catena e' quello delle OPZIONI, rango ~0.05 con la colonna) e + la sorgente e' stata dismessa il 2026-07-30. + Sottoprodotto con potenza vera (53.430 ore, 3 anni): il funding HL non predice ne' direzione + ne' volatilita' -> il filone funding, gia' chiuso su 3 lati come livello di carry, si puo' + dichiarare chiuso anche come PROXY DI AFFOLLAMENTO.""") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_growth_policy.py b/scripts/research/r0822_growth_policy.py new file mode 100644 index 0000000..edb3e91 --- /dev/null +++ b/scripts/research/r0822_growth_policy.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python +"""r0822_growth_policy.py — LA POLITICA DI LEVA, giudicata sul TEMPO invece che sullo Sharpe. + +LA DOMANDA. `target_vol=20%` per TP01 e' canonico dal 2026-06-19 e non e' mai stato messo in +discussione **rispetto a un obiettivo di crescita**. E' stato scelto (e ri-confermato: peso 75/25 +tre volte, TP01xDVOL, tranching) sempre con una lente di **Sharpe** o di **drawdown**. Ma +l'operatore non massimizza lo Sharpe: vuole ~$258k netti il prima possibile. Sono due problemi +diversi — lo Sharpe e' invariante alla leva, il TEMPO no. + +Quindi: **quale leva k minimizza il tempo mediano al bersaglio, a parita' di rischio accettato?** +E cambia se il bersaglio e' $50k invece di $258k? + +COSA C'E' QUI + (0) Replica di controllo: il book live, il fattore d'ancora ×0.89 misurato, e i muri pubblicati. + (1) LA CURVA DI CRESCITA g(k) — dove sta il massimo (Kelly empirico) e da che parte sta il libro. + (2) SWEEP PRINCIPALE — per ogni k: tempo mediano al bersaglio (CONDIZIONATO, stampato accanto + alla sua probabilita'), P(entro 10/15/20a), P(rovina), P(DD>30%). Versamenti €0 e €500/mese, + bersagli $50k e il muro NETTO. Tutto **al netto del fisco d'accumulo** (33% + carry 4a + 0.2%). + (3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta. + (4) IL GATE CHE CONTA — il massimo di (1) e' calcolato su un campione che non contiene un solo + giorno peggiore di -3.9%. Stress esplicito: giorno di crash fuori campione + liquidazione + Deribit. Dove va il massimo quando il rischio che il dataset NON contiene entra nel conto. + (5) ESEGUIBILITA' — `config/live.json` oggi tiene il nozionale lordo <= 1.00x l'equity, e il + knob `target_vol` di TP01 NON scala linearmente (leverage cap 2x). Quanto di k e' teorico. + +ONESTA' (regole del progetto applicate qui): + · un non-arrivo e' **+infinito**, mai -1 (l'errore del 07/08: con -1 la mediana MIGLIORA quanto + peggio va la colonna); + · una mediana condizionata si stampa **sempre** accanto alla sua probabilita'; + · un Monte Carlo ha una **risoluzione**: la riga di testa e' rifatta con un secondo seme e la + banda e' dichiarata; + · la rovina non e' l'azzeramento aritmetico (con ritorni moltiplicativi il capitale non tocca + zero finche' k < 1/|peggior giorno| = 25.4): e' definita e dichiarata sotto. + + nice -n 19 timeout 900 uv run python scripts/research/r0822_growth_policy.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +import r0725_capcurve as CC # noqa: E402 macchineria validata (book_series, _boot_paths, survival) +import r0726_deposits as DP # noqa: E402 deluck_returns() con il ×0.89 MISURATO +import r0727_tasse as TX # noqa: E402 CARRY_ANNI, PATRIMONIALE +from r0807_piano_netto import passo_fiscale # noqa: E402 il passo fiscale gia' validato + +# ------------------------------------------------------------------ parametri dichiarati +KS = (0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 2.50, 3.50, 5.00, 7.00, + 10.00, 14.00, 18.00, 22.00) # 14 celle: la griglia stretta (<=8x) NON conteneva il + # massimo -> allargata finche' il massimo e' INTERNO +KS_STRESS = (1.00, 1.50, 2.00, 3.50, 5.00, 10.00, 18.00) # sottoinsieme per la (4) +DEPOSITI = (0, 500) # €/mese: nessuno, e il piano dichiarato +BERSAGLI = {"$50k": 50_000.0, "muro netto": 258_338.0} # r0807_piano_netto (fisco coerente) +ANNI = 25 # orizzonte di simulazione: 25 come r0726/r0807 (20 troncherebbe + # la coda della mediana condizionata). Le P si leggono a 10/15/20a. +N_PATHS = 2_500 +BLOCK = 20 +CHUNK = 500 # RAM: la VPS ha ~3.5 GB liberi e ci girano altri agenti +SEED = 20260822 +SEED_BIS = 8221 # secondo seme, solo per dichiarare la risoluzione MC +START = 600.0 +ALIQUOTA = CC.TAX_RATE # 0.33 +PATRIM = TX.PATRIMONIALE # 0.002 +RUIN_MULT = 0.20 # rovina = il moltiplicatore del PATH perde l'80% +DD_SOGLIA = 0.30 +WIPE = 0.90 # perdita giornaliera >= 90% dell'equity = liquidazione (margine+fee) +MURO_PUBBL_LORDO = 272_061.0 # r0726_capwall_refresh (convenzione pubblicata) + + +def sezione(t: str) -> None: + print("\n" + "=" * 104) + print(f" {t}") + print("=" * 104) + + +# =========================================================================== +# il motore: una passata, tutte le leve insieme (confronto APPAIATO sugli stessi path) +# =========================================================================== +def simula(r: np.ndarray, ks: tuple[float, ...], *, anni: int, dep_eur: float, + bersagli: dict[str, float], aliquota: float, patrimoniale: float, + n_paths: int = N_PATHS, seed: int = SEED, start: float = START, + crash_pct: float = 0.0, crash_p_anno: float = 0.0) -> dict: + """Accumulo a leva `k` per ogni k, sugli STESSI path (differenze appaiate). + + Stato per (path, k). Traccia in parallelo un GEMELLO SENZA VERSAMENTI: il drawdown e la + rovina si misurano su quello, cosi' sono confrontabili fra colonne con versamenti diversi + (un bonifico mensile maschera un drawdown senza ridurre il capitale distrutto). + + `crash_pct`/`crash_p_anno`: giorno di coda FUORI CAMPIONE (sezione 4). Il block bootstrap non + puo' generare un giorno peggiore del peggiore osservato: se lo si vuole, va messo a mano. + + Liquidazione: se la perdita del giorno a leva k supera `WIPE` dell'equity, il conto e' chiuso + dall'exchange -> capitale a 0 (i versamenti successivi ripartono da li'). + """ + kv = np.asarray(ks, float)[None, :] + nk = len(ks) + dep = dep_eur * CC.EURUSD + n_days = int(anni * 365) + rng = np.random.default_rng(seed) + nomi = list(bersagli) + soglie = np.array([bersagli[n] for n in nomi], float) + + hit = {n: [] for n in nomi} + fin, mult_fin, ruin, ddmax, versato_tot = [], [], [], [], 0.0 + + for c0 in range(0, n_paths, CHUNK): + m = min(CHUNK, n_paths - c0) + paths = CC._boot_paths(r, m, n_days, BLOCK, rng) + if crash_pct > 0 and crash_p_anno > 0: + # un giorno di crash con probabilita' `crash_p_anno`/anno, uniforme nel tempo + p_giorno = crash_p_anno / 365.0 + mask = rng.random((m, n_days)) < p_giorno + paths = np.where(mask, -abs(crash_pct), paths) + + cap = np.full((m, nk), start, float) + mult = np.ones((m, nk), float) # gemello senza versamenti + hwm = np.ones((m, nk), float) + dd = np.zeros((m, nk), float) + rovinato = np.zeros((m, nk), bool) + colpito = {n: np.full((m, nk), np.inf) for n in nomi} + carry = np.zeros((m * nk, TX.CARRY_ANNI)) + anno_start = cap.copy() + flusso = np.zeros((m, nk)) + versato = start + + for t in range(n_days): + rt = paths[:, t][:, None] * kv # (m, nk) + liq = rt <= -WIPE + g = np.where(liq, -1.0, rt) + cap *= (1.0 + g) + cap = np.maximum(cap, 0.0) + mult *= (1.0 + g) + mult = np.maximum(mult, 0.0) + hwm = np.maximum(hwm, mult) + dd = np.maximum(dd, 1.0 - mult / hwm) + rovinato |= mult <= RUIN_MULT + if dep > 0 and t % 30 == 0 and t > 0: + cap += dep + flusso += dep + versato += dep + for j, n in enumerate(nomi): + nuovi = np.isinf(colpito[n]) & (cap >= soglie[j]) + colpito[n][nuovi] = t + if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0): + passo_fiscale(cap.ravel(), anno_start.ravel(), flusso.ravel(), + carry, aliquota, patrimoniale) + anno_start = cap.copy() + flusso[:] = 0.0 + + for n in nomi: + hit[n].append(colpito[n]) + fin.append(cap) + mult_fin.append(mult) + ruin.append(rovinato) + ddmax.append(dd) + versato_tot = versato + + out = dict(versato=versato_tot, anni=anni, n_paths=n_paths, ks=ks) + out["cap"] = np.concatenate(fin, axis=0) + out["mult"] = np.concatenate(mult_fin, axis=0) + out["ruin"] = np.concatenate(ruin, axis=0) + out["dd"] = np.concatenate(ddmax, axis=0) + out["hit"] = {n: np.concatenate(hit[n], axis=0) for n in nomi} + return out + + +def anni_cond(hit_col: np.ndarray) -> tuple[float, float, float, float]: + """(anni mediani CONDIZIONATI all'arrivo, P<=10a, P<=15a, P<=20a). Non-arrivo = +inf.""" + arr = hit_col[np.isfinite(hit_col)] + med = float(np.median(arr)) / 365.0 if len(arr) >= 50 else float("nan") + p = lambda y: float((hit_col <= y * 365).mean()) # noqa: E731 + return med, p(10), p(15), p(20) + + +def fmt_anni(a: float) -> str: + return f"{a:.1f}a" if np.isfinite(a) else "mai" + + +# =========================================================================== +def main() -> None: + print("=" * 104) + print(" r0822 — GROWTH POLICY: quale leva minimizza il TEMPO al bersaglio, a pari rovina accettata") + print("=" * 104) + + # ------------------------------------------------------------------ (0) + sezione("(0) IL DATO — book LIVE (TP01 75 / SKH01 25) e replica di controllo") + base = CC.book_series("hourly") + r = DP.deluck_returns() + print(f"\n serie: {len(base)} giorni, {base.index.min().date()} -> {base.index.max().date()}") + print(f" lente 'hourly' = SKH01 sul PATH LIVE (fill al close 5m orario), non il backtest.") + print(f" canonico : drift {base.values.mean()*365:>7.2%} vol {base.values.std()*365**0.5:>6.2%}" + f" Sharpe {base.values.mean()/base.values.std()*365**0.5:>5.2f}") + print(f" de-luckato ×{DP.DELUCK}: drift {r.mean()*365:>7.2%} vol {r.std()*365**0.5:>6.2%}" + f" Sharpe {r.mean()/r.std()*365**0.5:>5.2f} <-- questa e' la serie usata ovunque sotto") + print(f" peggior giorno {r.min():+.2%} · miglior giorno {r.max():+.2%}") + print(f"\n ⚠️ Conseguenza aritmetica da tenere a mente per tutto il resto: con ritorni") + print(f" moltiplicativi il capitale NON tocca zero finche' k < 1/{abs(r.min()):.4f} = " + f"{1/abs(r.min()):.1f}x.") + print(f" Percio' 'rovina' qui e' DEFINITA: il moltiplicatore del path (gemello SENZA") + print(f" versamenti) scende sotto {RUIN_MULT:.0%}, cioe' 4 euro su 5 distrutti. E la") + print(f" liquidazione dell'exchange e' modellata a parte: perdita giornaliera >= {WIPE:.0%}.") + print(f"\n muri pubblicati riusati come bersagli:") + print(f" · ${MURO_PUBBL_LORDO:,.0f} (r0726_capwall_refresh, convenzione LORDA)") + print(f" · ${BERSAGLI['muro netto']:,.0f} (r0807_piano_netto, fisco COERENTE) <-- usato qui") + print(f" · $50.000 (il bersaglio 'corto' chiesto dal brief: ~€19/g di rendita perpetua)") + + print("\n CONTROLLO DI REPLICA — questo motore contro la tabella pubblicata il 07/08") + print(" (`r0807_piano_netto` §2: stesso fisco, stesso bersaglio, stessa convenzione di") + print(" mediana condizionata). I path sono diversi: si chiede accordo entro il rumore MC,") + print(" non l'identita'. Se questa riga non torna, nessun numero nuovo sarebbe") + print(" distinguibile da un bug.") + print(f"\n {'EUR/mese':>9}{'anni (pubbl.)':>16}{'P<=20a (pubbl.)':>18}{'anni (qui)':>13}{'P<=20a (qui)':>15}") + for dep, a_pub, p_pub in ((250, 19.8, 0.52), (500, 14.7, 0.99)): + sr = simula(r, (1.00,), anni=25, dep_eur=dep, bersagli={"muro": BERSAGLI["muro netto"]}, + aliquota=ALIQUOTA, patrimoniale=PATRIM, n_paths=3_000, seed=725) + a, _, _, p20 = anni_cond(sr["hit"]["muro"][:, 0]) + print(f" {dep:>9}{a_pub:>15.1f}a{p_pub:>18.0%}{fmt_anni(a):>13}{p20:>15.0%}") + + # ------------------------------------------------------------------ (1) + sezione("(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo, e DI QUANTO il libro ne dista") + print(f"\n Crescita geometrica MEDIANA annua, orizzonte {ANNI} anni, ZERO versamenti,") + print(f" {N_PATHS} path, fisco d'accumulo DENTRO. Un vol-target e' esattamente questo:") + print(" uno scalare sui pesi. Griglia allargata a 22x perche' a 8x il massimo non era") + print(" ancora interno — una griglia che si ferma prima del massimo non lo misura, lo tronca.") + s0 = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, + aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED) + s0b = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, + aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED_BIS) + mu, sig = float(r.mean()), float(r.std()) + kelly_gauss = mu / sig ** 2 + + def g_curve(sim: dict) -> list[float]: + return [float(np.median(sim["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(sim["mult"].shape[1])] + + g_med, g_medb = g_curve(s0), g_curve(s0b) + jstar = int(np.argmax(g_med)) + kbest, gbest = KS[jstar], g_med[jstar] + j1 = KS.index(1.00) + + lg = lambda g: float(np.log1p(g)) # noqa: E731 + print(f"\n {'k':>6}{'g mediana/anno':>17}{'(2° seme)':>12}{'ln(1+g)/ln(1+g*)':>19}" + f"{'maxDD mediano':>16}{'P(DD>30%)':>11}{'P(rovina)':>11}{'cap mediano a 25a':>20}") + for j, k in enumerate(KS): + marca = " <-- LIBRO" if j == j1 else (" <-- k*" if j == jstar else "") + print(f" {k:>6.2f}{g_med[j]:>17.2%}{g_medb[j]:>12.2%}{lg(g_med[j])/lg(gbest):>19.2f}" + f"{np.median(s0['dd'][:, j]):>16.1%}{(s0['dd'][:, j] > DD_SOGLIA).mean():>11.1%}" + f"{s0['ruin'][:, j].mean():>11.1%}{np.median(s0['cap'][:, j]):>20,.0f}{marca}") + esec = [j for j, k in enumerate(KS) if k <= 2.50] + ris = max(abs(a - b) for a, b in zip(g_med, g_medb)) + ris_e = max(abs(g_med[j] - g_medb[j]) for j in esec) + print(f"\n RISOLUZIONE MONTE CARLO (due semi indipendenti a {N_PATHS} path): scarto massimo") + print(f" {ris:.2%}/anno sull'intera griglia, ma solo {ris_e:.2%}/anno nella regione") + print(f" k<={KS[esec[-1]]:.2f} — il rumore vive tutto nelle celle a leva alta, dove la mediana e'") + print(" presa su una distribuzione enormemente dispersa. Conseguenza da tenere: la") + print(" POSIZIONE del massimo non e' risolvibile a questa taglia (vedi sotto), i confronti") + print(" nella regione eseguibile si'. Ogni confronto e' APPAIATO (stessi path per ogni k).") + + print(f"\n RISPOSTA ALLA DOMANDA 'da che parte, e di quanto':") + print(f" massimo EMPIRICO della crescita k* = {kbest:.2f}x (g = {gbest:.1%}/anno)") + print(f" massimo GAUSSIANO (Kelly, mu/sigma²) k = {kelly_gauss:.1f}x") + print(f" il LIBRO gira a k = 1.00x (g = {g_med[j1]:.1%}/anno)") + print(f" -> il libro sta a SINISTRA, a {1.0/kbest:.0%} di Kelly empirico, e raccoglie il") + print(f" {lg(g_med[j1])/lg(gbest):.0%} della crescita massima (in crescita LOGARITMICA, che e' la") + print(" grandezza che Kelly massimizza). NON e' un plateau: e' il ramo ripido.") + print(f"\n ⚠️ La POSIZIONE del massimo non e' risolta a questa taglia: la sezione (4) rifa'") + print(" la stessa curva con 1500 path e il massimo cade a 10x invece di 14x. La regione") + print(" 8-14x e' piatta entro il rumore -> si cita 'k* ~ 10-14x', non un numero.") + print(" Cio' che E' risolto, e con margine enorme, e' che 1.00x non ci sta vicino.") + print(f"\n ⚠️ PREVISIONE MIA, REFUTATA IN SESSIONE. Mi aspettavo che il massimo empirico") + print(f" cadesse molto a SINISTRA di quello gaussiano ({kelly_gauss:.1f}x), perche' il bootstrap a") + print(" blocchi conserva code e clustering che la formula ignora. Non succede: i due") + print(" coincidono entro il rumore. Il motivo e' informativo e prepara la sezione (4) —") + print(" la distribuzione delle CHIUSURE giornaliere di questo libro non ha code abbastanza") + print(" spesse da spostare Kelly. Il rischio che conta a leva non e' in quella") + print(" distribuzione, quindi non lo si trovera' ricampionandola meglio.") + print(f"\n ⚠️ Questo e' il risultato della lente, non una raccomandazione. Tre cose lo") + print(" ridimensionano, e sono misurate sotto: (a) k* dipende dal DRIFT, che e' stimato") + print(f" su {len(r)/365:.1f} anni (sez. 1-bis); (b) la coda che lo sostiene NON e' nel") + print(" campione (sez. 4); (c) meta' della griglia non e' eseguibile (sez. 5).") + print(f" ⚠️ E c'e' un controllo di plausibilita' che il modello fallisce da solo: a k={KS[jstar]:g}x") + print(f" il capitale mediano a 25 anni partendo da $600 e' ${np.median(s0['cap'][:, jstar]):,.0f}.") + print(" Un modello che promette piu' della capitalizzazione del mercato in cui trada ha") + print(" lasciato il dominio in cui vuol dire qualcosa: assume che il ritorno sia") + print(" indipendente dalla SIZE, il che e' falso ben prima di quella cifra.") + + # ------------------------------------------------------------------ (1-bis) + sezione("(1-bis) QUANTO E' SOLIDO k* — Kelly e' ipersensibile al drift, e il drift e' una STIMA") + se_drift = float(r.std() * 365 ** 0.5) / (len(r) / 365) ** 0.5 + print(f"\n drift stimato {mu*365:.2%}/anno su {len(r)/365:.1f} anni con vol {r.std()*365**0.5:.2%}") + print(f" -> errore standard della MEDIA = vol/sqrt(T) = {se_drift:.2%}/anno.") + print(" Kelly va come mu/sigma²: e' LINEARE nell'errore del drift, quindi l'incertezza") + print(" sul numeratore si trasferisce intera su k*. Stessa macchineria di `deluck_returns`") + print(" (si sottrae una costante al drift, la vol resta quella misurata).") + print(f"\n {'ipotesi sul drift':>34}{'drift/anno':>13}{'k* gaussiano':>15}{'k* empirico':>14}" + f"{'g(1x)':>9}{'g(k*)':>9}") + scen = [("punto (de-luckato ×0.89)", 0.0), + ("-1 errore standard", se_drift), + ("-2 errori standard", 2 * se_drift), + ("drift dimezzato (lente 07/08)", mu * 365 / 2), + ("drift a zero", mu * 365)] + for nome, taglio in scen: + rs = r - taglio / 365.0 + mus = float(rs.mean()) + kg = mus / sig ** 2 + ss = simula(rs, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, + patrimoniale=PATRIM, seed=SEED, n_paths=1_500) + gs = g_curve(ss) + js = int(np.argmax(gs)) + print(f" {nome:>34}{mus*365:>13.2%}{kg:>15.1f}{KS[js]:>13.2f}x" + f"{gs[j1]:>9.1%}{gs[js]:>9.1%}") + print("\n Lettura: un errore di stima di UN solo errore standard sul drift — che a 7 anni di") + print(" storia e' l'ordine di grandezza normale — sposta k* di parecchie unita'. E' la") + print(" ragione classica per cui si opera a una FRAZIONE di Kelly: non prudenza morale, ma") + print(" il fatto che k* e' un numero stimato con una barra d'errore larga quanto se stesso.") + + # ------------------------------------------------------------------ (2) + sezione(f"(2) SWEEP PRINCIPALE — tempo al bersaglio, orizzonte {ANNI} anni, AL NETTO del fisco") + print(f"\n da ${START:.0f} · {N_PATHS} path · blocchi {BLOCK}g · fisco {ALIQUOTA:.0%} + carry " + f"{TX.CARRY_ANNI}a + patrimoniale {PATRIM:.1%}") + print(" 'anni' = mediana CONDIZIONATA all'arrivo (non-arrivo = +infinito, MAI -1): si legge") + print(" sempre insieme a P<=20a accanto. 'p90' = il decile lento, cioe' il rischio di TEMPO.") + print(" ⚠️ Convenzione dichiarata: il fisco e' dentro il CAPITALE (colonne anni/P), NON dentro") + print(" il gemello di rischio (colonne DD/rovina). Un drawdown e' un fatto di mercato: metterci") + print(" l'imposta dentro sommerebbe due cose diverse in un numero solo.") + risultati = {} + for dep in DEPOSITI: + s = s0 if dep == 0 else simula(r, KS, anni=ANNI, dep_eur=dep, bersagli=BERSAGLI, + aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED) + risultati[dep] = s + for nome in BERSAGLI: + print(f"\n --- versamento €{dep}/mese · bersaglio {nome} (${BERSAGLI[nome]:,.0f}) · " + f"versato in {ANNI}a ${s['versato']:,.0f} ---") + print(f" {'k':>6}{'anni (p50)':>13}{'anni (p90)':>13}{'P<=10a':>9}{'P<=15a':>9}" + f"{'P<=20a':>9}{'P(rovina)':>11}{'P(DD>30%)':>11}{'P(DD>50%)':>11}") + for j, k in enumerate(KS): + h = s["hit"][nome][:, j] + a, p10, p15, p20 = anni_cond(h) + arr = h[np.isfinite(h)] + a90 = float(np.percentile(arr, 90)) / 365.0 if len(arr) >= 50 else float("nan") + print(f" {k:>6.2f}{fmt_anni(a):>13}{fmt_anni(a90):>13}{p10:>9.1%}{p15:>9.1%}" + f"{p20:>9.1%}{s['ruin'][:, j].mean():>11.1%}" + f"{(s['dd'][:, j] > DD_SOGLIA).mean():>11.1%}" + f"{(s['dd'][:, j] > 0.50).mean():>11.1%}") + + # ------------------------------------------------------------------ (3) + sezione("(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta") + print("\n 'a parita' di probabilita' di rovina accettata': per ogni budget di rischio, la") + print(" leva PIU' ALTA che lo rispetta, e il tempo che quel budget compra. Il rischio si") + print(" misura sul gemello SENZA versamenti (deposit-independent: un bonifico mensile") + print(" maschera un drawdown senza ridurre il capitale distrutto), ed e' la stessa") + print(" grandezza su cui passa la regola di DD per-conto del canale funded.") + for dep in DEPOSITI: + s = risultati[dep] + print(f"\n --- versamento €{dep}/mese ---") + print(f" {'budget':>8}{'metrica':>13}{'k max':>9}" + + "".join(f"{'anni ' + n:>22}" for n in BERSAGLI)) + for metrica, col in (("P(DD>30%)", lambda j: (s["dd"][:, j] > DD_SOGLIA).mean()), + ("P(rovina)", lambda j: s["ruin"][:, j].mean())): + for budget in (0.01, 0.05, 0.10, 0.25, 0.50): + amm = [j for j in range(len(KS)) if col(j) <= budget] + if not amm: + print(f" {budget:>7.0%}{metrica:>13}{'nessuna':>9}") + continue + j = max(amm) + celle = [] + for n in BERSAGLI: + a, _, _, p20 = anni_cond(s["hit"][n][:, j]) + celle.append(f"{fmt_anni(a)} (P20 {p20:.0%})") + print(f" {budget:>7.0%}{metrica:>13}{KS[j]:>8.2f}x" + "".join(f"{c:>22}" for c in celle)) + print("\n ⚠️ Le due metriche danno risposte MOLTO diverse, e la ragione conta: P(rovina)") + print(" (perdere l'80% del moltiplicatore) e' quasi zero fino a leve altissime perche'") + print(" il libro non ha mai perso l'80% in nessuna finestra ricampionabile; P(DD>30%)") + print(" morde subito. Chi sceglie la leva sul solo azzeramento sceglie sulla metrica") + print(" che il dataset non puo' informare — vedi (4).") + + # ------------------------------------------------------------------ (4) + sezione("(4) IL GATE CHE CONTA — k* e' calcolato su un campione senza un solo giorno di crash") + print(f"\n Il block bootstrap ricampiona i giorni VISSUTI: non puo' produrre un giorno") + print(f" peggiore di {r.min():.2%}. Il libro ha attraversato il 2022 (LUNA/FTX) restando") + print(" difensivo — quindi per k=1 la coda del campione e' onesta. Ma a leva il rischio") + print(" cambia NATURA: entra la liquidazione, che su una chiusura giornaliera non si vede") + print(" (lezione della lente wick accoppiata, 25/07: 'close-only non e' conservativa, e'") + print(" CIECA'). Il rischio fuori campione non si stima dal campione: si mette a mano e si") + print(" dichiara. Ampiezze scelte a priori sull'unico precedente misurato nel progetto:") + print(" il gap-through-stop di SKH01 (sl 2% modellato -> -11/-23% realizzato).") + print(f"\n liquidazione se la perdita a leva supera {WIPE:.0%} dell'equity in un giorno.") + print(f"\n {'scenario':>22}" + "".join(f"{f'k={k:g}':>10}" for k in KS_STRESS) + f"{' k*':>8}") + s0s = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, + patrimoniale=PATRIM, seed=SEED, n_paths=1_500) + g0s = [float(np.median(s0s["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))] + print(f" {'nessuno stress':>22}" + "".join(f"{g:>10.1%}" for g in g0s) + + f"{f'{KS_STRESS[int(np.argmax(g0s))]:g}x':>8}") + for cpct in (0.10, 0.15, 0.20): + for panno in (0.20, 1.00): + ss = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, + patrimoniale=PATRIM, seed=SEED, crash_pct=cpct, crash_p_anno=panno, + n_paths=1_500) + gg = [float(np.median(ss["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))] + print(f" {f'-{cpct:.0%} ogni {1/panno:.0f}a':>22}" + + "".join(f"{g:>10.1%}" for g in gg) + + f"{f'{KS_STRESS[int(np.argmax(gg))]:g}x':>8}") + print("\n Lettura: basta UN giorno fuori campione all'anno perche' il massimo collassi da") + print(" due cifre a poche unita', e il ramo destro della curva e' il primo ad andare a") + print(" -100%. La crescita a leva alta e' comprata vendendo la coda che il dataset non") + print(" contiene: e' la stessa firma che il gate `implausible_sharpe` cerca (rischio FUORI") + print(" dal dataset), applicata alla politica invece che alla strategia.") + + # ------------------------------------------------------------------ (5) + sezione("(5) ESEGUIBILITA' — quanto di questa griglia esiste davvero sul conto") + import json + cfg = json.loads((ROOT / "config" / "live.json").read_text()) + frac = cfg["max_notional_per_asset_frac"] + lordo_max = frac * 2 # 2 asset (BTC, ETH) + print(f"\n (a) IL CAP DI CONFIG. max_notional_per_asset_frac = {frac} su 2 asset") + print(f" -> nozionale LORDO massimo = {lordo_max:.2f}x l'equity. **k > {lordo_max:.2f}x NON e'") + print(" eseguibile senza toccare `config/live.json`**, e il 26/07 il progetto ha legato") + print(" una conclusione a quel parametro con un test apposta") + print(" (`test_leva_massima_da_config_resta_sotto_o_uguale_a_1x`): alzare il cap ROMPE") + print(" quel test, che e' esattamente il suo scopo. Quindi 11 delle 14 celle di questa") + print(" griglia sono TEORICHE per costruzione.") + print(f" min_order ${cfg['min_order_usd']} · disaster-SL {cfg['disaster_sl_pct']:.0%} · " + f"cap $ {cfg['max_notional_per_asset_usd']:,} (binding: min(quello, equity×{frac}))") + + print("\n (b) IL KNOB `target_vol` DI TP01 — ipotesi mia: il leverage cap 2x lo strozza.") + from src.data.downloader import load_data # noqa: E402 + from src.strategies.trend_portfolio import ( # noqa: E402 + CANONICAL, TrendPortfolio, resample_1d, simple_returns) + import pandas as pd # noqa: E402 + dfs = {a: resample_1d(load_data(a, "1h")) for a in ("BTC", "ETH")} + def tp01_knob(tv: float, lev: float) -> tuple[float, float, float]: + """(vol realizzata annua, Sharpe, quota di barre incollate al leverage cap) del solo TP01 + 50/50 BTC-ETH, netto fee, con `target_vol=tv` e `leverage=lev`.""" + serie, atcap = {}, [] + for a, df in dfs.items(): + tp = TrendPortfolio(**{**CANONICAL, "target_vol": tv, "leverage": lev}) + tgt = np.nan_to_num(np.asarray(tp.target_series(df), float)) + ret = simple_returns(df["close"].values.astype(float)) + pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] + net = pos * ret - 0.0005 * np.abs(np.diff(pos, prepend=0.0)) + net[0] = 0.0 + serie[a] = pd.Series(net, index=pd.to_datetime(df["datetime"])) + atcap.append(float((np.abs(tgt) >= lev - 1e-9).mean())) + J = pd.concat(serie, axis=1, join="inner").fillna(0.0) # le due serie hanno lunghezze + x = (0.5 * J["BTC"] + 0.5 * J["ETH"]).values # diverse: si allineano sulle date + return (float(x.std() * 365 ** 0.5), float(x.mean() / x.std() * 365 ** 0.5), + float(np.mean(atcap))) + + rif = tp01_knob(0.20, 2.0)[0] # il canonico, calcolato PRIMA di usarlo come riferimento + print(f"\n {'target_vol':>11}{'lev cap':>9}{'x NOMINALE':>12}{'vol realizz.':>14}" + f"{'x OTTENUTO':>12}{'Sharpe':>9}{'quota barre al cap':>21}") + for lev in (2.0, 4.0): + for tv in (0.10, 0.20, 0.30, 0.40, 0.60): + v, sh, ac = tp01_knob(tv, lev) + print(f" {tv:>11.0%}{lev:>9.1f}{tv/0.20:>12.2f}{v:>14.2%}{v/rif:>12.2f}{sh:>9.2f}" + f"{ac:>21.1%}") + v20, sh20, _ = tp01_knob(0.20, 2.0) + print(f"\n ❌ IPOTESI REFUTATA, e la refutazione e' piu' utile dell'ipotesi. 'x OTTENUTO'") + print(" segue 'x NOMINALE' fino a target_vol 60% (3.00 nominale -> 2.98 ottenuto) e le") + print(" barre incollate al cap sono l'1.0%: **il leverage cap 2x NON e' binding**, quindi") + print(" il knob e' uno scalare lineare pulito. Lo Sharpe e' invariante a ogni cella") + print(f" (1.31 ovunque) — che e' esattamente il punto: la lente con cui `target_vol=20%`") + print(" fu scelto NON puo' distinguere queste righe, e infatti non le distingue.") + print(f"\n ⚠️ Sottoprodotto da registrare: a `target_vol=20%` la vol REALIZZATA di TP01 e'") + print(f" {v20:.2%}, non 20%. Il target vale sulla posizione quando c'e'; TP01 e' long-flat e") + print(" sta flat una parte del tempo, quindi l'etichetta sovrastima il rischio preso di") + print(" ~40%. Chi legge '20%' e lo confronta con un altro sistema sta confrontando un") + print(" parametro con una misura.") + print("\n Cio' che resta binding e' altro: (i) il cap di nozionale di config, punto (a);") + print(" (ii) SKH01 (25% del libro) NON e' vol-targeted affatto (misurato 26/07) — li'") + print(" l'unico modo di alzare la leva e' moltiplicare la size, cioe' margine e") + print(" liquidazione vere, non un parametro di sizing.") + + print("\n (c) COSA COSTA LA LEVA E NON E' NEL MODELLO SOPRA (tutti a sfavore di k alto):") + print(" · la fee scala LINEARMENTE con k (3,5 bps/lato oggi) e SKH01 e' ~4x piu'") + print(" fee-sensibile di TP01 (curva misurata il 26/07: -0.017 Sharpe/bps di book);") + print(" · il funding dei perp e il costo del margine oltre 1x non sono modellati;") + print(" · il min_order $5 su un conto da $635 e la granularita' che ne consegue;") + print(" · l'impatto: il modello assume ritorno indipendente dalla size (vedi sez. 1).") + print(" -> i numeri a k>1 sono un TETTO, non una stima.") + + # ------------------------------------------------------------------ verdetto + sezione("VERDETTO") + s500 = risultati[500] + jj = {k: KS.index(k) for k in (1.00, 1.50, 2.00)} + righe = [] + for nome in BERSAGLI: + for k, j in jj.items(): + a, _, _, p20 = anni_cond(s500["hit"][nome][:, j]) + righe.append((nome, k, a, p20, (s500["dd"][:, j] > DD_SOGLIA).mean())) + print(f"\n A €500/mese (il piano dichiarato), zona ESEGUIBILE e primo passo oltre:") + print(f" {'bersaglio':>14}{'k':>7}{'anni (p50)':>13}{'P<=20a':>9}{'P(DD>30%)':>11}") + for nome, k, a, p20, pdd in righe: + print(f" {nome:>14}{k:>6.2f}x{fmt_anni(a):>13}{p20:>9.0%}{pdd:>11.0%}") + print(f""" + GATE — cosa e' girato e cosa no (un gate non girato si dichiara, non si omette): + · `marginal_vs_tp01` NON APPLICABILE. Non c'e' un flusso di ritorni nuovo da giudicare + marginalmente: k e' uno SCALARE sul libro esistente, quindi la + correlazione col baseline e' 1.00 per costruzione. + · `deflated_sharpe` NON APPLICABILE, e vale la pena dire perche': lo Sharpe e' + INVARIANTE a k (misurato, sez. 5b: 1.31 a ogni cella del knob). + Un gate costruito sullo Sharpe da' lo stesso verdetto su tutta la + griglia — non e' che lo passi, e' che non vede la variabile. + · null del de-levering NON e' un gate da superare qui: e' L'OGGETTO della misura, percorso + in entrambi i versi (k<1 e k>1). E lo conferma: ogni 'meno DD' via + leva e' de-levering per definizione, ogni 'piu' crescita' e' leva. + · fortuna d'ancora GESTITA col fattore MISURATO ×{DP.DELUCK} sul drift (26/07), non con una + banda nuova: la leva non ha una propria ancora, moltiplica il drift. + · `implausible_sharpe` Non girato come funzione (darebbe lo stesso verdetto a ogni k, vedi + sopra); il controllo di plausibilita' e' stato fatto sull'OUTPUT ed + e' FALLITO dal modello a leva alta: capitale mediano ${np.median(s0['cap'][:, jstar]):,.0f} + da $600 in 25 anni. La firma 'rischio fuori dal dataset' c'e', ma + vive nella POLITICA, non nella strategia. + · causalita' Ereditata: la serie viene da `CC.book_series('hourly')`, macchineria + gia' validata; qui non si costruisce nessun segnale nuovo. + · risoluzione MC DICHIARATA: {ris_e:.2%}/anno nella regione eseguibile, {ris:.2%}/anno sulla + griglia intera. La posizione del massimo NON e' risolta. + + 1. IL LIBRO STA A SINISTRA DEL MASSIMO, E DI MOLTO: k* empirico ~10-14x (banda, non un + numero: a 1500 path cade a 10x, a 2500 a {kbest:.0f}x), il libro gira a 1x = {1.0/kbest:.0%} di Kelly + e raccoglie il {lg(g_med[j1])/lg(gbest):.0%} della crescita massima in log. Non e' un plateau + (come lo era il peso 75/25): e' il ramo ripido della curva. Sulla lente della CRESCITA + la scelta canonica `target_vol=20%` non e' ottimale — ed e' un fatto, perche' quella + scelta fu fatta su una lente di Sharpe, che alla leva e' INVARIANTE. + + 2. MA k* NON E' UN NUMERO SU CUI SI PUO' AGIRE, per tre ragioni misurate qui: + (a) e' lineare nell'errore del drift, stimato su {len(r)/365:.1f} anni (sez. 1-bis); + (b) e' comprato vendendo una coda che il dataset NON contiene — un giorno di crash + all'anno lo riporta a poche unita' (sez. 4); + (c) 11 celle su 14 non sono eseguibili: il cap di config tiene il nozionale lordo a + {lordo_max:.2f}x l'equity. (Il knob `target_vol` invece FUNZIONA — misurato, sez. 5b: il + leverage cap 2x non morde. L'ostacolo e' la config e SKH01, non il sizing di TP01.) + + 3. IL BERSAGLIO CAMBIA LA RISPOSTA, e nel verso opposto a quello che la domanda suggerisce. + Su $50k a €500/mese la leva quasi non serve: a k=1 ci si arriva in ~5 anni con P=100%, + perche' fino a quella taglia il capitale lo portano i BONIFICI, non il rendimento + (misura indipendente del 26/07: al traguardo l'80% viene dal rendimento, ma i primi + anni no). Sul muro netto la leva morde: e' proprio dove il rischio di rovina diventa + non-recuperabile, perche' zero e' assorbente e si ricomincia con lo stesso stipendio. + + 4. IL BUCO STRUTTURALE, che e' il risultato piu' trasferibile: **ogni gate di questo + progetto e' invariante alla leva**. `study_family_honest`, il deflated-Sharpe, + `marginal_vs_tp01`, `implausible_sharpe`, il null del de-levering, `weights_tilt_null` + — tutti giudicano forma della distribuzione, correlazioni o PESI RELATIVI, e nessuno + vede la SCALA. Per questo `target_vol=20%` e' sopravvissuto due mesi di scrutinio + senza mai essere esaminato: non c'era una lente che potesse bocciarlo. + + 5. COSA RESTA DI OPERATIVO: nulla da cambiare oggi. Il gradino ESEGUIBILE (k da 1.00x a + 1.25-1.50x, cioe' alzare il cap di config) esiste e non e' rumore MC, ma passa per una + modifica a un file di produzione su un conto con soldi veri, e nessun gate del progetto + lo autorizza: `weights_tilt_null` giudica i PESI, non la scala, e non esiste un gate + 'politica di leva'. Il candidato naturale sarebbe misurare la stessa curva sul canale + FUNDED, dove il vincolo binding e' gia' una regola di DD per-conto e il nozionale non + e' il proprio. + """) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_oi_pin.py b/scripts/research/r0822_oi_pin.py new file mode 100644 index 0000000..87b1f52 --- /dev/null +++ b/scripts/research/r0822_oi_pin.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python +"""r0822_oi_pin.py — OI-PIN: il prezzo e' attratto verso gli strike a massimo open interest +nelle ore che precedono la scadenza Deribit? (ondata 2026-08-22, filone OI-PIN) + +CONTESTO / PERCHE' QUESTO FILONE NON E' "CALENDARIO GIA' MORTO" +-------------------------------------------------------------- +Il 02/07 il progetto ha bocciato il **calendario** delle scadenze Deribit (drift post-expiry +weekly/monthly/quarterly): 0/24 celle a Bonferroni, e il pattern si INVERTE proprio sul +quarterly, dove l'open interest massimo dovrebbe amplificarlo. Quel test guardava le DATE. +Qui si guarda l'OPEN INTEREST VERO, per strike, ora per ora (`data/raw/cb_chain/`): +il meccanismo proposto non e' "il venerdi' e' speciale" ma "il market maker corto gamma +ricompra/rivende verso lo strike dove sta la sua esposizione". E' un meccanismo NON di +calendario, quindi ammissibile secondo la sezione 5 del brief. + +IPOTESI (dichiarata PRIMA di misurare) +-------------------------------------- +H1 Il prezzo deriva VERSO il livello a massima concentrazione di OI / gamma / max-pain nelle + ultime ore prima della scadenza (statistica `toward = sign(K - S_t) * r_{t->T}` > 0). +H2 L'effetto e' piu' forte a poche ore dalla scadenza e sulle scadenze grandi (venerdi'). +H0-ATTESA DELL'AUTORE: **negativa**. Motivi dichiarati in anticipo: + (a) il crypto e' 24/7, senza il "chiusura di borsa" che genera il pinning azionario classico; + (b) l'OI Deribit e' grande ma il perp/spot che lo dovrebbe muovere e' molto piu' grande; + (c) il progetto ha gia' misurato che ogni effetto di finestra su BTC/ETH e' rumore; + (d) e soprattutto: `K_oi` sta quasi sempre VICINO allo spot, quindi qualunque statistica + "il prezzo va verso K" e' contaminata dalla POSIZIONE del livello, non dal suo OI. +Per questo il null location-matched (regola codificata il 02/07 sui livelli di Fibonacci) +non e' un contorno: e' IL test. + +CONVENZIONE CAUSALE (verificata da un assert nel codice) +-------------------------------------------------------- +Barre 1h open-labeled: la barra etichettata `tau` copre [tau, tau+1h) e il suo close e' il +prezzo a `tau+1h`. + * snapshot catena all'ora `hh` -> righe con ts in [hh, hh+1h) + * decisione a fine barra `hh` -> prezzo eseguibile = close(hh) = prezzo a hh+1h + * quindi entrata a `hte` ore dalla scadenza <=> snapshot a dte = hte+1 ore + * uscita al prezzo di regolamento = close della barra (E-1h) = prezzo a E (08:00 UTC) +`A.causality_ok` NON e' applicabile (il segnale non e' funzione del solo feed OHLC): la +causalita' e' garantita per costruzione e verificata da `_assert_causalita()`. + +Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_oi_pin.py +""" +from __future__ import annotations + +import glob +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +import altlib as A # noqa: E402 + +STORE = ROOT / "data" / "raw" / "cb_chain" +ASSETS = ("BTC", "ETH") +FEE_SIDE_REAL = 0.00035 # 3,5 bps/lato = fee Deribit REALE del libro (7 bps RT) +FEE_SIDE_CONS = A.FEE_SIDE # 5 bps/lato = convenzione conservativa dei backtest del progetto +MAX_H = 50 # ore prima della scadenza da tenere in memoria +HTE_GRID = (1, 2, 3, 6, 12, 24, 36, 48) # ore-alla-scadenza all'INGRESSO +RULES = ("oi", "gex", "mp") # max OI · max gamma*OI · max pain +PLACEBOS = ("rand", "adj", "jit", "mid", "mr7") +SEED = 20260822 +RNG = np.random.default_rng(SEED) + + +# =========================================================================== dati +def _files() -> list[str]: + fs = sorted(glob.glob(str(STORE / "*.parquet"))) + if not fs: + raise FileNotFoundError(f"{STORE} vuoto o assente") + return fs + + +def load_near(max_h: int = MAX_H) -> pd.DataFrame: + """Catena filtrata alle sole ore vicine a scadenza, letta file per file (RAM: 7 GB in due).""" + cols = ["asset", "strike", "option_type", "ts", "exp", + "open_interest", "gamma", "quote_status"] + out = [] + for p in _files(): + d = pd.read_parquet(p, columns=cols) + d["ts"] = pd.to_datetime(d["ts"], utc=True) + d["exp"] = pd.to_datetime(d["exp"], utc=True) + d["hh"] = d["ts"].dt.floor("h") + d["dte_h"] = (d["exp"] - d["hh"]).dt.total_seconds() / 3600.0 + d = d[(d["dte_h"] > 0) & (d["dte_h"] <= max_h)] + if len(d): + out.append(d) + del d + d = pd.concat(out, ignore_index=True) + # una riga presente non e' un dato presente: OI mancante o quota rotta non conta + d = d[d["open_interest"].notna() & (d["open_interest"] > 0)] + d = (d.sort_values("ts") + .drop_duplicates(subset=["asset", "exp", "hh", "strike", "option_type"], keep="last") + .reset_index(drop=True)) + return d + + +def spot_1h() -> dict[str, pd.Series]: + s = {} + for a in ASSETS: + df = A.get(a, "1h") + s[a] = pd.Series(df["close"].to_numpy(float), + index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))) + return s + + +# =========================================================================== livelli +def _max_pain(K: np.ndarray, oi_c: np.ndarray, oi_p: np.ndarray) -> float: + """Strike che minimizza il payout complessivo verso i detentori (max pain classico).""" + d = K[None, :] - K[:, None] # d[i,j] = K_j - K_i (K_i = candidato) + pay = (np.maximum(-d, 0.0) * oi_c[None, :]).sum(1) + (np.maximum(d, 0.0) * oi_p[None, :]).sum(1) + return float(K[int(np.argmin(pay))]) + + +def levels_table(chain: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFrame: + """Un record per (asset, scadenza, ora): i 3 livelli candidati + i 4 placebo di strike + + le misure di concentrazione. I placebo sono LOCATION-MATCHED (regola 02/07).""" + rows = [] + piv = (chain.groupby(["asset", "exp", "hh", "strike", "option_type"], sort=False) + .agg(oi=("open_interest", "sum"), gm=("gamma", "mean")).reset_index()) + for (asset, exp, hh), g in piv.groupby(["asset", "exp", "hh"], sort=False): + S = spot[asset].asof(hh) # prezzo a hh+1h = close(barra hh) + if not np.isfinite(S) or S <= 0: + continue + K = np.sort(g["strike"].unique().astype(float)) + if len(K) < 3: + continue + oi_c = np.zeros(len(K)); oi_p = np.zeros(len(K)); gx = np.zeros(len(K)) + idx = {k: i for i, k in enumerate(K)} + for k, ot, o, gm in zip(g["strike"].to_numpy(float), g["option_type"].to_numpy(), + g["oi"].to_numpy(float), g["gm"].to_numpy(float)): + i = idx[float(k)] + (oi_c if ot == "C" else oi_p)[i] += o + gx[i] += o * (gm if np.isfinite(gm) else 0.0) + oi = oi_c + oi_p + tot = oi.sum() + if tot <= 0: + continue + i_oi = int(np.argmax(oi)) + lv = { + "oi": float(K[i_oi]), + "gex": float(K[int(np.argmax(gx))]) if gx.max() > 0 else np.nan, + "mp": _max_pain(K, oi_c, oi_p), + } + # ---- placebo location-matched ------------------------------------------------- + # rand: permutare l'OI fra gli strike e riprendere l'argmax equivale ESATTAMENTE a + # estrarre uno strike uniforme dalla griglia (l'argmax di un vettore permutato + # e' uniforme sulle posizioni) -> lo si implementa cosi', ed e' esatto. + p_rand = float(K[RNG.integers(len(K))]) + # adj: lo strike ADIACENTE al max-OI (stesso quartiere, OI diverso) + j = i_oi + (1 if (i_oi == 0 or (i_oi < len(K) - 1 and RNG.random() < 0.5)) else -1) + p_adj = float(K[int(np.clip(j, 0, len(K) - 1))]) + # jit: strike arbitrario alla STESSA distanza dallo spot ("0.618 vs 0.58?") + dist = abs(K - S) + d0 = abs(lv["oi"] - S) + cand = np.where((dist >= 0.5 * d0) & (dist <= 1.5 * d0) & (K != lv["oi"]))[0] + p_jit = float(K[cand[RNG.integers(len(cand))]]) if len(cand) else np.nan + # mid: punto medio della griglia quotata = puro artefatto di copertura, zero info OI + p_mid = float(0.5 * (K[0] + K[-1])) + rows.append(dict( + asset=asset, exp=exp, hh=hh, dte_h=float((exp - hh).total_seconds() / 3600.0), + S=float(S), n_k=len(K), tot_oi=float(tot), + k_oi=lv["oi"], k_gex=lv["gex"], k_mp=lv["mp"], + p_rand=p_rand, p_adj=p_adj, p_jit=p_jit, p_mid=p_mid, + top_share=float(oi[i_oi] / tot), + hhi=float(((oi / tot) ** 2).sum()), + k_step=float(np.median(np.diff(K))) if len(K) > 1 else np.nan, + )) + return pd.DataFrame(rows) + + +# =========================================================================== il FATTO +def build_events(lev: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFrame: + """Un evento per (asset, scadenza, hte): livelli, prezzo d'ingresso, prezzo di regolamento.""" + ev = [] + mr7 = {a: spot[a].rolling(24 * 7, min_periods=24).mean() for a in ASSETS} + for a in ASSETS: + s = spot[a] + sub = lev[lev["asset"] == a] + for exp, g in sub.groupby("exp"): + t_set = pd.Timestamp(exp) - pd.Timedelta(hours=1) # barra il cui close e' a E + if t_set not in s.index: + continue + S_T = float(s.loc[t_set]) + gi = g.set_index("hh") + for hte in HTE_GRID: + hh = pd.Timestamp(exp) - pd.Timedelta(hours=hte + 1) + if hh not in gi.index or hh not in s.index: + continue + r = gi.loc[hh] + S = float(s.loc[hh]) # close(hh) = prezzo a E-hte + if not np.isfinite(S) or S <= 0: + continue + rec = dict(asset=a, exp=exp, hte=hte, hh=hh, S=S, S_T=S_T, + ret=S_T / S - 1.0, n_k=int(r["n_k"]), tot_oi=float(r["tot_oi"]), + top_share=float(r["top_share"]), hhi=float(r["hhi"]), + k_step=float(r["k_step"]), + dow=int(pd.Timestamp(exp).dayofweek)) + lvls = {"oi": r["k_oi"], "gex": r["k_gex"], "mp": r["k_mp"], + "rand": r["p_rand"], "adj": r["p_adj"], "jit": r["p_jit"], + "mid": r["p_mid"], "mr7": float(mr7[a].asof(hh))} + for nm, K in lvls.items(): + K = float(K) if K is not None else np.nan + if not np.isfinite(K) or K <= 0: + rec[f"d_{nm}"] = np.nan; rec[f"tw_{nm}"] = np.nan + rec[f"sh_{nm}"] = np.nan + continue + d = (K - S) / S + rec[f"K_{nm}"] = K + rec[f"d_{nm}"] = d + rec[f"tw_{nm}"] = np.sign(d) * rec["ret"] # verso il livello + rec[f"sh_{nm}"] = (abs(S - K) - abs(S_T - K)) / S # avvicinamento + ev.append(rec) + E = pd.DataFrame(ev) + # NULL STATICO CAUSALE (lezione 25/07: il primo null statico di STATARB usava + # sign(mean(segnale)) su tutto il campione = look-ahead; qui la maggioranza e' ESPANDENTE + # e shiftata). Risponde a: "il candidato e' solo 'sempre nello stesso verso' prima della + # scadenza?" — che su 3,7 mesi di mercato direzionale e' l'ipotesi piu' probabile. + E = E.sort_values(["asset", "hte", "hh"]).reset_index(drop=True) + sgn = np.sign(E["d_mp"].to_numpy(float)) + maj = (pd.Series(sgn).groupby([E["asset"], E["hte"]]) + .transform(lambda x: x.expanding().mean().shift(1))) + E["d_maj"] = np.where(np.isfinite(maj), np.sign(maj) * 0.01, np.nan) + E["tw_maj"] = np.sign(E["d_maj"]) * E["ret"] + E["sh_maj"] = np.nan + E["d_long"] = 0.01; E["tw_long"] = E["ret"]; E["sh_long"] = np.nan + E["d_short"] = -0.01; E["tw_short"] = -E["ret"]; E["sh_short"] = np.nan + return E + + +def _assert_causalita(chain: pd.DataFrame, ev: pd.DataFrame) -> str: + """Il livello usato a `hh` deve venire da quote osservate PRIMA del prezzo d'ingresso.""" + bad = int((chain["ts"] < chain["hh"]).sum() + + (chain["ts"] >= chain["hh"] + pd.Timedelta("1h")).sum()) + assert bad == 0, "snapshot fuori dalla propria ora" + dte = (pd.to_datetime(ev["exp"]) - pd.to_datetime(ev["hh"])).dt.total_seconds() / 3600.0 + assert bool((dte == ev["hte"] + 1).all()), "hte non coerente con lo snapshot" + assert bool(((pd.to_datetime(ev["hh"]) + pd.Timedelta(hours=1)) + <= pd.to_datetime(ev["exp"])).all()), "ingresso oltre la scadenza" + return (f"OK — 0/{len(chain)} righe di catena fuori dalla propria ora; " + f"{len(ev)} eventi con dte == hte+1 (ingresso = close(hh), quota vista in [hh,hh+1h))") + + +# =========================================================================== inferenza +def cluster_boot(x: np.ndarray, clusters: np.ndarray, n: int = 2000, seed: int = SEED): + """IC95 della media con ricampionamento dei CLUSTER (una scadenza = 1 cluster: BTC ed ETH + della stessa scadenza non sono osservazioni indipendenti).""" + x = np.asarray(x, float) + m = np.isfinite(x) + x, clusters = x[m], np.asarray(clusters)[m] + if len(x) < 5: + return np.nan, np.nan, np.nan, 0 + uc = np.unique(clusters) + groups = [x[clusters == c] for c in uc] + rng = np.random.default_rng(seed) + draws = np.empty(n) + for i in range(n): + pick = rng.integers(0, len(groups), len(groups)) + draws[i] = np.mean(np.concatenate([groups[j] for j in pick])) + return float(np.mean(x)), float(np.percentile(draws, 2.5)), float(np.percentile(draws, 97.5)), len(uc) + + +def mde(x: np.ndarray, clusters: np.ndarray) -> float: + """Effetto minimo rilevabile (alpha .05 bilaterale, potenza 80%) con questo campione.""" + x = np.asarray(x, float) + m = np.isfinite(x) + x, cl = x[m], np.asarray(clusters)[m] + if len(x) < 3: + return np.nan + nc = max(len(np.unique(cl)), 1) + return float(2.802 * np.std(x) / np.sqrt(nc)) + + +# =========================================================================== strategia +_W: dict = {} + + +def win_1h(asset: str, lo: pd.Timestamp, hi: pd.Timestamp) -> pd.DataFrame: + """Feed 1h tagliato alla finestra della catena: fuori da li' la posizione e' zero per + costruzione, e valutare 70k barre 288 volte costerebbe minuti per nulla.""" + key = (asset, lo, hi) + if key not in _W: + df = A.get(asset, "1h") + dt = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + m = (dt >= lo - pd.Timedelta("2D")) & (dt <= hi + pd.Timedelta("2D")) + _W[key] = df.loc[m].reset_index(drop=True) + return _W[key] + + +def hourly_target(ev: pd.DataFrame, asset: str, rule: str, hte: int, + band: tuple[float, float] | None, sizing: str, + subsample: str, df1h: pd.DataFrame) -> np.ndarray: + """Posizione oraria: entra `hte` ore prima della scadenza nel verso del livello, esce a + scadenza. Se piu' finestre si sovrappongono vince la scadenza PIU' VICINA (una posizione + alla volta = cio' che il conto Deribit puo' davvero fare: strumento unico, netting).""" + idx = pd.DatetimeIndex(pd.to_datetime(df1h["datetime"], utc=True)) + pos = np.zeros(len(idx)) + loc = pd.Series(np.arange(len(idx)), index=idx) + e = ev[(ev["asset"] == asset) & (ev["hte"] == hte)].copy() + if subsample == "fri": + e = e[e["dow"] == 4] + elif subsample == "big": + e = e[e["tot_oi"] >= e["tot_oi"].median()] + d = e[f"d_{rule}"].to_numpy(float) + keep = np.isfinite(d) + if band is not None: + keep &= (np.abs(d) >= band[0]) & (np.abs(d) <= band[1]) + e = e[keep] + d = d[keep] + if len(e) == 0: + return pos + w = np.sign(d) if sizing == "sign" else np.clip(d / 0.01, -1.0, 1.0) + order = np.argsort(e["exp"].to_numpy())[::-1] # lontane prima: la scadenza VICINA sovrascrive + for j in order: + hh = pd.Timestamp(e["hh"].iloc[j]); exp = pd.Timestamp(e["exp"].iloc[j]) + last = exp - pd.Timedelta(hours=2) + if hh not in loc.index or last not in loc.index: + continue + i0, i1 = int(loc[hh]), int(loc[last]) + if i1 >= i0: + pos[i0:i1 + 1] = w[j] + return pos + + +def strat_daily(ev: pd.DataFrame, rule: str, hte: int, band, sizing: str, subsample: str, + fee_side: float) -> tuple[pd.Series, dict]: + """Serie giornaliera netta 50/50 BTC+ETH + metriche, sulla sola finestra della catena.""" + nets, info = {}, {} + lo_w = pd.Timestamp(ev["hh"].min()); hi_w = pd.Timestamp(ev["exp"].max()) + for a in ASSETS: + df = win_1h(a, lo_w, hi_w) + tgt = hourly_target(ev, a, rule, hte, band, sizing, subsample, df) + r = A.eval_weights(df, tgt, fee_side=fee_side) + nets[a] = pd.Series(r["net"], index=r["idx"]) + info[a] = int(np.sum(np.abs(np.diff(np.r_[0.0, tgt])) > 1e-12)) + J = pd.concat(nets, axis=1, join="inner").fillna(0.0) + daily = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + lo = pd.Timestamp(ev["hh"].min()).normalize() + daily = daily[daily.index >= lo] + return daily, info + + +def metr(s: pd.Series) -> dict: + r = s.dropna() + if len(r) < 5: + return dict(sharpe=np.nan, dd=np.nan, cagr=np.nan, n=len(r)) + eq = np.cumprod(1 + r.to_numpy(float)); pk = np.maximum.accumulate(eq) + yrs = max((r.index[-1] - r.index[0]).total_seconds() / 86400 / 365.25, 1e-6) + return dict(sharpe=A._sh(r), dd=float(np.max((pk - eq) / pk)), + cagr=float(eq[-1] ** (1 / yrs) - 1), n=int(len(r))) + + +# =========================================================================== main +def main() -> None: + P = print + P("=" * 100) + P("OI-PIN — pinning verso gli strike a massimo open interest (catena Deribit oraria)") + P("=" * 100) + + # ---------------------------------------------------------------- 1. DATO + chain = load_near() + spot = spot_1h() + lev = levels_table(chain, spot) + ev = build_events(lev, spot) + P("\n[1] DATO") + P(f" righe catena a <= {MAX_H}h dalla scadenza : {len(chain):,}") + P(f" scadenze distinte : {ev.groupby('asset').exp.nunique().to_dict()}") + P(f" finestra : {ev.hh.min()} -> {ev.exp.max()}") + P(f" eventi (asset x scadenza x hte) : {len(ev)}") + P(f" strike per snapshot mediana/min : {lev.n_k.median():.0f} / {lev.n_k.min():.0f}") + P(f" causalita' : {_assert_causalita(chain, ev)}") + P(" ⚠ il collettore scarta gli strumenti con OI<100 -> la griglia e' TRONCATA sulle code:") + P(" il max-OI ne soffre poco (e' un argmax), il MAX PAIN si', perche' e' una somma su tutta") + P(" la catena. Il verdetto su 'mp' va letto come 'max pain sulla catena OI>=100'.") + + # ---------------------------------------------------------------- 2. POTENZA (PRIMA del risultato) + P("\n[2] POTENZA DICHIARATA — calcolata PRIMA di leggere qualunque effetto") + P(" Unita' statistica = una SCADENZA (BTC ed ETH della stessa data non sono indipendenti).") + P(f" {'hte':>4} {'n oss':>6} {'n clus':>7} {'sd(ret)':>9} {'MDE medio/trade':>16} {'MDE ann.':>10}") + for hte in HTE_GRID: + e = ev[ev["hte"] == hte] + m = mde(e["ret"].to_numpy(float), e["exp"].to_numpy()) + ann = m * (365.25 * 24 / hte) if np.isfinite(m) else np.nan + P(f" {hte:>4} {len(e):>6} {e.exp.nunique():>7} {np.nanstd(e['ret']):>9.4f}" + f" {m:>16.4f} {ann:>9.1%}") + P(" LETTURA OBBLIGATORIA: il campione rileva solo effetti ENORMI. A 24h l'MDE e' ~0,5-0,7%") + P(" per trade contro 0,07% di fee: un pinning realistico (pochi bps) e' INVISIBILE qui.") + P(" Quindi un 'non significativo' NON prova assenza; un 'significativo' sarebbe cosi' grande") + P(" da essere sospetto. Il verdetto massimo raggiungibile e' LEAD.") + + # ---------------------------------------------------------------- 3. IL FATTO + P("\n[3] IL FATTO — dove sta il livello e dove va il prezzo") + P(" |ret| verso la scadenza e distanza |d| del livello (mediane):") + P(f" {'hte':>4} {'|ret| med':>10} {'|d_oi| med':>11} {'|d_gex|':>9} {'|d_mp|':>9}" + f" {'d_oi<0':>8} {'top_share':>10}") + for hte in HTE_GRID: + e = ev[ev["hte"] == hte] + P(f" {hte:>4} {e['ret'].abs().median():>10.4f} {e['d_oi'].abs().median():>11.4f}" + f" {e['d_gex'].abs().median():>9.4f} {e['d_mp'].abs().median():>9.4f}" + f" {(e['d_oi'] < 0).mean():>8.2f} {e['top_share'].median():>10.3f}") + P(" ⚠ se 'd_oi<0' e' lontano da 0.50 il livello ha un BIAS DI SEGNO (griglia troncata o") + P(" mercato in trend): una statistica 'il prezzo va verso K' lo erediterebbe.") + + P("\n Statistica di pinning tw = sign(K-S)*ret (media, IC95 cluster-bootstrap su scadenze):") + P(f" {'hte':>4} {'rule':>5} {'media tw':>10} {'IC95 lo':>9} {'IC95 hi':>9} {'sig':>4}" + f" {'shrink med':>11} {'n':>5}") + fact = [] + for hte in HTE_GRID: + e = ev[ev["hte"] == hte] + for rl in RULES: + m, lo, hi, nc = cluster_boot(e[f"tw_{rl}"].to_numpy(float), e["exp"].to_numpy()) + sg = "***" if (np.isfinite(lo) and lo > 0) else ("neg" if (np.isfinite(hi) and hi < 0) else "-") + P(f" {hte:>4} {rl:>5} {m:>10.5f} {lo:>9.5f} {hi:>9.5f} {sg:>4}" + f" {np.nanmedian(e[f'sh_{rl}']):>11.5f} {int(np.isfinite(e[f'tw_{rl}']).sum()):>5}") + fact.append(dict(hte=hte, rule=rl, mean=m, lo=lo, hi=hi)) + F = pd.DataFrame(fact) + P(f" celle con IC95 tutto sopra zero: {int((F.lo > 0).sum())}/{len(F)}" + f" | tutto sotto zero: {int((F.hi < 0).sum())}/{len(F)}") + + P("\n LA STESSA STATISTICA SUI LIVELLI PLACEBO — se sale anche li', NON e' pinning:") + P(f" {'hte':>4} " + " ".join(f"{('tw_' + n):>10}" for n in + ("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short"))) + for hte in HTE_GRID: + e = ev[ev["hte"] == hte] + P(f" {hte:>4} " + " ".join( + f"{np.nanmean(e[f'tw_{n}']):>10.5f}" for n in + ("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short"))) + P("\n QUANTO SONO LO STESSO SEGNALE? accordo del SEGNO di (K-S) fra max-pain e i placebo:") + for n in ("mid", "mr7", "oi", "maj"): + agr = [float(np.nanmean(np.sign(ev.loc[ev.hte == h, "d_mp"]) + == np.sign(ev.loc[ev.hte == h, f"d_{n}"]))) for h in HTE_GRID] + P(f" mp vs {n:<5}: " + " ".join(f"h{h}={a:.2f}" for h, a in zip(HTE_GRID, agr))) + P(f" base rate sign<0 : " + " ".join( + f"{n}={np.nanmean(np.sign(ev[f'd_{n}']) < 0):.2f}" for n in ("oi", "gex", "mp", "mid", "mr7"))) + for a in ASSETS: + sp_ = spot[a] + w = sp_[(sp_.index >= ev.hh.min()) & (sp_.index <= ev.exp.max())] + P(f" deriva {a} nella finestra: {w.iloc[-1] / w.iloc[0] - 1:+.1%}" + f" (se il livello sta quasi sempre da un lato, il candidato e' una scommessa direzionale)") + + # ---------------------------------------------------------------- 4. NULL LOCATION-MATCHED + P("\n[4] NULL LOCATION-MATCHED (regola 02/07: e' la POSIZIONE o e' l'OI?)") + P(" Confronto APPAIATO per evento: tw(livello speciale) - tw(placebo alla stessa distanza).") + P(" placebo: rand=strike uniforme (== permutazione dell'OI, equivalenza esatta) · adj=strike") + P(" adiacente al max-OI · jit=strike arbitrario a distanza 0.5-1.5x (analogo Fib±jitter) ·") + P(" mid=centro della griglia quotata (zero info OI) · mr7=media 7g dello spot (mean-reversion).") + P(f" {'hte':>4} {'rule':>5} " + " ".join(f"{('Δ vs ' + p):>12}" for p in PLACEBOS)) + npos = ntot = 0 + for hte in HTE_GRID: + e = ev[ev["hte"] == hte] + for rl in RULES: + cells = [] + for p in PLACEBOS: + dd = e[f"tw_{rl}"].to_numpy(float) - e[f"tw_{p}"].to_numpy(float) + m, lo, hi, _ = cluster_boot(dd, e["exp"].to_numpy()) + mark = "*" if (np.isfinite(lo) and lo > 0) else (" ") + cells.append(f"{m:>11.5f}{mark}") + ntot += 1 + npos += int(np.isfinite(lo) and lo > 0) + P(f" {hte:>4} {rl:>5} " + " ".join(cells)) + P(f" celle (rule x hte x placebo) in cui lo SPECIALE batte il placebo con IC95>0:" + f" {npos}/{ntot} (atteso per caso ~{0.025 * ntot:.0f})") + + # ---------------------------------------------------------------- 5. CONDIZIONAMENTO + P("\n[5] CONDIZIONAMENTO — la concentrazione di OI e le scadenze grandi cambiano qualcosa?") + P(f" {'hte':>4} {'gruppo':>16} {'media tw_oi':>12} {'IC95 lo':>9} {'IC95 hi':>9} {'n':>4}") + for hte in (3, 12, 24): + e = ev[ev["hte"] == hte] + hi_c = e["top_share"] >= e["top_share"].median() + big = e["tot_oi"] >= e["tot_oi"].quantile(0.75) + for nm, sel in (("conc ALTA", hi_c), ("conc BASSA", ~hi_c), + ("OI top-25%", big), ("venerdi'", e["dow"] == 4)): + x = e.loc[sel, "tw_oi"].to_numpy(float) + m, lo, hh_, _ = cluster_boot(x, e.loc[sel, "exp"].to_numpy()) + P(f" {hte:>4} {nm:>16} {m:>12.5f} {lo:>9.5f} {hh_:>9.5f} {int(np.isfinite(x).sum()):>4}") + + # ---------------------------------------------------------------- 6. STRATEGIA + P("\n[6] STRATEGIA — expectancy netta fee (3,5 bps/lato reali) e serie giornaliera") + bands = {"nessuna": None, "0.2-3%": (0.002, 0.03)} + subs = ("all", "big", "fri") + sizings = ("sign", "prop") + cells, n_trials = [], 0 + for rl in RULES: + for hte in HTE_GRID: + for bn, bd in bands.items(): + for sz in sizings: + for sb in subs: + n_trials += 1 + d, info = strat_daily(ev, rl, hte, bd, sz, sb, FEE_SIDE_REAL) + mm = metr(d) + cells.append(dict(rule=rl, hte=hte, band=bn, sizing=sz, sub=sb, + **mm, ntrade=sum(info.values()))) + C = pd.DataFrame(cells) + P(f" griglia strategia valutata: {n_trials} celle " + f"({len(RULES)} rule x {len(HTE_GRID)} hte x {len(bands)} bande x {len(sizings)} sizing x {len(subs)} sottocampioni)") + P(f" celle con Sharpe > 0: {(C.sharpe > 0).sum()}/{len(C)} (una famiglia di RUMORE sta a ~50%)") + P(" migliori 8 celle per Sharpe (NON e' una selezione: e' il MASSIMO della griglia, che serve al DSR):") + P(C.sort_values("sharpe", ascending=False).head(8).to_string(index=False, + float_format=lambda v: f"{v:.3f}")) + P(" peggiori 3:") + P(C.sort_values("sharpe").head(3).to_string(index=False, float_format=lambda v: f"{v:.3f}")) + + P("\n [6-bis] LA STESSA MACCHINA SUI LIVELLI PLACEBO (stesse finestre, stessa fee, stesso") + P(" sizing): se un placebo regge quanto il livello 'speciale', l'OI non aggiunge nulla.") + nulls = [] + for rl in ("mp", "oi", "mid", "mr7", "rand", "jit", "maj", "short", "long"): + for hte in (3, 6, 12, 24): + for bn, bd in bands.items(): + d, _i = strat_daily(ev, rl, hte, bd, "sign", "all", FEE_SIDE_REAL) + mm = metr(d) + nulls.append(dict(rule=rl, hte=hte, band=bn, sharpe=mm["sharpe"], + cagr=mm["cagr"], dd=mm["dd"])) + N = pd.DataFrame(nulls) + piv = N.pivot_table(index=["hte", "band"], columns="rule", values="sharpe") + P(piv.reindex(columns=["mp", "oi", "mid", "mr7", "rand", "jit", "maj", "short", "long"]) + .to_string(float_format=lambda v: f"{v:6.2f}")) + P(f" celle placebo valutate: {len(N)} (contano come trial: la griglia dichiarata sale)") + + best = C.sort_values("sharpe", ascending=False).iloc[0] + P(f"\n CELLA MIGLIORE = {best['rule']}/hte{int(best['hte'])}/{best['band']}/{best['sizing']}/{best['sub']}") + + # expectancy per trade, in % e in R, sulla cella migliore + e = ev[ev["hte"] == int(best["hte"])].copy() + if best["sub"] == "big": + e = e[e["tot_oi"] >= e["tot_oi"].median()] + elif best["sub"] == "fri": + e = e[e["dow"] == 4] + dd = e[f"d_{best['rule']}"].to_numpy(float) + keep = np.isfinite(dd) + if best["band"] != "nessuna": + keep &= (np.abs(dd) >= 0.002) & (np.abs(dd) <= 0.03) + tw = e.loc[keep, f"tw_{best['rule']}"].to_numpy(float) + dk = np.abs(dd[keep]) + net = tw - 2 * FEE_SIDE_REAL + wins, losses = net[net > 0], net[net < 0] + rr = (wins.mean() / abs(losses.mean())) if len(wins) and len(losses) else np.nan + P(f" trade: {len(net)} · WR {np.mean(net > 0):.1%} · RR medio {rr:.2f}" + f" · WR-knob atteso 1/(1+RR) = {1 / (1 + rr):.1%}" if np.isfinite(rr) else " RR n.d.") + P(f" expectancy NETTA per trade: {np.mean(net):+.5f} ({np.mean(net) * 100:+.3f}%)" + f" | in R (R = distanza dal livello): {np.mean(net / np.maximum(dk, 1e-6)):+.3f} R") + P(f" a fee ZERO: {np.mean(tw):+.5f} -> {'l edge lordo non esiste' if np.mean(tw) <= 0 else 'edge lordo positivo, la fee lo mangia?'}") + + dbest, info = strat_daily(ev, best["rule"], int(best["hte"]), + None if best["band"] == "nessuna" else (0.002, 0.03), + best["sizing"], best["sub"], FEE_SIDE_REAL) + dcons, _ = strat_daily(ev, best["rule"], int(best["hte"]), + None if best["band"] == "nessuna" else (0.002, 0.03), + best["sizing"], best["sub"], FEE_SIDE_CONS) + mb, mc = metr(dbest), metr(dcons) + P(f" serie giornaliera cella migliore fee 3,5bps: Sharpe {mb['sharpe']:+.2f}" + f" · maxDD {mb['dd']:.2%} · CAGR {mb['cagr']:+.2%} · n={mb['n']}g") + P(f" fee 5,0bps: Sharpe {mc['sharpe']:+.2f}" + f" · maxDD {mc['dd']:.2%} · CAGR {mc['cagr']:+.2%}") + # scomposizione: per mese e per ERA DI COLLETTORE (bite:research fino al 30/07, poi pyg) + P("\n SCOMPOSIZIONE (regola 22/08: un contributo positivo si scompone prima di crederci)") + mo = dbest.groupby([dbest.index.year, dbest.index.month]) + P(" " + " · ".join(f"{y}-{m:02d}: Sh {A._sh(g):+.2f} ret {float(np.prod(1 + g) - 1):+.2%} ({len(g)}g)" + for (y, m), g in mo)) + cut = pd.Timestamp("2026-07-30", tz="UTC") + e1, e2 = dbest[dbest.index < cut], dbest[dbest.index >= cut] + P(f" era collettore bite:research (<30/07): Sh {A._sh(e1):+.2f} ret {float(np.prod(1 + e1) - 1):+.2%} ({len(e1)}g)" + f" | pyg (>=30/07): Sh {A._sh(e2):+.2f} ret {float(np.prod(1 + e2) - 1):+.2%} ({len(e2)}g)") + top5 = dbest.sort_values(ascending=False).head(5) + P(f" concentrazione: i 5 giorni migliori valgono {float(np.prod(1 + top5) - 1):+.2%}" + f" su {float(np.prod(1 + dbest) - 1):+.2%} totali" + f" = {100 * np.log1p(top5).sum() / max(np.log1p(dbest).sum(), 1e-9):.0f}% del log-equity") + P(" ⚠ HOLD-OUT: NON ESISTE. La catena parte dal 2026-05-01 e l'hold-out del progetto e'") + P(" 2025-01-01: TUTTA la serie sta dentro l'hold-out, quindi non c'e' nessuna finestra") + P(" fuori campione da mostrare. Questo NON e' un dettaglio: e' il motivo per cui") + P(" 3,7 mesi di catena non possono produrre uno sleeve.") + + # ---------------------------------------------------------------- 7. GATE + P("\n[7] GATE") + P(f" causalita' : {_assert_causalita(chain, ev)}") + P(" A.causality_ok : NON GIRATO — non applicabile (il segnale non e' funzione del solo") + P(" feed OHLC; la causalita' e' garantita dalla convenzione hte=dte-1") + P(" e verificata dall'assert qui sopra).") + + # DSR: conta TUTTI i trial al rialzo + all_sr = [float(v) for v in C["sharpe"].to_numpy(float) if np.isfinite(v)] + all_sr_wide = all_sr + [float(v) for v in N["sharpe"].to_numpy(float) if np.isfinite(v)] + n_fact = len(HTE_GRID) * len(RULES) * (1 + len(PLACEBOS)) + 3 * 4 # celle del fatto + condiz. + trials_tot = len(all_sr_wide) + n_fact + dsr, sr0 = A.deflated_sharpe(mb["sharpe"], all_sr, dbest, dpy=365.25) + dsr_w, _ = A.deflated_sharpe(mb["sharpe"], all_sr_wide, dbest, dpy=365.25) + P(f" deflated Sharpe : DSR {dsr:.3f} (soglia 0.95) · Sharpe-null atteso {sr0:.2f}" + f" · trial contati {len(all_sr)} (griglia strategia)") + P(f" DSR contando ANCHE le celle placebo ({len(all_sr_wide)} trial): {dsr_w:.3f}") + P(f" trial TOTALI dichiarati al rialzo (fatto+null+condiz.+strategia): {trials_tot}") + + # marginal vs TP01 + mv = A.marginal_vs_tp01(dbest) + P(f" marginal vs TP01 : {mv.get('marginal_verdict')} · corr {mv.get('corr_full')}" + f" · uplift w25 full {mv.get('blends', {}).get('w25', {}).get('uplift_full')}" + f" · giorni in comune {mv.get('n_days')}") + P(" ⚠ il gate NON PUO' dare ADDS con questo campione: `multicut_persistent`") + P(" richiede >=2 tagli annuali da >=120 giorni e la serie ne ha ~113 in UN") + P(" anno solo; e `has_insample_edge` e' True per DEFAULT perche' non c'e'") + P(" alcun pre-2025. Il verdetto marginale qui e' strutturale, non informativo.") + + # null del de-levering (obbligatorio su ogni claim di DD) + B = A.tp01_baseline_daily() + J = pd.concat({"B": B, "C": dbest}, axis=1, join="inner").dropna() + if len(J) > 20: + bl = 0.75 * J["B"] + 0.25 * J["C"] + dd_bl = A._dd_ret(bl) + ks = np.linspace(0.05, 1.0, 96) + ok = [(k, A._sh(k * J["B"]), A._dd_ret(k * J["B"])) for k in ks] + cand = [(k, s) for k, s, d_ in ok if d_ <= dd_bl] + kbest = max(cand, key=lambda t: t[1]) if cand else None + P(f" null de-levering : blend 0.75TP01+0.25cand -> Sharpe {A._sh(bl):+.2f} / DD {dd_bl:.2%}" + f" | k*TP01 a pari DD -> k={kbest[0]:.2f} Sharpe {kbest[1]:+.2f}" + if kbest else " null de-levering : non calcolabile") + if kbest: + P(f" esito: {'REFUTED (il de-levering fa meglio)' if kbest[1] >= A._sh(bl) else 'superato'}") + else: + P(" null de-levering : NON GIRATO (sovrapposizione con TP01 troppo corta)") + + # implausible + imp = A.implausible_sharpe(dbest, n_trades=int(best["ntrade"])) + P(f" implausible_sharpe : implausible={imp['implausible']} · {imp.get('reasons')}") + + # anchor: l'ANCORA di questa strategia e' hte (quale ora prima della scadenza si entra) + ab = A.anchor_luck_band( + lambda h: strat_daily(ev, best["rule"], int(h), + None if best["band"] == "nessuna" else (0.002, 0.03), + best["sizing"], best["sub"], FEE_SIDE_REAL)[0], + offsets=list(HTE_GRID), canonical=int(best["hte"])) + P(f" anchor (hte) : canonico {ab.get('canonical'):.2f} al {100 * ab.get('canonical_pctl', np.nan):.0f}° pctl" + f" · MEDIANA ONESTA {ab.get('median'):.2f} · banda [{ab.get('lo'):.2f},{ab.get('hi'):.2f}]" + f" · positive {ab.get('frac_positive'):.0%} · gate_pass={ab.get('gate_pass')}") + + # eseguibilita' a $600 + P(" eseguibilita' $600 :", end=" ") + hc = [] + for a in ASSETS: + df = win_1h(a, pd.Timestamp(ev["hh"].min()), pd.Timestamp(ev["exp"].max())) + tgt = hourly_target(ev, a, best["rule"], int(best["hte"]), + None if best["band"] == "nessuna" else (0.002, 0.03), + best["sizing"], best["sub"], df) + sc = A.eval_weights_smallcap(df, tgt, capital=300.0, fee_side=FEE_SIDE_REAL) + hc.append(f"{a} haircut {sc['sharpe_haircut']:+.3f} ({sc['n_executed_trades']} trade eseguiti)") + P(" · ".join(hc)) + P(" (300$/asset = il cap per-asset del libro live a questo capitale;") + P(" una gamba ±1 muove 300$ >> min-order 5$ -> nessun ordine saltato)") + + # ---------------------------------------------------------------- 8. SINTESI + P("\n[8] SINTESI") + n_fact_pos = int((F.lo > 0).sum()) + P(f" fatto : {n_fact_pos}/{len(F)} celle (rule x hte) con pinning significativo a IC95") + P(f" null loc. : {npos}/{ntot} confronti appaiati in cui il livello 'speciale' batte il placebo") + P(f" strategia : miglior Sharpe della griglia {mb['sharpe']:+.2f} su {mb['n']} giorni, DSR {dsr:.3f}") + P(f" marginale : {mv.get('marginal_verdict')} (strutturalmente non ADDS-abile a 3,7 mesi)") + P("=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_ortho_screen.py b/scripts/research/r0822_ortho_screen.py new file mode 100644 index 0000000..b91e181 --- /dev/null +++ b/scripts/research/r0822_ortho_screen.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +"""r0822_ortho_screen.py — ONDATA 2026-08-22, filone ORTHO-SCREEN (la rete larga, onesta). + +NON e' un'ipotesi: e' un SETACCIO. Sette famiglie di segnale mai (o solo parzialmente) +coperte dalle ondate precedenti, passate TUTTE dallo stesso protocollo, senza eccezioni: + + 1. famiglia DICHIARATA prima (parametri + celle), trial contati AL RIALZO; + 2. A.study_family_honest -> cella scelta IN-SAMPLE-ONLY + deflated-Sharpe di famiglia; + 3. A.marginal_vs_tp01 -> ADDS / NEUTRAL / DILUTES / HEDGE / NOISE / REDUNDANT; + 4. A.causality_ok (+ A.day_boundary_robust dove ha senso, vedi NOTA CONFINE); + 5. banda d'ancora su 8 offset orari (bar 1d ricostruite da 1h) -> la stima onesta e' la MEDIANA; + 6. null del DE-LEVERING su ogni claim di drawdown; + 7. A.eval_weights_smallcap($600) + A.implausible_sharpe sulla cella scelta; + 8. **DSR di SCREEN**: oltre al deflated-Sharpe di famiglia, il deflated-Sharpe calcolato sul + POOL di TUTTE le celle di TUTTE le famiglie. Se scelgo il migliore fra 7 famiglie, i trial + sono 168, non 24: e' l'unico conto onesto per un setaccio. + +PRIOR DICHIARATO PRIMA DI MISURARE: il soffitto direzionale BTC/ETH misurato e' Sharpe ~1.3 e +in 104 ipotesi + 153 agenti (giugno 2026) NESSUNA e' sopravvissuta alla verifica avversariale. +Mi aspetto 0 CANDIDATI. Il valore consegnato e' **quale gate uccide cosa**, perche' questo +restringe lo spazio della prossima ondata. + +LE 7 FAMIGLIE (24 celle ciascuna = 168 trial dichiarati) + RSKEW skew realizzata (da 1h) come PREDITTORE direzionale, non come gate + [nuovo vs blind/agent_40_skewgate, che la usava come FILTRO su un trend] + TACC accelerazione del trend (derivata seconda del log-prezzo) come direzione standalone + VPX relazione volume-prezzo su barre 1d (conferma / divergenza a volume alto e basso) + XDISP dispersione cross-sezionale dei 51 alt HL usata come segnale TIME-SERIES sul mercato + [nuovo vs XS01, dove la dispersione e' un gate sul paniere cross-sectional] + XCORR correlazione realizzata BTC-ETH come STATO di mercato direzionale + [nuovo vs ortho/agent_09 e XAS08, dove la corr gata un book relative-value] + XTAIL struttura dei ritorni dopo uno shock a k-sigma (continuazione vs inversione) + VRAT variance-ratio (persistenza) come CONVINZIONE continua sul TSMOM + [nuovo vs blind/agent_37_hurst, che faceva uno switch BINARIO di modo] + +NOTA CONFINE (perche' day_boundary_robust non gira su tutte): il test sposta le ETICHETTE del +calendario lasciando i prezzi fermi. Per un segnale di solo prezzo (TACC/VPX/XTAIL/VRAT) e' +esattamente il controllo giusto e deve dare INVARIANT. Per un segnale che fa merge_asof su una +serie AUSILIARIA (RSKEW da 1h, XDISP da HL, XCORR da 1h BTC/ETH) spostare l'etichetta in AVANTI +sposta il punto di merge nel FUTURO: sarebbe un LEAK indotto dallo strumento, non un controllo. +Per quelle famiglie il test d'ancora corretto e' la RICOSTRUZIONE delle barre a offset orario +(banda d'ancora, punto 5), che gira su tutte e sette. + +USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_ortho_screen.py +""" +from __future__ import annotations + +import os +import sys +import time +from functools import lru_cache + +import numpy as np +import pandas as pd + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "alt")) +import altlib as A # noqa: E402 + +HL_DIR = A.DATA_DIR +ANCHOR_OFFSETS = (0, 3, 6, 9, 12, 15, 18, 21) +W_BLEND = 0.25 # peso del candidato nel blend con TP01 (convenzione altlib) + + +# =========================================================================== +# INFRASTRUTTURA — barre a offset, merge causale di serie ausiliarie +# =========================================================================== +@lru_cache(maxsize=64) +def bars_at(asset: str, off: int) -> pd.DataFrame: + """Barre 1d ancorate all'ora `off`, ricostruite dal feed 1h certificato. + off=0 deve riprodurre esattamente A.get(asset,'1d') (controllo in _sanity).""" + h = A.get(asset, "1h").copy() + idx = pd.to_datetime(h["timestamp"], unit="ms", utc=True) - pd.Timedelta(hours=off) + h.index = idx + o = (h.resample("1D", label="left", closed="left") + .agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) + .dropna(subset=["open"])) + o["datetime"] = o.index + pd.Timedelta(hours=off) + epoch = pd.Timestamp("1970-01-01", tz="UTC") + o["timestamp"] = ((o["datetime"] - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + return o.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]] + + +def _bar_ms(df: pd.DataFrame) -> int: + d = pd.Series(df["timestamp"].astype("int64")).diff().median() + return int(d) if np.isfinite(d) and d > 0 else 86_400_000 + + +def merge_aux(df: pd.DataFrame, aux_ts: np.ndarray, aux_val: np.ndarray) -> np.ndarray: + """Valore di una serie ausiliaria NOTO alla CHIUSURA di ogni barra di df. + `aux_ts` e' gia' il timestamp in cui il valore diventa noto (fine della sua barra). + merge_asof backward sul close della barra -> mai un dato del futuro: eval_weights + tiene target[i] durante la barra i+1, quindi close[i] e' l'ultimo istante lecito.""" + close_ts = df["timestamp"].astype("int64").values + _bar_ms(df) + left = pd.DataFrame({"timestamp": close_ts}) + right = (pd.DataFrame({"timestamp": np.asarray(aux_ts, dtype="int64"), + "v": np.asarray(aux_val, dtype=float)}) + .dropna().sort_values("timestamp").reset_index(drop=True)) + if right.empty: + return np.full(len(df), np.nan) + return pd.merge_asof(left, right, on="timestamp", direction="backward")["v"].values + + +def roll_z(x: np.ndarray, win: int) -> np.ndarray: + s = pd.Series(np.asarray(x, float)) + mp = max(20, win // 4) + m = s.rolling(win, min_periods=mp).mean() + sd = s.rolling(win, min_periods=mp).std() + return ((s - m) / sd.where(sd > 0)).values + + +def exp_pct(x: np.ndarray, min_obs: int = 180) -> np.ndarray: + """Percentile ESPANDENTE causale (rank fra tutti i valori visti finora, incluso l'attuale).""" + return pd.Series(np.asarray(x, float)).expanding(min_periods=min_obs).rank(pct=True).values + + +def _shape(u: np.ndarray, shape: str) -> np.ndarray: + u = np.nan_to_num(np.asarray(u, float), nan=0.0) + return np.sign(u) if shape == "sign" else np.clip(u, -1.0, 1.0) + + +def _sized(direction: np.ndarray, df: pd.DataFrame) -> np.ndarray: + return A.vol_target(np.nan_to_num(direction, nan=0.0), df, + target_vol=0.20, vol_win_days=30, leverage_cap=2.0) + + +# =========================================================================== +# SERIE AUSILIARIE (calcolate una volta, riusate da tutte le celle) +# =========================================================================== +@lru_cache(maxsize=16) +def skew_aux(asset: str, w_days: int): + """Skew realizzata dei ritorni 1h su finestra trascinata di w_days giorni. + Valore al bar 1h i -> noto alla sua chiusura (ts_i + 1h).""" + h = A.get(asset, "1h") + r = A.log_returns(h["close"].values.astype(float)) + win = w_days * 24 + sk = pd.Series(r).rolling(win, min_periods=win // 2).skew().values + return h["timestamp"].astype("int64").values + 3_600_000, sk + + +@lru_cache(maxsize=4) +def disp_aux(): + """Dispersione cross-sezionale dei 51 alt Hyperliquid (1d, 2024+): deviazione standard + cross-section dei log-ritorni giornalieri. Valore del giorno d noto alla chiusura di d.""" + import glob + files = sorted(glob.glob(str(HL_DIR / "hl_*_1d.parquet"))) + cols = {} + for f in files: + x = pd.read_parquet(f, columns=["timestamp", "close"]) + s = pd.Series(x["close"].values.astype(float), + index=x["timestamp"].astype("int64").values) + cols[os.path.basename(f)] = np.log(s).diff() + M = pd.DataFrame(cols).sort_index() + n_ok = M.notna().sum(axis=1) + disp = M.std(axis=1, ddof=1).where(n_ok >= 15) + ts = M.index.values.astype("int64") + 86_400_000 # noto a fine giornata + return ts, disp.values, int(len(files)) + + +@lru_cache(maxsize=16) +def xcorr_aux(w_days: int): + """Correlazione realizzata BTC-ETH sui ritorni 1h, finestra trascinata w_days giorni.""" + b = A.get("BTC", "1h")[["timestamp", "close"]].rename(columns={"close": "b"}) + e = A.get("ETH", "1h")[["timestamp", "close"]].rename(columns={"close": "e"}) + J = pd.merge(b, e, on="timestamp", how="inner").sort_values("timestamp") + rb = pd.Series(A.log_returns(J["b"].values.astype(float))) + re = pd.Series(A.log_returns(J["e"].values.astype(float))) + win = w_days * 24 + c = rb.rolling(win, min_periods=win // 2).corr(re).values + return J["timestamp"].astype("int64").values + 3_600_000, c + + +# =========================================================================== +# LE 7 FAMIGLIE — factory(tf=..., **params) -> target(df, asset) +# =========================================================================== +def mk_rskew(tf="1d", w_days=30, z_days=365, shape="lin", sgn=-1): + """RSKEW: la skew realizzata recente come PREDITTORE del ritorno successivo. + A-priori (Amaya et al., azioni): skew realizzata alta -> ritorno futuro BASSO (sgn=-1).""" + def target(df, asset): + ts, sk = skew_aux(asset, w_days) + v = merge_aux(df, ts, sk) + u = np.clip(roll_z(v, z_days) / 2.0, -1.0, 1.0) + return _sized(sgn * _shape(u, shape), df) + return target + + +def mk_tacc(tf="1d", H=60, L=20, shape="lin", sgn=1): + """TACC: accelerazione = variazione della PENDENZA log-prezzo su H barre, misurata a L barre + di distanza. Direzione standalone (non e' il LIVELLO del trend). A-priori sgn=+1.""" + def target(df, asset): + c = np.log(df["close"].values.astype(float)) + n = len(c) + slope = np.full(n, np.nan) + slope[H:] = (c[H:] - c[:-H]) / H + acc = np.full(n, np.nan) + acc[L:] = slope[L:] - slope[:-L] + u = np.clip(roll_z(acc, 365) / 2.0, -1.0, 1.0) + return _sized(sgn * _shape(u, shape), df) + return target + + +def mk_vpx(tf="1d", W=30, k=5, mode="confirm", thr=0.5): + """VPX: volume-prezzo su barre 1d (a 1d il volume Deribit e' informativo, a 5m no). + confirm -> segui il movimento SOLO se il volume conferma (z >= thr) + fade_low -> fai il contrario del movimento avvenuto a volume BASSO (z <= -thr) + fade_high -> fai il contrario del movimento avvenuto a volume ALTO (climax, z >= thr)""" + def target(df, asset): + c = df["close"].values.astype(float) + vol = df["volume"].values.astype(float) + n = len(c) + vz = roll_z(np.log(np.where(vol > 0, vol, np.nan)), W) + rk = np.full(n, np.nan) + rk[k:] = c[k:] / c[:-k] - 1.0 + s = np.sign(np.nan_to_num(rk, nan=0.0)) + vzf = np.nan_to_num(vz, nan=0.0) + if mode == "confirm": + d = np.where(vzf >= thr, s, 0.0) + elif mode == "fade_low": + d = np.where(vzf <= -thr, -s, 0.0) + else: # fade_high + d = np.where(vzf >= thr, -s, 0.0) + return _sized(d, df) + return target + + +def mk_xdisp(tf="1d", S=5, trans="z252", shape="lin", sgn=1): + """XDISP: dispersione cross-sezionale dei 51 alt HL come STATO del mercato (time-series), + non come gate di un paniere cross-sectional. Nessun prior forte sul segno -> entrambi in griglia.""" + def target(df, asset): + ts, dsp, _ = disp_aux() + sm = pd.Series(dsp).rolling(S, min_periods=1).mean().values + u = (np.clip(roll_z(sm, 252) / 2.0, -1, 1) if trans == "z252" + else 2.0 * exp_pct(sm, 180) - 1.0) + v = merge_aux(df, ts, u) + return _sized(sgn * _shape(v, shape), df) + return target + + +def mk_xcorr(tf="1d", W=60, trans="z252", shape="lin", sgn=-1): + """XCORR: co-movimento BTC-ETH (corr realizzata) come stato direzionale del mercato. + A-priori debole: corr alta = 'tutto il crypto e' un trade solo' = regime tardo -> sgn=-1.""" + def target(df, asset): + ts, cr = xcorr_aux(W) + u_aux = (np.clip(roll_z(cr, 252 * 24) / 2.0, -1, 1) if trans == "z252" + else 2.0 * exp_pct(cr, 180 * 24) - 1.0) + v = merge_aux(df, ts, u_aux) + return _sized(sgn * _shape(v, shape), df) + return target + + +def mk_xtail(tf="1d", ksig=3.0, N=3, sgn=1, sigwin=30): + """XTAIL: struttura dei ritorni ESTREMI. Dopo |r| > ksig*sigma trascurata, tieni per N barre + la direzione (sgn=+1 continuazione) o il suo opposto (sgn=-1 inversione). + ATTENZIONE dichiarata: sgn=-1 e' mean-reversion, famiglia gia' MORTA -> se vince li', si chiude.""" + def target(df, asset): + c = df["close"].values.astype(float) + r = A.simple_returns(c) + sd = pd.Series(r).rolling(sigwin, min_periods=sigwin // 2).std().shift(1).values + n = len(c) + d = np.zeros(n) + hit = np.where(np.isfinite(sd) & (sd > 0) & (np.abs(r) > ksig * sd))[0] + for i in hit: + d[i:min(n, i + N)] = sgn * np.sign(r[i]) + return _sized(d, df) + return target + + +def mk_vrat(tf="1d", q=10, LB=365, conv="gate", lf=True): + """VRAT: variance-ratio VR(q)=Var(r_q)/(q*Var(r_1)) su finestra LB come CONVINZIONE CONTINUA + sul TSMOM (non uno switch binario di modo come blind/agent_37_hurst). + gate -> pesa il trend solo dove VR>1 (persistente), altrimenti flat + flip -> inverte il trend dove VR<1 (anti-persistente)""" + HZ = (30, 90, 180) + + def target(df, asset): + c = df["close"].values.astype(float) + lc = np.log(c) + n = len(c) + r1 = pd.Series(A.log_returns(c)) + rq = pd.Series(np.concatenate([np.full(q, np.nan), lc[q:] - lc[:-q]])) + v1 = r1.rolling(LB, min_periods=LB // 2).var() + vq = rq.rolling(LB, min_periods=LB // 2).var() + vr = (vq / (q * v1.where(v1 > 0))).values + dirs = np.zeros(n) + for H in HZ: + m = np.zeros(n) + m[H:] = np.sign(c[H:] / c[:-H] - 1.0) + dirs += m / len(HZ) + if lf: + dirs = np.maximum(dirs, 0.0) + k = np.nan_to_num(vr, nan=1.0) - 1.0 + w = np.clip(k * 5.0, 0.0, 1.0) if conv == "gate" else np.clip(k * 5.0, -1.0, 1.0) + return _sized(dirs * w, df) + return target + + +# ---- griglie DICHIARATE (24 celle ciascuna, 7 famiglie = 168 trial) ---------- +def _grid(**axes): + keys = list(axes) + out = [{}] + for k in keys: + out = [dict(o, **{k: v}) for o in out for v in axes[k]] + return out + + +FAMILIES = [ + dict(code="RSKEW", factory=mk_rskew, price_only=False, + grid=_grid(w_days=[14, 30, 60], z_days=[180, 365], shape=["lin", "sign"], sgn=[1, -1])), + dict(code="TACC", factory=mk_tacc, price_only=True, + grid=_grid(H=[20, 60, 120], L=[5, 20], shape=["lin", "sign"], sgn=[1, -1])), + dict(code="VPX", factory=mk_vpx, price_only=True, + grid=_grid(W=[30, 90], k=[5, 20], mode=["confirm", "fade_low", "fade_high"], thr=[0.5, 1.0])), + dict(code="XDISP", factory=mk_xdisp, price_only=False, + grid=_grid(S=[1, 5, 20], trans=["z252", "pct"], shape=["lin", "sign"], sgn=[1, -1])), + dict(code="XCORR", factory=mk_xcorr, price_only=False, + grid=_grid(W=[20, 60, 120], trans=["z252", "pct"], shape=["lin", "sign"], sgn=[1, -1])), + dict(code="XTAIL", factory=mk_xtail, price_only=True, + grid=_grid(ksig=[2.5, 3.0], N=[1, 3, 10], sgn=[1, -1], sigwin=[30, 90])), + dict(code="VRAT", factory=mk_vrat, price_only=True, + grid=_grid(q=[5, 10, 20], LB=[180, 365], conv=["gate", "flip"], lf=[True, False])), +] + + +# =========================================================================== +# GATE AGGIUNTIVI +# =========================================================================== +def cand_daily_at(fn, off: int) -> pd.Series: + """Serie giornaliera 50/50 BTC+ETH del candidato con barre ancorate all'ora `off`.""" + ser = {} + for a in A.CERTIFIED: + df = bars_at(a, off) + ev = A.eval_weights(df, A._call_target(fn, df, a)) + ser[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(ser, axis=1, join="inner").fillna(0.0) + return A._to_daily(0.5 * J[A.CERTIFIED[0]] + 0.5 * J[A.CERTIFIED[1]]) + + +def anchor_report(fn) -> dict: + """Banda d'ancora su 8 offset orari: Sharpe standalone E uplift del blend. + La stima ONESTA e' la MEDIANA della banda, non l'ancora 00:00 (lezione 02/07).""" + band = A.anchor_luck_band(lambda o: cand_daily_at(fn, o), ANCHOR_OFFSETS) + B = A.tp01_baseline_daily() + ups = {} + for o in ANCHOR_OFFSETS: + C = cand_daily_at(fn, o) + J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna() + if len(J) > 30: + ups[o] = round(A._sh((1 - W_BLEND) * J["B"] + W_BLEND * J["C"]) - A._sh(J["B"]), 3) + vals = np.array(list(ups.values()), float) if ups else np.array([np.nan]) + band["uplift_per_offset"] = ups + band["uplift_canonical"] = ups.get(0) + band["uplift_median"] = round(float(np.median(vals)), 3) + band["uplift_frac_positive"] = round(float((vals > 0).mean()), 3) + return band + + +def delever_null(C: pd.Series, w: float = W_BLEND) -> dict: + """NULL DEL DE-LEVERING (obbligatorio su ogni claim di meno drawdown, 5 occorrenze nel + progetto): esiste k<1 che, applicato al SOLO TP01, da' lo STESSO maxDD con Sharpe MIGLIORE? + Se si', l'overlay non serve: bastava meno leva.""" + B = A.tp01_baseline_daily() + J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna() + if len(J) < 60: + return dict(run=False, reason="overlap insufficiente") + blend = (1 - w) * J["B"] + w * J["C"] + dd_bl, sh_bl = A._dd_ret(blend), A._sh(blend) + dd_b, sh_b = A._dd_ret(J["B"]), A._sh(J["B"]) + if dd_bl >= dd_b: + return dict(run=True, claims_less_dd=False, dd_blend=round(dd_bl, 4), + dd_tp01=round(dd_b, 4), sharpe_blend=round(sh_bl, 3), + sharpe_tp01=round(sh_b, 3), + note="nessun claim di DD: il blend NON riduce il drawdown -> null non pertinente") + ks = np.linspace(0.05, 1.0, 96) + dds = np.array([A._dd_ret(k * J["B"]) for k in ks]) + k = float(ks[int(np.argmin(np.abs(dds - dd_bl)))]) + sh_k = A._sh(k * J["B"]) + return dict(run=True, claims_less_dd=True, dd_blend=round(dd_bl, 4), dd_tp01=round(dd_b, 4), + sharpe_blend=round(sh_bl, 3), k_equal_dd=round(k, 3), + dd_k=round(A._dd_ret(k * J["B"]), 4), sharpe_k_tp01=round(sh_k, 3), + passes=bool(sh_bl > sh_k), + note=("il blend batte il de-levering" if sh_bl > sh_k + else "REFUTED: k*TP01 da' lo stesso DD con Sharpe migliore")) + + +def smallcap_report(fn) -> dict: + out = {} + for a in A.CERTIFIED: + df = A.get(a, "1d") + sc = A.eval_weights_smallcap(df, A._call_target(fn, df, a), capital=600.0, min_order=5.0) + out[a] = dict(modeled=sc["modeled"]["sharpe"], realistic=sc["realistic"]["sharpe"], + haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"]) + return out + + +def active_window_metrics(C: pd.Series) -> dict: + """Trappola #9 (barre attive vs calendario): una serie che e' 0 per meta' della storia + (XDISP parte nel 2024) ha Sharpe DILUITO di sqrt(frazione attiva). Si riporta anche il + numero sulla sola finestra ATTIVA, senza usarlo per decidere.""" + nz = C[C != 0.0] + if len(nz) < 30: + return dict(active_days=int(len(nz))) + first = nz.index[0] + sub = C[C.index >= first] + return dict(active_days=int(len(nz)), frac_active=round(float(len(nz) / max(1, len(C))), 3), + first_active=str(first.date()), sharpe_active_window=round(A._sh(sub), 3), + maxdd_active_window=round(A._dd_ret(sub), 4)) + + +# =========================================================================== +# SANITY — un setaccio che non si controlla non sta setacciando niente +# =========================================================================== +def sanity() -> None: + print("### SANITY") + for a in A.CERTIFIED: + b0, g = bars_at(a, 0), A.get(a, "1d") + n = min(len(b0), len(g)) + d = float(np.max(np.abs(b0["close"].values[:n] - g["close"].values[:n]))) + dts = float(np.max(np.abs(b0["timestamp"].values[:n] - g["timestamp"].values[:n]))) + print(f" bars_at({a},0) vs A.get({a},'1d'): n={n}/{len(g)} max|dclose|={d:.6g} max|dts|={dts:.0f}") + assert d < 1e-9 and dts == 0, "ricostruzione barre a offset 0 NON identica al feed certificato" + ts, dsp, nfiles = disp_aux() + ok = np.isfinite(dsp) + print(f" disp_aux: {nfiles} file HL, {int(ok.sum())} giorni con >=15 asset, " + f"da {pd.Timestamp(ts[ok][0], unit='ms', tz='UTC').date()} " + f"a {pd.Timestamp(ts[ok][-1], unit='ms', tz='UTC').date()}") + B = A.tp01_baseline_daily() + print(f" TP01 baseline: n={len(B)} ShFULL={A._sh(B):.3f} ShHOLD={A._sh(B[B.index >= A.HOLDOUT]):.3f}") + print() + + +# =========================================================================== +# MAIN +# =========================================================================== +def main() -> None: + t0 = time.time() + print(__doc__.split("USO:")[0]) + sanity() + + pooled_sh: list[float] = [] + results = [] + + for fam in FAMILIES: + code, fac, grid = fam["code"], fam["factory"], fam["grid"] + t1 = time.time() + print(f"### {code} — {len(grid)} celle dichiarate") + sel = A.select_cell_insample(fac, grid, ("1d",)) + pooled_sh.extend([s for s in sel["all_full_sharpe"] if np.isfinite(s)]) + rep = A.study_family_honest(code, fac, grid, ("1d",)) + ch = rep["chosen"] + if ch is None: + print(f" nessuna cella valida ({rep.get('reason')})\n") + results.append(dict(code=code, n_cells=len(grid), chosen=None, + killed_by="nessuna cella valutabile")) + continue + + fn = fac(tf=ch["tf"], **ch["params"]) + C = A.candidate_daily(fn, tf=ch["tf"]) + m = rep["marginal"]["marginal"] + absr = rep["marginal"]["absolute"]["cells"][0] + + caus = A.causality_ok(fn, tf="1d") + dayb = A.day_boundary_robust(fn, tf="1d") if fam["price_only"] else \ + dict(verdict="NON GIRATO", reason="segnale con merge su serie ausiliaria: " + "spostare l'etichetta in avanti sposta il merge nel FUTURO (leak indotto), " + "non e' un controllo -> vale la banda d'ancora") + anch = anchor_report(fn) + deln = delever_null(C) + smal = smallcap_report(fn) + impl = A.implausible_sharpe(C) + actw = active_window_metrics(C) + + full_sh = A._sh(C) + hold_sh = A._sh(C[C.index >= A.HOLDOUT]) + mm = A._metrics_from_net(C.values, C.index) + + results.append(dict( + code=code, n_cells=len(grid), chosen=ch, fn=fn, C=C, + full_sharpe=round(full_sh, 3), hold_sharpe=round(hold_sh, 3), + maxdd=mm["maxdd"], cagr=mm["cagr"], + corr=m.get("corr_full"), corr_hold=m.get("corr_hold"), + verdict=rep["marginal"]["marginal_verdict"], + earns_slot=rep["earns_slot_marginal"], honest=rep["earns_slot_honest"], + dsr_family=rep["deflated_sharpe"], dsr_pass=rep["dsr_pass"], + insample_sharpe=m.get("cand_insample_sharpe"), + has_insample_edge=m.get("has_insample_edge"), + robust_oos=m.get("robust_oos"), is_hedge=m.get("is_hedge"), + uplift_full=m["blends"]["w25"]["uplift_full"], + uplift_hold=m["blends"]["w25"]["uplift_hold"], + multicut=m.get("multicut_uplift"), abs_grade=rep["marginal"]["abs_grade"], _m=m, + fee_survives=absr.get("fee_survives"), + causality=caus, dayb=dayb, anchor=anch, delever=deln, + smallcap=smal, implausible=impl, active=actw, + )) + + print(f" cella IN-SAMPLE-ONLY: {ch['params']} (IS Sh {ch['insample_sharpe']}, " + f"rango 1/{len(sel['rows'])} in-sample; FULL Sh di quella cella {ch['full_sharpe']})") + print(f" standalone FULL {full_sh:+.3f} | HOLD {hold_sh:+.3f} | maxDD {mm['maxdd']:.1%} | " + f"CAGR {mm['cagr']:+.2%} | IS {m.get('cand_insample_sharpe')}") + print(f" marginale {rep['marginal']['marginal_verdict']:<9} corr->TP01 {m.get('corr_full')} " + f"(hold {m.get('corr_hold')}) uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} " + f"hold {m['blends']['w25']['uplift_hold']}") + print(f" robust_oos={m.get('robust_oos')} insample_edge={m.get('has_insample_edge')} " + f"is_hedge={m.get('is_hedge')} abs={rep['marginal']['abs_grade']} " + f"fee_survives={absr.get('fee_survives')}") + print(f" DSR famiglia {rep['deflated_sharpe']} (atteso null max {rep['expected_null_max']}) " + f"pass={rep['dsr_pass']} earns_slot_honest={rep['earns_slot_honest']}") + print(f" causality ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}") + print(f" confine {dayb.get('verdict')} " + + (f"spread {dayb.get('spread')} per_offset {dayb.get('per_offset')}" + if fam["price_only"] else f"({dayb.get('reason','')[:60]}...)")) + _nan = float("nan") + print(f" ancora Sh canonica {anch.get('canonical', _nan):+.3f} " + f"(pctl {anch.get('canonical_pctl')}) " + f"MEDIANA {anch.get('median', _nan):+.3f} " + f"banda [{anch.get('lo', _nan):+.3f},{anch.get('hi', _nan):+.3f}] " + f"frac>0 {anch.get('frac_positive')}") + print(f" uplift canonico {anch.get('uplift_canonical')} MEDIANA " + f"{anch.get('uplift_median')} frac>0 {anch.get('uplift_frac_positive')}") + print(f" de-levering {deln.get('note')}") + print(f" $600 " + " ".join( + f"{a}: mod {v['modeled']:+.2f} -> real {v['realistic']:+.2f} (haircut {v['haircut']:+.2f}, " + f"{v['n_trades']} trade)" for a, v in smal.items())) + print(f" implausible {impl.get('implausible')} {impl.get('reasons')}") + if actw.get("frac_active", 1.0) < 0.9: + print(f" attive {actw}") + print(f" [{time.time() - t1:.1f}s]\n") + + # ---- DSR DI SCREEN: i trial sono TUTTE le celle di TUTTE le famiglie ------------- + print("### DSR DI SCREEN (il conto onesto per un setaccio: scelgo il meglio fra " + f"{len(pooled_sh)} celle, non fra 24)") + for r in results: + if r.get("chosen") is None: + continue + d, s0 = A.deflated_sharpe(r["full_sharpe"], pooled_sh, r["C"]) + r["dsr_screen"] = round(d, 3) if np.isfinite(d) else None + print(f" {r['code']:<6} Sh {r['full_sharpe']:+.3f} DSR famiglia {r['dsr_family']} " + f"DSR screen {r['dsr_screen']} (null max atteso {s0:.3f})") + print() + + deep_dive(results) + + # ---- TABELLA FINALE -------------------------------------------------------------- + def killer(r) -> str: + if r.get("chosen") is None: + return "nessuna cella valutabile" + why = [] + if not r["causality"]["ok"]: + why.append("CAUSALITY") + if r["verdict"] != "ADDS": + why.append(f"marginal={r['verdict']}") + if not r["has_insample_edge"]: + why.append("no in-sample edge (<0.5)") + if not r["robust_oos"]: + why.append("robust_oos") + if r["is_hedge"]: + why.append("is_hedge") + if not r["dsr_pass"]: + why.append(f"DSR fam {r['dsr_family']}") + if r.get("dsr_screen") is not None and r["dsr_screen"] < 0.95: + why.append(f"DSR screen {r['dsr_screen']}") + if r["dayb"].get("verdict") == "ARTIFACT-RISK": + why.append("confine ARTIFACT-RISK") + um = r["anchor"].get("uplift_median") + if um is not None and np.isfinite(um) and um <= 0: + why.append("ancora: uplift mediano <=0") + if r["delever"].get("claims_less_dd") and not r["delever"].get("passes"): + why.append("null de-levering") + if r["implausible"].get("implausible"): + why.append("implausible_sharpe") + return " + ".join(why) if why else "-- nessun gate lo uccide --" + + print("### TABELLA — ORTHO-SCREEN 2026-08-22 (candidato | trial | ShFULL | hold-out | " + "corr->TP01 | marginale | DSR fam / screen | esito)") + hdr = (f"{'cand':<7}{'trial':>6}{'ShFULL':>8}{'hold':>7}{'maxDD':>8}{'CAGR':>8}" + f"{'corr':>7} {'marginale':<10}{'DSRfam':>7}{'DSRscr':>8} esito / gate che l'ha ucciso") + print(hdr); print("-" * len(hdr)) + for r in results: + if r.get("chosen") is None: + print(f"{r['code']:<7}{r['n_cells']:>6}{'--':>8}{'--':>7}{'--':>8}{'--':>8}{'--':>7} " + f"{'--':<10}{'--':>7}{'--':>8} SCARTATO — {killer(r)}") + continue + ok = (r["verdict"] == "ADDS" and r["dsr_pass"] and (r.get("dsr_screen") or 0) >= 0.95 + and r["causality"]["ok"]) + esito = "CANDIDATO" if ok else "SCARTATO" + print(f"{r['code']:<7}{r['n_cells']:>6}{r['full_sharpe']:>+8.3f}{r['hold_sharpe']:>+7.2f}" + f"{r['maxdd']:>8.1%}{r['cagr']:>+8.1%}{r['corr']:>7.2f} {r['verdict']:<10}" + f"{str(r['dsr_family']):>7}{str(r.get('dsr_screen')):>8} {esito} — {killer(r)}") + print() + print("### FAMIGLIE NON TESTATE (l'elenco di cio' che non ho guardato fa parte del risultato)") + for line in NOT_TESTED: + print(" - " + line) + print(f"\n[totale {time.time() - t0:.1f}s]") + + +def mk_mom(tf="1d", H=5, shape="lin"): + """CONTROLLO per TACC: il LIVELLO del momentum a orizzonte H, stessa pipeline + (z-score 365g -> clip -> vol_target). Serve a rispondere alla domanda della famiglia: + l'ACCELERAZIONE aggiunge qualcosa al LIVELLO, o e' un momentum corto travestito?""" + def target(df, asset): + c = df["close"].values.astype(float) + n = len(c) + m = np.full(n, np.nan) + m[H:] = c[H:] / c[:-H] - 1.0 + u = np.clip(roll_z(m, 365) / 2.0, -1.0, 1.0) + return _sized(_shape(u, shape), df) + return target + + +MOM_CONTROL_GRID = _grid(H=[5, 20, 60], shape=["lin", "sign"]) # 6 celle di CONTROLLO + + +def deep_dive(results) -> None: + """APPROFONDIMENTO sull'unica famiglia arrivata a marginal=ADDS. + Due domande, entrambe misurate: + (a) QUALE sotto-gate di robust_oos ha fallito (clean-year? jackknife? multi-cut?); + (b) l'accelerazione e' distinta dal LIVELLO del momentum corto, o e' quello travestito? + -> 6 celle di CONTROLLO dichiarate (H x shape), che si SOMMANO ai 168 trial.""" + adds = [r for r in results if r.get("chosen") is not None and r["verdict"] == "ADDS"] + if not adds: + print("### APPROFONDIMENTO — nessuna famiglia a marginal=ADDS, niente da approfondire\n") + return + r = max(adds, key=lambda x: x["uplift_hold"] or -9) + print(f"### APPROFONDIMENTO — {r['code']} (l'unica a marginal=ADDS)") + print(f" perche' robust_oos=False: clean_year_uplift={r['_m'].get('clean_year_uplift')} " + f"(serve >0.02) jackknife_min_uplift={r['_m'].get('jackknife_min_uplift')} (serve >0)") + print(f" multicut (uplift a ogni taglio d'anno): {r['multicut']}") + print(f" uplift TP01-up {r['_m'].get('uplift_tp01_up')} / TP01-down {r['_m'].get('uplift_tp01_down')} " + f"| hedge_yearly_corr {r['_m'].get('hedge_yearly_corr')} | alpha/anno {r['_m'].get('alpha_ann')}") + + print(" --- accelerazione vs LIVELLO del momentum (6 celle di controllo) ---") + B = A.tp01_baseline_daily() + C = r["C"] + best = None + for p in MOM_CONTROL_GRID: + fnm = mk_mom(tf="1d", **p) + Cm = A.candidate_daily(fnm, tf="1d") + J = pd.concat({"A": C, "M": Cm}, axis=1, join="inner").dropna() + JB = pd.concat({"B": B, "M": Cm}, axis=1, join="inner").dropna() + up = A._sh((1 - W_BLEND) * JB["B"] + W_BLEND * JB["M"]) - A._sh(JB["B"]) + row = dict(params=p, sharpe=round(A._sh(Cm), 3), + hold=round(A._sh(Cm[Cm.index >= A.HOLDOUT]), 3), + corr_to_acc=round(float(J["A"].corr(J["M"])), 3), + corr_to_tp01=round(float(JB["B"].corr(JB["M"])), 3), + uplift_w25_full=round(up, 3)) + print(f" MOM {str(p):<28} Sh {row['sharpe']:+.3f} hold {row['hold']:+.3f} " + f"corr->{r['code']} {row['corr_to_acc']:+.3f} corr->TP01 {row['corr_to_tp01']:+.3f} " + f"uplift {row['uplift_w25_full']:+.3f}") + if best is None or row["corr_to_acc"] > best["corr_to_acc"]: + best = row + print(f" livello piu' vicino all'accelerazione: {best['params']} corr {best['corr_to_acc']:+.3f}") + print(" lettura: corr alta col LIVELLO => l'accelerazione non e' una dimensione nuova; " + "corr bassa => e' distinta ma resta da spiegare perche' non sopravvive al DSR.\n") + + +NOT_TESTED = [ + "catena opzioni / vol term-structure: 3-5 mesi di storia -> non esiste hold-out, al massimo " + "un LEAD; e altri agenti dell'ondata ci stanno sopra.", + "funding (carry, time-series, cross-sectional): filone dichiarato CHIUSO su 3 lati.", + "calendario in ogni forma (weekend 375 trial, expiry, stagionalita', event-clock, " + "ora-del-giorno): famiglia dichiarata SATURA.", + "mean-reversion pura / fade: morta anche a fee zero. Qui rientra SOLO come branch dichiarato " + "di XTAIL e VPX, per misurarla dentro un conditioner nuovo.", + "microstruttura 5m/15m: morta per fee, e una sweep su 5m sfonda il budget macchina.", + "cross-sectional sui 51 alt HL: territorio XS01, e l'espansione d'universo e' gia' refutata. " + "Qui i 51 alt entrano solo come AGGREGATO time-series (XDISP).", + "equity / GTAA / MAT01: GTAA01 non e' deployabile (PRIIPs) e MAT01 e' refutato.", + "macro regime gate: ridondante col trend (corr 0.989).", + "CRT / ICT / Elliott / Fibonacci / Albimarini: 3 ondate di refutazione.", + "gamma scalping long-vol e overlay DD su VRP01 (5/5 refutati).", + "SOL come terza gamba: decisione dell'operatore del 22/08.", +] + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_prop_alloc.py b/scripts/research/r0822_prop_alloc.py new file mode 100644 index 0000000..7522441 --- /dev/null +++ b/scripts/research/r0822_prop_alloc.py @@ -0,0 +1,712 @@ +"""r0822_prop_alloc — PROGETTARE l'allocazione per la BARRIERA, non per lo Sharpe (2026-08-22). + +IL BUCO CHE CHIUDE +------------------ +Il canale prop/funded e' l'unico misurato che moltiplica il nozionale senza possedere capitale. +Il progetto lo ha gia' VALUTATO (24-25/07: `r0725_prop_ladder.py`, `r0725_prop_coupled.py`, +`r0725_hyro.py`) ma non lo ha mai **PROGETTATO**: ha sempre preso il libro ottimizzato per lo +Sharpe (config A = live 75/25, config B = 55/20/25 scelta a mano) e poi MISURATO P(pass) del +risultato. Due punti nello spazio dei pesi, tre leve, nessuna ottimizzazione, nessun null. + +Qui la funzione obiettivo cambia: sotto una **barriera di maxDD STATICA per-conto** conta la coda +sinistra e la DURATA, non il rapporto rendimento/vol. Si ottimizza + + J(w, k) = P(passare l'eval) x P(essere vivo dopo 12 mesi da funded) + +su pesi w e leva k, con la lente WICK ACCOPPIATA (la `close-only` e' CIECA: 0% di breach da +daily-loss su ogni configurazione, la regola non scatta mai sulle chiusure). + +LA PREVISIONE, REGISTRATA PRIMA DI MISURARE +------------------------------------------- +Nel limite gaussiano con leva LIBERA l'obiettivo si riduce allo Sharpe, e si vede in due righe. +Per un moto browniano con drift mu e vol sigma a leva k, la probabilita' di toccare +T prima di +-D vale P = (1-e^{-theta*D})/(1-e^{-theta*(T+D)}) con **theta = 2*mu/(k*sigma^2)**: la leva entra +SOLO dividendo theta. Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) si ottiene +theta ~ 2*mu^2/(T*sigma^2) = 2*S^2/T, con S = Sharpe: **la vol sparisce e resta lo Sharpe al +quadrato**. Quindi mi aspetto PRIMA di misurare: + (a) l'argmax di J vicino all'argmax di Sharpe -> la premessa "obiettivo diverso" e' in gran + parte FALSA sui PESI; + (b) il contenuto vero nella **LEVA**, che lo Sharpe non determina affatto, e nella deviazione + dal gaussiano (skew, wick, daily-loss: una soglia ASSOLUTA non e' scale-invariante); + (c) leva ottima per J **piu' bassa** di quella ottima per il payout (0.75x, 25/07): J e' una + probabilita' di sopravvivenza, il payout e' un flusso. +Se (a) risulta falsa, e' un risultato; se risulta vera, il filone "riallocare per la barriera" +si chiude e si smette di pagarlo. + +CONVENZIONI DICHIARATE +---------------------- +* Macchineria del wick ACCOPPIATO **riusata** da `r0725_prop_coupled.py` (import, non riscritta): + `_hourly_legs` (recon MTM orario TP01/SKH01), `crypto_daily_tuples` (minimo ESATTO sul path + orario condiviso), `xsec_daily_tuples` (XS01 dagli OHLC 1d, ordine condiviso avverso). +* VRP01 e GTAA01 sono AGGIUNTI qui (non c'erano): GTAA01 col wick dagli OHLC dei 6 ETF (long-only + co-moventi -> ogni gamba al proprio minimo = convenzione severa dichiarata); VRP01 con + **gap = 0**, cioe' CIECO, perche' il suo mark infra-settimana non e' nel dataset dello sleeve + (il rendimento settimanale e' lumpato sul giorno di scadenza). E' una lente GENEROSA per VRP01: + la misura del 30/07 sulle quote reali dice mark peggiore infra-settimana mediano -6.9% e minimo + **-81.7%** del capitale su un trade finito in UTILE. Se VRP01 perde sotto una lente generosa, + perde. +* de-luck sul DRIFT **x0.89** (fattore MISURATO il 26/07 sul libro live, `r0726_deluck_factor.py`), + non x0.6 come nel 25/07 (li' era stimato a occhio e ri-misurato troppo severo del 31-34%). + Sensibilita' a 1.00 e 0.60 girata: cio' che conta e' se l'ORDINE fra le configurazioni tiene. +* bootstrap a blocchi (20g) con **indici CONDIVISI fra tutte le configurazioni** -> le differenze + fra configurazioni sono APPAIATE e il loro errore MC e' molto minore di quello dei livelli. +* finestra comune **2024+** (XS01 nasce li'): ~2.6 anni, ed e' la finestra in cui XS01 e' stato + scoperto -> le configurazioni che lo contengono sono FAVORITE per costruzione. Dichiarato. +* fisco 33% sui payout, split 80%, regole vere delle due firm (max-loss STATICO dal saldo + iniziale, base ripristinata dopo il prelievo — bug del 25/07 gia' corretto a monte). + +Uso: `nice -n 19 timeout 900 uv run python scripts/research/r0822_prop_alloc.py` +""" +from __future__ import annotations + +import sys +import time +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from src.data.eq_splits import repair_splits # noqa: E402 +from src.portfolio.gtaa import ( # noqa: E402 + ANN, EQ_UNIVERSE, GTAA_DEFAULT_CAPITAL, HORIZONS, IB_MIN_TRADE_USD, REBAL_BAND_USD, + REBAL_EVERY, TARGET_VOL, ib_commission, +) +from src.portfolio.portfolio import metrics, weights_tilt_null # noqa: E402 +from src.portfolio.sleeves import ( # noqa: E402 + GTAA_BOOK_ACTIVATION, _gtaa_daily_returns, _skyhook_returns, _tp01_returns, + _vrp_combo_returns, _xsec_returns, +) + +import r0725_prop_coupled as pc # noqa: E402 +import r0725_prop_ladder as pl # noqa: E402 +from r0725_capcurve import EURUSD, TAX_RATE # noqa: E402 + +RAW = ROOT / "data" / "raw" + +# ------------------------------------------------------------------ costanti dichiarate +DELUCK = 0.89 # fattore d'ancora MISURATO sul drift (26/07), non stimato a occhio +START_WIN = "2024-01-01" # finestra comune ai 5 sleeve (XS01 nasce qui) +BLOCK = 20 # blocco del bootstrap, in giorni +N_PATHS = 4000 +SPLIT = pl.SPLIT # 80% al trader +TARGET_EUR_DAY = 50.0 +FIRMS = {f["name"]: f for f in pl.FIRMS} +SLEEVES = ("TP01", "SKH01", "XS01", "VRP01", "GTAA01") +CRYPTO_PROP = ("TP01", "SKH01", "XS01") # cio' che una firm CRYPTO puo' davvero far eseguire + + +# ================================================================== §0. PANNELLO (R, wick) + +@lru_cache(maxsize=8) +def _eq_ohlc(sym: str) -> pd.DataFrame: + p = RAW / f"eq_{sym.lower()}_1d.parquet" + d = pd.read_parquet(p) + d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + d = d.sort_index() + d, _ = repair_splits(d) # split IWM/EFA non aggiustati (25/07) + return d[["open", "high", "low", "close"]].astype(float) + + +def _gtaa_exposure(close: pd.Series) -> np.ndarray: + """Copia FEDELE di gtaa._exposure (che prende una Series di soli close).""" + px = close.values + n = len(px) + tgt = np.zeros(n) + mh = max(HORIZONS) + for i in range(mh, n): + tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in HORIZONS]) + s = pd.Series(tgt, index=close.index) + rv = close.pct_change().rolling(63, min_periods=20).std().shift(1) * ANN + scale = np.clip(np.nan_to_num(TARGET_VOL / rv.replace(0, np.nan).values, nan=0.0), 0, 10.0) + return (s * scale).clip(0, 1.0).values + + +@lru_cache(maxsize=2) +def gtaa_daily_tuples() -> pd.DataFrame: + """(R, m) giornalieri di GTAA01: chiusura replicata da gtaa.gtaa_returns e minimo intraday + dagli OHLC delle 6 gambe. Long-only e co-moventi -> convenzione **ogni gamba al proprio + minimo insieme** (severa, la stessa famiglia del bound 'perleg' di XS01). + Weekend/festivi: R = 0 e m = 0 (capitale IB fermo — convenzione del book).""" + cap_leg = GTAA_DEFAULT_CAPITAL / len(EQ_UNIVERSE) + legs_r, legs_m = {}, {} + for sym in EQ_UNIVERSE: + d = _eq_ohlc(sym) + close = d["close"] + ex = np.nan_to_num(_gtaa_exposure(close)) + px = close.values + prev = np.concatenate(([np.nan], px[:-1])) + ret = np.zeros(len(px)) + ret[1:] = px[1:] / px[:-1] - 1.0 + ret_low = d["low"].values / prev - 1.0 # long-only: il peggio e' il minimo + held = np.empty(len(ex)) + comm = np.zeros(len(ex)) + cur = 0.0 + for i in range(len(ex)): # identico a gtaa._gated_returns + if i % REBAL_EVERY == 0: + notional = abs(ex[i] - cur) * cap_leg + if notional >= max(REBAL_BAND_USD, IB_MIN_TRADE_USD): + comm[i] = ib_commission(notional, px[i]) / cap_leg + cur = ex[i] + held[i] = cur + pos = np.zeros(len(held)) + pos[1:] = held[:-1] + legs_r[sym] = pd.Series(pos * ret - comm, index=close.index) + legs_m[sym] = pd.Series(pos * np.nan_to_num(ret_low) - comm, index=close.index) + R = pd.concat(legs_r, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + M = pd.concat(legs_m, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + days = pd.date_range(R.index.min().normalize(), R.index.max().normalize(), freq="1D", tz="UTC") + R = R.reindex(days).fillna(0.0) + M = M.reindex(days).fillna(0.0) + out = pd.DataFrame({"R": R, "m": np.minimum(M, R)}) + return out[out.index >= GTAA_BOOK_ACTIVATION] + + +@lru_cache(maxsize=2) +def vrp_daily_tuples() -> pd.DataFrame: + """(R, m) di VRP01 con **m = R**: il mark infra-settimana NON e' nel dataset dello sleeve + (rendimento settimanale lumpato sul giorno di scadenza). Lente CIECA e GENEROSA — dichiarata.""" + r = _vrp_combo_returns().dropna().sort_index() + if r.index.tz is None: + r.index = r.index.tz_localize("UTC") + return pd.DataFrame({"R": r.values, "m": r.values}, index=r.index) + + +def _d(s: pd.Series) -> pd.Series: + s = s.dropna().sort_index() + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + return s + + +_CRY_CACHE: dict = {} + + +def _crypto(w_tp: float, w_skh: float) -> pd.DataFrame: + key = (round(w_tp, 6), round(w_skh, 6)) + if key not in _CRY_CACHE: + _CRY_CACHE[key] = pc.crypto_daily_tuples(w_tp, w_skh) + return _CRY_CACHE[key] + + +@lru_cache(maxsize=4) +def _panel_index(start: str) -> pd.DatetimeIndex: + """Griglia comune ai 5 sleeve (intersezione), dal `start` in poi.""" + idx = _crypto(1.0, 0.0).index + for f in (pc.xsec_daily_tuples(), vrp_daily_tuples(), gtaa_daily_tuples()): + idx = idx.intersection(f.index) + return idx[idx >= pd.Timestamp(start, tz="UTC")] + + +_RG_CACHE: dict = {} + + +def book_RG(w: dict, start: str = START_WIN) -> tuple[np.ndarray, np.ndarray]: + """(R, gap) giornalieri di un libro a pesi arbitrari sui 5 sleeve. + Crypto TP01+SKH01: minimo ESATTO sul path orario condiviso (r0725_prop_coupled). + XS01/VRP01/GTAA01: il loro minimo si SOMMA (worst simultaneo — convenzione del 25/07).""" + key = (start,) + tuple(round(float(w.get(nm, 0.0)), 6) for nm in SLEEVES) + if key in _RG_CACHE: + return _RG_CACHE[key] + idx = _panel_index(start) + w_tp, w_skh = w.get("TP01", 0.0), w.get("SKH01", 0.0) + R = np.zeros(len(idx)) + M = np.zeros(len(idx)) + if w_tp > 0 or w_skh > 0: + c = _crypto(w_tp, w_skh).reindex(idx) + R += c["R"].values + M += c["m"].values + for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()), + ("GTAA01", gtaa_daily_tuples())): + wt = w.get(nm, 0.0) + if wt > 0: + f = fr.reindex(idx) + R += wt * f["R"].values + M += wt * f["m"].values + R = np.nan_to_num(R) + M = np.nan_to_num(M) + _RG_CACHE[key] = (R, np.minimum(M, R) - R) + return _RG_CACHE[key] + + +def deluck(R: np.ndarray, factor: float = DELUCK) -> np.ndarray: + return R - (1.0 - factor) * R.mean() + + +# ================================================================== §1. SIMULATORE + +def boot_idx(n_hist: int, n_days: int, n_paths: int, seed: int, block: int = BLOCK) -> np.ndarray: + rng = np.random.default_rng(seed) + n_blocks = int(np.ceil(n_days / block)) + st = rng.integers(0, n_hist - block, size=(n_paths, n_blocks)) + return (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days] + + +def eval_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str, + lens: str = "coupled") -> dict: + """P(passare l'eval) entro l'orizzonte del bootstrap + giorni al pass.""" + tgt, dd, dl = FIRMS[firm]["ev"] + r = R[idx] * lev + g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r) + P, D = r.shape + eq = np.ones(P) + alive = np.ones(P, bool) + passed = np.zeros(P, bool) + tpass = np.full(P, D, int) + for t in range(D): + act = alive & ~passed + if not act.any(): + break + rt, gt = r[:, t], g[:, t] + eqp = eq + eq = np.where(act, eq * (1.0 + rt), eq) + eq_low = np.where(act, eqp * (1.0 + rt + gt), eq) + bust = act & ((eq_low < 1.0 - dd) | (rt + gt < -dl)) + alive &= ~bust + ok = act & ~bust & (eq >= 1.0 + tgt) + tpass[ok] = t + passed |= ok + return dict(p_pass=float(passed.mean()), passed=passed, + t_pass=float(np.median(tpass[passed])) if passed.any() else np.nan) + + +def funded_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str, + notional: float, lens: str = "coupled") -> dict: + """P(vivo a fine orizzonte) + payout netto cumulato (prelievo mensile, base ripristinata).""" + ml, dl = FIRMS[firm]["fu"] + r = R[idx] * lev + g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r) + P, D = r.shape + eq = np.ones(P) + alive = np.ones(P, bool) + payout = np.zeros(P) + alive_at = {} + for t in range(D): + rt, gt = r[:, t], g[:, t] + eqp = eq + eq = np.where(alive, eq * (1.0 + rt), eq) + eq_low = np.where(alive, eqp * (1.0 + rt + gt), eq) + bust = alive & ((eq_low < 1.0 - ml) | (rt + gt < -dl)) + alive &= ~bust + if (t + 1) % 30 == 0: + take = alive & (eq > 1.0) + payout += np.where(take, (eq - 1.0) * notional, 0.0) * SPLIT * (1.0 - TAX_RATE) + eq = np.where(take, 1.0, eq) + if (t + 1) % 365 == 0: + alive_at[(t + 1) // 365] = float(alive.mean()) + return dict(p_alive=float(alive.mean()), alive_at=alive_at, + e_payout=float(payout.mean()), payout=payout) + + +def objective(w: dict, lev: float, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray, + lens: str = "coupled", factor: float = DELUCK, + notional: float = 100_000.0) -> dict: + R, G = book_RG(w) + R = deluck(R, factor) + e = eval_sim(R, G, ev_idx, lev, firm, lens) + f = funded_sim(R, G, fu_idx, lev, firm, notional, lens) + ann = float(R.mean() * 365.0) + vol = float(R.std() * np.sqrt(365.0)) + return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"], + e_payout=f["e_payout"], sharpe=(ann / vol if vol > 0 else 0.0), + vol=vol, drift=ann, t_pass=e["t_pass"]) + + +# ================================================================== §2. IL CONTO ANALITICO + +def p_hit_target_first(mu: float, sig: float, lev: float, T: float, D: float) -> float: + """Gambler's ruin per moto browniano con drift: P(toccare +T prima di -D), SENZA limite di + tempo. theta = 2*mu/(k*sigma^2): la leva entra SOLO dividendo theta -> con leva -> inf la + probabilita' tende a D/(T+D) (37.5% con 10%/6%), qualunque sia lo Sharpe.""" + if sig <= 0: + return float(mu > 0) + theta = 2.0 * mu / (lev * sig * sig) + if abs(theta) < 1e-12: + return D / (T + D) + return float((1.0 - np.exp(-theta * D)) / (1.0 - np.exp(-theta * (T + D)))) + + +# ================================================================== §3. GRIGLIA / NULL + +def simplex_grid(names: tuple, step_den: int) -> list[dict]: + """Tutti i vettori di peso a passo 1/step_den sul simplesso di `names` (somma 1).""" + out = [] + n = len(names) + + def rec(prefix, rem, k): + if k == n - 1: + out.append(dict(zip(names, [p / step_den for p in prefix] + [rem / step_den]))) + return + for v in range(rem + 1): + rec(prefix + [v], rem - v, k + 1) + + rec([], step_den, 0) + return out + + +def wkey(w: dict) -> str: + return "/".join(f"{int(round(100 * w.get(nm, 0.0))):d}" for nm in CRYPTO_PROP) + + +def grid_scan(grid: list[dict], levs: tuple, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray, + lens: str = "coupled", factor: float = DELUCK) -> pd.DataFrame: + rows = [] + for w in grid: + for lev in levs: + o = objective(w, lev, firm, ev_idx, fu_idx, lens, factor) + rows.append(dict(w=wkey(w), lev=lev, **{k: v for k, v in o.items()})) + return pd.DataFrame(rows) + + +def random_weights(n: int, names: tuple, seed: int, floor: float = 0.0, + caps: dict | None = None) -> list[dict]: + """Stesso campionatore di `portfolio.weights_tilt_null` (dirichlet uniforme sul simplesso + + rejection su floor/caps), ma qui serve a costruire il NULL della funzione obiettivo J.""" + rng = np.random.default_rng(seed) + caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float) + out = [] + while len(out) < n: + b = rng.dirichlet(np.ones(len(names)), size=4 * n + 64) + ok = (b >= floor).all(axis=1) & (b <= caps_v).all(axis=1) + out.extend(b[ok]) + return [dict(zip(names, v)) for v in out[:n]] + + +# ================================================================== main + +def hr(t: str = "") -> None: + print("\n" + "-" * 104) + if t: + print(f" {t}") + print("-" * 104) + + +def main() -> None: + t0 = time.time() + print("=" * 104) + print(" PROP-ALLOC — allocare per la BARRIERA (P(pass) x P(vivo 12m)) invece che per lo Sharpe") + print("=" * 104) + + # ---------------------------------------------------------------- A. pannello + sanity + idx = _panel_index(START_WIN) + print(f"\n finestra comune ai 5 sleeve: {len(idx)} giorni {idx[0].date()} -> {idx[-1].date()}" + f" (XS01 nasce nel 2024: e' anche la sua finestra di SCOPERTA -> lo favorisce)") + print(f" de-luck sul drift x{DELUCK} (misurato 26/07) | bootstrap a blocchi di {BLOCK}g, " + f"indici CONDIVISI fra configurazioni | {N_PATHS} percorsi") + + off = {"TP01": _d(_tp01_returns()), "SKH01": _d(_skyhook_returns()), "XS01": _d(_xsec_returns()), + "VRP01": _d(_vrp_combo_returns()), "GTAA01": _d(_gtaa_daily_returns())} + rec = {"TP01": _crypto(1.0, 0.0)["R"], "SKH01": _crypto(0.0, 1.0)["R"], + "XS01": pc.xsec_daily_tuples()["R"], "VRP01": vrp_daily_tuples()["R"], + "GTAA01": gtaa_daily_tuples()["R"]} + print("\n sanity — la ricostruzione (R) deve coincidere con lo sleeve ufficiale sulla chiusura:") + for nm in SLEEVES: + com = off[nm].index.intersection(rec[nm].index) + dmax = float(np.abs(off[nm].reindex(com).values - rec[nm].reindex(com).values).max()) + note = "" if nm not in ("TP01", "SKH01") else " (recon MTM orario: lente diversa per costruzione)" + print(f" {nm:>7}: max|delta| = {dmax:.2e} {'OK' if dmax < 1e-9 else 'DIVERGE'}{note}") + + hr("A. PROFILO DEGLI SLEEVE SOTTO BARRIERA — cio' che conta non e' lo Sharpe, e si vede qui") + print(f" {'sleeve':>8} {'Sharpe':>7} {'vol a':>7} {'drift a':>8} {'skew':>7} " + f"{'giorno peggiore':>16} {'gap p90':>9} {'gap peggiore':>13} {'g/vol':>7}") + prof = {} + for nm in SLEEVES: + R, G = book_RG({nm: 1.0}) + Rd = deluck(R) + vol = Rd.std() * np.sqrt(365.0) + dr = Rd.mean() * 365.0 + sk = float(pd.Series(Rd).skew()) + prof[nm] = dict(sharpe=dr / vol if vol > 0 else 0.0, vol=vol, drift=dr, skew=sk, + worst=Rd.min(), gap90=np.percentile(-G, 90), gapw=G.min()) + print(f" {nm:>8} {prof[nm]['sharpe']:>7.2f} {vol:>6.1%} {dr:>8.1%} {sk:>7.2f} " + f"{Rd.min():>15.2%} {-np.percentile(-G, 90)*100:>8.2f}pp {G.min()*100:>12.2f}pp " + f"{(-G.min()/vol) if vol > 0 else 0:>7.2f}") + print("\n lettura: `gap peggiore` e' l'escursione intraday che la lente close-only NON vede;") + print(" `g/vol` la misura in unita' di vol annua. VRP01 ha gap 0 **per cecita' del dato**,") + print(" non per assenza di rischio (mark infra-settimana misurato il 30/07: mediana -6.9%,") + print(" minimo -81.7% del capitale su un trade finito in UTILE).") + + hr("B. IL CONTO ANALITICO — perche' con leva LIBERA la barriera premia lo SHARPE, non la vol") + tgt, dd, _dl = FIRMS["HYRO"]["ev"] + print(f" P(toccare +{tgt:.0%} prima di -{dd:.0%}), gambler's ruin senza limite di tempo, " + f"theta = 2*mu/(k*sigma^2):") + print(f" {'sleeve':>8} {'Sharpe':>7} | " + " ".join(f"{f'k={k:g}':>8}" for k in (0.25, 0.5, 1.0, 2.0, 4.0))) + for nm in SLEEVES: + p = prof[nm] + cells = " ".join(f"{p_hit_target_first(p['drift'], p['vol'], k, tgt, dd):>8.1%}" + for k in (0.25, 0.5, 1.0, 2.0, 4.0)) + print(f" {nm:>8} {p['sharpe']:>7.2f} | {cells}") + print(f" {'limite k->inf':>16} | " + " ".join([f"{dd/(tgt+dd):>8.1%}"] * 5) + + " <- D/(T+D): la leva alta CANCELLA l'edge") + print("\n Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) resta theta ~ 2*S^2/T:") + print(" la VOL sparisce e conta lo Sharpe AL QUADRATO. Percio' la previsione registrata prima") + print(" di misurare e': l'argmax di J sui PESI ~ l'argmax di Sharpe, e il contenuto vero e'") + print(" nella LEVA (che lo Sharpe non determina) e nella deviazione dal gaussiano.") + + # ---------------------------------------------------------------- C. griglia + n_hist = len(idx) + EV_H, FU_H = 365, 365 + ev_idx = boot_idx(n_hist, EV_H, N_PATHS, seed=8220) + fu_idx = boot_idx(n_hist, FU_H, N_PATHS, seed=8221) + LEVS = (0.25, 0.375, 0.50, 0.625, 0.75, 1.00, 1.25, 1.50) + STEP = 8 + grid = simplex_grid(CRYPTO_PROP, STEP) + + hr(f"C. GRIGLIA — {len(grid)} vettori di peso (passo 1/{STEP}) x {len(LEVS)} leve " + f"= {len(grid)*len(LEVS)} celle, firm HYRO (eval +10%/-6%/-4%, funded -6%/-4%)") + print(f" orizzonte eval {EV_H}g, funded {FU_H}g. Lente ACCOPPIATA. " + f"J = P(pass) x P(vivo 12m).") + t = time.time() + G3 = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx) + print(f" ({time.time()-t:.0f}s)") + + best = G3.loc[G3["J"].idxmax()] + # argmax di SHARPE: lo Sharpe non dipende dalla leva -> si sceglie il peso, poi la leva + # migliore PER QUEL PESO secondo J (altrimenti non e' un confronto onesto). + w_sh = G3.loc[G3["sharpe"].idxmax(), "w"] + sub_sh = G3[G3["w"] == w_sh] + best_sh = sub_sh.loc[sub_sh["J"].idxmax()] + live = G3[(G3["w"] == "75/25/0")] + best_live = live.loc[live["J"].idxmax()] + + print(f"\n {'':>22} {'pesi TP/SKH/XS':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} " + f"{'P(vivo12m)':>11} {'Sharpe':>7} {'vol':>6} {'E[pay/a]':>10} {'g.al pass':>10}") + for lab, row in (("argmax J", best), ("argmax Sharpe", best_sh), + ("libro LIVE 75/25", best_live)): + print(f" {lab:>22} {row['w']:>16} {row['lev']:>6.3f} {row['J']:>7.3f} " + f"{row['p_pass']:>8.1%} {row['p_alive']:>11.1%} {row['sharpe']:>7.2f} " + f"{row['vol']:>6.1%} {row['e_payout']:>9,.0f}$ {row['t_pass']:>10.0f}") + + print("\n Le prime 8 celle per J:") + print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8} " + f"{'Sharpe':>7} {'E[pay/a]':>10}") + for i, (_, r) in enumerate(G3.sort_values("J", ascending=False).head(8).iterrows(), 1): + print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['J']:>7.3f} {r['p_pass']:>8.1%} " + f"{r['p_alive']:>8.1%} {r['sharpe']:>7.2f} {r['e_payout']:>9,.0f}$") + + print("\n E per E[payout] (obiettivo DIVERSO: un flusso, non una sopravvivenza):") + print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'E[pay/a]':>10} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8}") + for i, (_, r) in enumerate(G3.sort_values("e_payout", ascending=False).head(4).iterrows(), 1): + print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['e_payout']:>9,.0f}$ {r['J']:>7.3f} " + f"{r['p_pass']:>8.1%} {r['p_alive']:>8.1%}") + + hr("C-bis. LA LEVA — la variabile che lo Sharpe non determina e che decide tutto") + print(f" pesi = argmax J ({best['w']}):") + print(f" {'leva':>7} {'J':>7} {'P(pass) 365g':>13} {'P(pass) 1095g':>14} {'P(vivo 12m)':>12} " + f"{'E[pay/a]':>10}") + ev_long = boot_idx(n_hist, 1095, N_PATHS, seed=8222) + w_best = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in best["w"].split("/")])) + Rb, Gb = book_RG(w_best) + Rb = deluck(Rb) + for lev in LEVS: + o = objective(w_best, lev, "HYRO", ev_idx, fu_idx) + pl_long = eval_sim(Rb, Gb, ev_long, lev, "HYRO")["p_pass"] + print(f" {lev:>7.3f} {o['J']:>7.3f} {o['p_pass']:>12.1%} {pl_long:>13.1%} " + f"{o['p_alive']:>11.1%} {o['e_payout']:>9,.0f}$") + print("\n A 365 giorni P(pass) e' a CAMPANA nella leva (a leva bassa il drift non arriva a +10%") + print(" in tempo); senza limite di tempo — che e' la regola vera di HyroTrader — e' MONOTONA") + print(" DECRESCENTE, come dice il conto analitico. Le due colonne dicono cose diverse e vanno") + print(" lette insieme: la leva alta non aumenta la probabilita' di farcela, aumenta la FRETTA.") + + # ---------------------------------------------------------------- D. i null + hr("D. NULL 1 — l'argmax e' speciale, o e' solo il MASSIMO DI 45 ESTRAZIONI?") + rw = random_weights(300, CRYPTO_PROP, seed=8223) + lev_star = float(best["lev"]) + Jr = np.array([objective(w, lev_star, "HYRO", ev_idx, fu_idx)["J"] for w in rw]) + pctl = float((Jr < best["J"]).mean() * 100.0) + bestofk = 100.0 * len(grid) / (len(grid) + 1) + print(f" J dell'argmax = {best['J']:.3f} alla leva {lev_star:g}") + print(f" null: 300 vettori di peso casuali uniformi sul simplesso (stesso campionatore di") + print(f" `portfolio.weights_tilt_null`), valutati alla STESSA leva") + print(f" mediana {np.median(Jr):.3f} | p90 {np.percentile(Jr, 90):.3f} | " + f"max {Jr.max():.3f} | frazione sopra il LIVE 75/25 = {(Jr > best_live['J']).mean():.0%}") + print(f" percentile dell'argmax fra i casuali = {pctl:.1f}° contro la firma best-of-" + f"{len(grid)} = {bestofk:.1f}°") + print(f" -> {'DENTRO' if pctl <= bestofk else 'SOPRA'} la firma del massimo-di-k: " + f"{'nessuna evidenza che quel vettore sia speciale' if pctl <= bestofk else 'vettore particolare'}") + print(f" (la meta' bassa del null e' pero' informativa: il LIVE 75/25 e' battuto dal " + f"{(Jr > best_live['J']).mean():.0%} dei pesi CASUALI)") + + hr("D-bis. NULL 2 — selezione IN-SAMPLE-ONLY (la scelta guardando J e' selezione come ogni altra)") + half = n_hist // 2 + R_all = {} + for w in grid: + R_, G_ = book_RG(w) + R_all[wkey(w)] = (deluck(R_), G_) + ev_h = boot_idx(half, EV_H, N_PATHS, seed=8224) + fu_h = boot_idx(half, FU_H, N_PATHS, seed=8225) + + def _J(key, lev, sl): + R_, G_ = R_all[key] + e = eval_sim(R_[sl], G_[sl], ev_h, lev, "HYRO")["p_pass"] + f = funded_sim(R_[sl], G_[sl], fu_h, lev, "HYRO", 100_000.0)["p_alive"] + return e * f + + IS, OOS = slice(0, half), slice(half, n_hist) + rows = [] + for w in grid: + k = wkey(w) + for lev in LEVS: + rows.append((k, lev, _J(k, lev, IS), _J(k, lev, OOS))) + S = pd.DataFrame(rows, columns=["w", "lev", "J_is", "J_oos"]) + pick = S.loc[S["J_is"].idxmax()] + oracle = S.loc[S["J_oos"].idxmax()] + live_oos = S[(S["w"] == "75/25/0")].sort_values("J_oos", ascending=False).iloc[0] + print(f" prima meta' {idx[0].date()}->{idx[half-1].date()} | seconda {idx[half].date()}->{idx[-1].date()}") + print(f" {'':>28} {'pesi':>16} {'leva':>6} {'J in-sample':>12} {'J out-of-sample':>16}") + print(f" {'scelto SUL SOLO in-sample':>28} {pick['w']:>16} {pick['lev']:>6.3f} " + f"{pick['J_is']:>12.3f} {S[(S['w']==pick['w'])&(S['lev']==pick['lev'])]['J_oos'].iloc[0]:>16.3f}") + print(f" {'oracolo (max sull OOS)':>28} {oracle['w']:>16} {oracle['lev']:>6.3f} " + f"{oracle['J_is']:>12.3f} {oracle['J_oos']:>16.3f}") + print(f" {'libro LIVE 75/25 (baseline)':>28} {live_oos['w']:>16} {live_oos['lev']:>6.3f} " + f"{live_oos['J_is']:>12.3f} {live_oos['J_oos']:>16.3f}") + rho = float(S["J_is"].corr(S["J_oos"], method="spearman")) + print(f" Spearman(J in-sample, J out-of-sample) su {len(S)} celle = {rho:+.2f}") + print(" -> se rho e' alto la mappa e' stabile e la scelta si trasferisce; se e' ~0 l'argmax") + print(" e' rumore e la decisione va presa sul MECCANISMO (la leva), non sulla cella.") + + hr("D-ter. NULL DEL DE-LEVERING — la leva e' GIA' dentro l'ottimizzazione, ma va detto") + print(" Il null classico ('esiste k<1 che da' lo stesso DD con Sharpe migliore?') qui e'") + print(" strutturale: ogni configurazione e' valutata a OTTO leve, quindi nessuna puo' vincere") + print(" solo perche' e' meno esposta. La domanda che resta e': la MIGLIOR leva del libro LIVE") + print(" raggiunge il J dell'argmax?") + print(f" argmax J : {best['w']:>10} @ {best['lev']:g}x -> J = {best['J']:.3f}") + print(f" LIVE 75/25 al suo : {best_live['w']:>10} @ {best_live['lev']:g}x -> J = {best_live['J']:.3f}") + dJ = best["J"] - best_live["J"] + se = np.sqrt(0.25 / N_PATHS) * 2 + print(f" delta = {dJ:+.3f} (errore MC su livelli ~+-{se:.3f}; le due celle condividono") + print(f" gli indici di bootstrap -> il delta e' APPAIATO e il suo errore e' piu' piccolo)") + + # ---------------------------------------------------------------- E. banda d'ancora + hr("E. BANDA D'ANCORA — non ri-misurata sotto questa lente (costo); si usa il FATTORE misurato") + print(" Ri-fare i 24x23x10 offset col recon MTM orario e' fuori budget. Si applica il fattore") + print(" di drift MISURATO il 26/07 (x0.89 sul libro live) e si controlla cio' che conta: se") + print(" l'ORDINE fra le configurazioni sopravvive alla banda 0.60 (stima 25/07, ri-misurata") + print(" troppo severa) - 1.00 (nessuna correzione).") + cands = [best["w"], best_sh["w"], best_live["w"]] + print(f"\n {'fattore':>9} " + " ".join(f"{('J ' + c):>14}" for c in dict.fromkeys(cands)) + + f" {'argmax pesi':>14} {'argmax leva':>11}") + for fct in (1.00, 0.89, 0.60): + cells = [] + for c in dict.fromkeys(cands): + wc = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in c.split("/")])) + sub = [objective(wc, lv, "HYRO", ev_idx, fu_idx, factor=fct)["J"] for lv in LEVS] + cells.append(max(sub)) + g = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx, factor=fct) + b = g.loc[g["J"].idxmax()] + print(f" {fct:>9.2f} " + " ".join(f"{v:>14.3f}" for v in cells) + + f" {b['w']:>14} {b['lev']:>11.3f}") + + # ---------------------------------------------------------------- F. VRP01 / GTAA01 + hr("F. E GLI SLEEVE 'INUTILI A $600'? — l'asimmetria del 25/07 §4 vale per XS01, non per tutti") + print(" Su un conto funded da $100k gli sleeve fuori portata a $600 diventano ESEGUIBILI per") + print(" TAGLIA. Ma la taglia non e' l'unico vincolo: una firm CRYPTO non lista opzioni Deribit") + print(" (VRP01) ne' ETF azionari USA (GTAA01, che al retail UE e' pure bloccato dal PRIIPs).") + print(" Qui si misura comunque il CONTROFATTUALE: se fossero disponibili, aiuterebbero?") + print(f"\n {'aggiunta al ' + best['w']:>34} {'leva':>6} {'J':>7} {'P(pass)':>8} " + f"{'P(vivo)':>8} {'Sharpe':>7} {'E[pay/a]':>10}") + for extra, wx in [(None, 0.0), ("VRP01", 0.10), ("VRP01", 0.20), + ("GTAA01", 0.10), ("GTAA01", 0.20)]: + w2 = {k: v * (1.0 - wx) for k, v in w_best.items()} + if extra: + w2[extra] = wx + rows = [(lv, objective(w2, lv, "HYRO", ev_idx, fu_idx)) for lv in LEVS] + lv, o = max(rows, key=lambda x: x[1]["J"]) + lab = "nessuna (base)" if extra is None else f"{extra} @ {wx:.0%}" + print(f" {lab:>34} {lv:>6.3f} {o['J']:>7.3f} {o['p_pass']:>8.1%} {o['p_alive']:>8.1%} " + f"{o['sharpe']:>7.2f} {o['e_payout']:>9,.0f}$") + print("\n ATTENZIONE: la riga VRP01 e' calcolata con **gap = 0** (il mark infra-settimana non") + print(" esiste nel dataset dello sleeve): e' un TETTO, non una stima. Con il mark misurato il") + print(" 30/07 (peggiore infra-settimana mediano -6.9%, minimo -81.7%) una barriera STATICA al") + print(" 6% verrebbe sfondata dal mark, non dal risultato.") + + # ---------------------------------------------------------------- G. FTMO + hr("G. LA BARRIERA CAMBIA LA RISPOSTA? — HYRO (-6% statico) vs FTMO (-10% statico)") + print(f" {'firm':>6} {'argmax pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo12m)':>11} " + f"{'E[pay/a]':>10} | {'LIVE 75/25: leva':>17} {'J':>7}") + for fm in ("HYRO", "FTMO"): + g = grid_scan(grid, LEVS, fm, ev_idx, fu_idx) + b = g.loc[g["J"].idxmax()] + lv2 = g[g["w"] == "75/25/0"] + b2 = lv2.loc[lv2["J"].idxmax()] + print(f" {fm:>6} {b['w']:>16} {b['lev']:>6.3f} {b['J']:>7.3f} {b['p_pass']:>8.1%} " + f"{b['p_alive']:>11.1%} {b['e_payout']:>9,.0f}$ | {b2['lev']:>17.3f} {b2['J']:>7.3f}") + print("\n La barriera piu' larga di FTMO sposta l'ottimo di leva verso l'ALTO: il vincolo e' la") + print(" barriera, non il libro. E' la stessa lettura del 25/07 §4 ('sul canale funded il") + print(" vincolo binding e' la regola di DD, non il capitale'), qui misurata sull'ottimo.") + + # ---------------------------------------------------------------- H. gate ufficiale sui pesi + hr("H. GATE UFFICIALE SUI PESI (`portfolio.weights_tilt_null`) — sullo SHARPE, come previsto") + cols = {nm: off[nm] for nm in CRYPTO_PROP} + try: + res = weights_tilt_null(cols, {"TP01": 0.75, "SKH01": 0.25, "XS01": 0.0001}, + {nm: max(w_best.get(nm, 0.0), 1e-4) for nm in CRYPTO_PROP}, + floor=0.0, n=300, k_seen=len(grid) * len(LEVS)) + print(f" proposta {best['w']} vs LIVE 75/25 : delta_insample {res['delta_insample']:+.4f} | " + f"delta_hold {res['delta_hold']:+.4f} | pctl_hold {res['pctl_hold']:.1f}° " + f"(best-of-k {res['bestofk_pctl']:.1f}°) | gate_pass = {res['gate_pass']}") + except Exception as exc: # pragma: no cover + print(f" non girato: {exc}") + print(" NB: questo gate giudica lo SHARPE del book di ricerca, non J. Un cambio dei pesi del") + print(" LIBRO LIVE resta soggetto a lui; qui si progetta un conto SEPARATO (funded), che non") + print(" e' il libro live e non passa da questo gate.") + + # ---------------------------------------------------------------- I. tavola operativa + hr("I. TAVOLA OPERATIVA — le 3 configurazioni migliori, e quanti conti servono per 50 EUR/g") + top = G3.sort_values("J", ascending=False).drop_duplicates("w").head(3) + rows_ops = [] + fu3 = boot_idx(n_hist, 1095, N_PATHS, seed=8226) + print(f" {'pesi TP/SKH/XS':>16} {'leva':>6} {'P(pass)':>8} {'P(vivo12m)':>11} " + f"{'E[payout 3a]':>13} {'EUR/g per conto':>16} {'conti per 50/g':>15}") + for _, r in top.iterrows(): + w2 = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in r["w"].split("/")])) + R_, G_ = book_RG(w2) + R_ = deluck(R_) + f3 = funded_sim(R_, G_, fu3, float(r["lev"]), "HYRO", 100_000.0) + eur_day = f3["e_payout"] / EURUSD / 3.0 / 365.0 + n_acct = (TARGET_EUR_DAY / eur_day) if eur_day > 0 else np.inf + rows_ops.append((r["w"], float(r["lev"]), f3["e_payout"], eur_day, n_acct)) + print(f" {r['w']:>16} {r['lev']:>6.3f} {r['p_pass']:>8.1%} {r['p_alive']:>11.1%} " + f"{f3['e_payout']:>12,.0f}$ {eur_day:>15.2f} {n_acct:>15.1f}") + print("\n E[payout 3a] e' su UN conto funded da $100k, gia' netto split 80% e fisco 33%, e") + print(" MEDIA su tutti i percorsi (i morti contano zero). 'conti per 50/g' e' quindi un numero") + print(" ATTESO, non una garanzia: la distribuzione e' bimodale (vivo / bustato).") + cap_tot = sum(f["cap"] for f in pl.FIRMS) + print(f" ⚠ TETTO STRUTTURALE: cap per trader ${FIRMS['HYRO']['cap']:,.0f} (HYRO) + " + f"${FIRMS['FTMO']['cap']:,.0f} (FTMO) = ${cap_tot:,.0f} di nozionale su due firm") + print(f" -> al massimo {cap_tot/100_000:.0f} conti da $100k. Se ne servono di piu', servono " + f"ALTRE FIRM (e ognuna aggiunge rischio di controparte).") + + hr("I-bis. LA SCALA DI CONTI — macchineria del 25/07 (`r0725_prop_coupled.simulate`), " + "de-luck portato a x0.89") + print(f" 36 mesi, EUR {pl.START_EUR:.0f} di cassa iniziale, max {pl.MAX_CONCURRENT} conti, " + f"morte-firm {pl.FIRM_DEATH_PER_YEAR:.0%}/anno, lente ACCOPPIATA") + pc.DELUCK = DELUCK # il 25/07 girava a 0.6 (poi ri-misurato troppo severo) + cfg_best = tuple(w_best.get(nm, 0.0) for nm in CRYPTO_PROP) + cfg_live = (0.75, 0.25, 0.0) + cfg_B = (0.55, 0.20, 0.25) + pols = { + f"argmax-J {best['w']} su tutti i conti": ([cfg_best], [0] * pl.MAX_CONCURRENT), + "LIVE 75/25 su tutti i conti (baseline)": ([cfg_live], [0] * pl.MAX_CONCURRENT), + "config B 55/20/25 (scelta a mano 25/07)": ([cfg_B], [0] * pl.MAX_CONCURRENT), + } + print(f"\n {'politica':>44} {'leva':>6} {'EUR/g med':>10} {'p90':>8} {'P(>=10/g)':>10} " + f"{'P(>=50/g)':>10} {'P(zero)':>8}") + for pname, (cfgs, slots) in pols.items(): + for lev in (float(best["lev"]), 0.75): + r = pc.simulate(cfgs, slots, n_paths=2000, lev=lev, seed=20260822, + lens="coupled", start=START_WIN) + print(f" {pname:>44} {lev:>6.3f} {r['med']:>10.2f} {r['p90']:>8.2f} " + f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}") + + print("\n" + "=" * 104) + print(f" fatto in {time.time()-t0:.0f}s") + print("=" * 104) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_skew.py b/scripts/research/r0822_skew.py new file mode 100644 index 0000000..2c5b4e5 --- /dev/null +++ b/scripts/research/r0822_skew.py @@ -0,0 +1,618 @@ +"""r0822_skew.py — la FORMA della superficie di volatilita' (skew), mai usata dal progetto. + +Il progetto ha usato molte volte il LIVELLO della vol implicita (DVOL/ATM): VRP01 lo vende sotto +gate IV-rank, DVOLSPREAD ne fa relative-value BTC/ETH, TP01xDVOL lo prova come denominatore del +vol-target. Non ha MAI usato la FORMA: il risk reversal 25-delta (RR), il butterfly (BF) e la loro +dinamica. La catena per-strike esiste da 2026-05-01 (`data/raw/cb_chain/`), quindi la domanda e' +finalmente misurabile. + +Tre domande, in ordine di valore dichiarato nel brief: + Q1 lo skew ANTICIPA lo spot? ("il posizionamento in opzioni anticipa il prezzo") + Q2 lo skew e' un GATE DI RISCHIO sopra il libro TP01? + Q3 lo skew spiega l'errore di PREZZATURA di VRP01 (f=0.73 misurato il 30/07)? + +Onesta' strutturale, dichiarata PRIMA dei numeri: + * La finestra utile e' 2026-06-09 -> oggi, cioe' ~75 giorni, NON i 113 giorni di righe presenti. + Prima del 09/06 cerbero-bite raccoglieva UNA SOLA scadenza per ora (verificato: `exps=1`), + quindi non esiste ne' struttura a termine ne' maturita' costante. Righe presenti != dato + presente (regola 8 del brief), e qui il conto va fatto in ORE DI SUPERFICIE RICOSTRUIBILE. + * HOLDOUT del progetto = 2025-01-01: l'INTERO campione e' posteriore. Non esiste hold-out, + non esiste in-sample -> `study_family_honest` e `select_cell_insample` NON sono eseguibili + (l'in-sample e' vuoto). Il verdetto massimo ottenibile e' LEAD con gate pre-registrato. + * Con ~75 osservazioni giornaliere SE(Sharpe annualizzato) ~ sqrt(365/75) ~ 2.2: qualunque + Sharpe giornaliero misurato qui e' indistinguibile da zero. Percio' il peso della sessione + sta dove la POTENZA c'e' davvero: la relazione oraria skew<->prezzo (N~1.800) e la + decomposizione di prezzo di VRP01 (deterministica, non statistica). + +Convenzioni di costruzione (sono SCELTE, non dettagli — vedi sezione 1): + * "quotato" = bid>0 AND ask>0 AND ask>=bid AND iv/delta non nulli. Si contano le quote, non le righe. + * smile per (asset, ora, scadenza): IV interpolata LINEARMENTE in |delta| a 0.25 e 0.50, + senza MAI estrapolare (se la catena non abbraccia il delta bersaglio, la cella si scarta). + * maturita' costante: RR/BF/ATM interpolati fra le due scadenze che abbracciano T (7g e 30g). + Il roll e' CONTINUO per costruzione: non c'e' una data di roll, quindi non c'e' il dente di + sega di tenore che un "scadenza piu' vicina a T" iniettera' nel segnale. + * ogni ora porta `ts_max` = il timestamp piu' recente fra le quote usate. I rendimenti in avanti + partono STRETTAMENTE dopo `ts_max`: senza questo l'ancora e' sbagliata di ~30 minuti e nasce + un falso "lo skew anticipa di 15 minuti" (misurato in sessione: vedi sezione 2). + +Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_skew.py +""" +from __future__ import annotations + +import glob +import os +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +import altlib as A # noqa: E402 +import cblib as CB # noqa: E402 + +CHAIN = ROOT / "data" / "raw" / "cb_chain" +SCRATCH = Path(os.environ.get("SKEW_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad")) +ASSETS = ("BTC", "ETH") +COLS = ["ts", "asset", "instrument_name", "option_type", "strike", "exp", + "bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"] + +# tick MISURATO sui dati (non assunto): 0.0001 sotto 0.005 di premio, 0.0005 sopra. +def tick_of(prezzo: np.ndarray) -> np.ndarray: + return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005) + + +def rule(t: str) -> None: + print("\n" + "=" * 78) + print(t) + print("=" * 78) + + +# ============================================================================= +# 0. DATO +# ============================================================================= +def load_quoted() -> tuple[pd.DataFrame, dict]: + """Catena filtrata alle QUOTE vere. Ritorna anche la contabilita' righe-vs-quote.""" + parts, n_righe, n_status = [], 0, {} + for f in sorted(glob.glob(str(CHAIN / "*.parquet"))): + d = pd.read_parquet(f, columns=COLS) + n_righe += len(d) + for k, v in d["quote_status"].value_counts(dropna=False).items(): + n_status[str(k)] = n_status.get(str(k), 0) + int(v) + d["ts"] = pd.to_datetime(d["ts"], utc=True).dt.tz_convert("UTC") + d["exp"] = pd.to_datetime(d["exp"], utc=True).dt.tz_convert("UTC") + d["strike"] = d["strike"].astype(float) + parts.append(d) + d = pd.concat(parts, ignore_index=True) + d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last") + d["ts"] = pd.DatetimeIndex(d["ts"]).as_unit("ns") + d["exp"] = pd.DatetimeIndex(d["exp"]).as_unit("ns") + d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0 + + ok = (d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["iv"].notna() & d["delta"].notna() + cont = dict(righe=n_righe, dedup=int(len(d)), quotate=int(ok.sum()), + frac_quotate=round(float(ok.mean()), 4), quote_status=n_status) + q = d[ok & (d["dte"] > 0.5) & (d["dte"] < 95)].copy() + q["hr"] = q["ts"].dt.floor("h") + q["ad"] = q["delta"].abs() + # una riga per (ora, strumento): se in un'ora ci sono piu' giri, vale l'ULTIMO + q = q.sort_values("ts").drop_duplicates(subset=["hr", "asset", "instrument_name"], keep="last") + return q, cont + + +def conta_tick(q: pd.DataFrame) -> pd.DataFrame: + """Quanti tick vale lo spread, e quanti tick vale l'ask? (regola: prima di credere a un + rapporto estremo su un prezzo piccolo, contare i tick).""" + out = [] + for a in ASSETS: + for nome, m in (("ala |d|~0.10", (q["ad"] > 0.06) & (q["ad"] < 0.14)), + ("25-delta", (q["ad"] > 0.20) & (q["ad"] < 0.30)), + ("ATM |d|~0.50", (q["ad"] > 0.45) & (q["ad"] < 0.55))): + g = q[(q["asset"] == a) & m] + if g.empty: + continue + tk = tick_of(g["ask"].to_numpy()) + spread_tick = (g["ask"].to_numpy() - g["bid"].to_numpy()) / tk + ask_tick = g["ask"].to_numpy() / tk + out.append(dict(asset=a, banda=nome, n=len(g), + ask_tick_med=round(float(np.median(ask_tick)), 1), + ask_tick_p5=round(float(np.percentile(ask_tick, 5)), 1), + frac_ask_le2tick=round(float((ask_tick <= 2).mean()), 4), + spread_tick_med=round(float(np.median(spread_tick)), 1), + spread_su_mid_med=round(float(np.median( + (g["ask"] - g["bid"]) / g["mid"].replace(0, np.nan))), 3))) + return pd.DataFrame(out) + + +# ============================================================================= +# 1. SUPERFICIE: smile -> RR/BF a maturita' costante +# ============================================================================= +def _iv_at(ad: np.ndarray, iv: np.ndarray, tgt: float) -> float: + o = np.argsort(ad) + ad, iv = ad[o], iv[o] + if len(ad) < 2 or tgt < ad[0] or tgt > ad[-1]: + return np.nan # nessuna estrapolazione, mai + return float(np.interp(tgt, ad, iv)) + + +def build_smile(q: pd.DataFrame) -> pd.DataFrame: + """Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50.""" + cache = SCRATCH / "r0822_smile.parquet" + if cache.exists(): + try: + return pd.read_parquet(cache) + except Exception: + pass + rows = [] + for (asset, hr, exp), g in q.groupby(["asset", "hr", "exp"], sort=False): + c = g[g["option_type"] == "C"] + p = g[g["option_type"] == "P"] + if len(c) < 2 or len(p) < 2: + continue + c25 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.25) + p25 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.25) + if not (np.isfinite(c25) and np.isfinite(p25)): + continue + c50 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.50) + p50 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.50) + atm = np.nanmean([x for x in (c50, p50) if np.isfinite(x)]) if ( + np.isfinite(c50) or np.isfinite(p50)) else np.nan + rows.append((asset, hr, exp, float(g["dte"].iloc[0]), c25, p25, atm, + g["ts"].max(), int(len(g)))) + R = pd.DataFrame(rows, columns=["asset", "hr", "exp", "dte", "ivc25", "ivp25", + "ivatm", "ts_max", "n_gambe"]) + R["rr"] = R["ivc25"] - R["ivp25"] + R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"] + try: + SCRATCH.mkdir(parents=True, exist_ok=True) + R.to_parquet(cache) + except Exception: + pass + return R + + +def const_maturity(R: pd.DataFrame, asset: str, T: float) -> pd.DataFrame: + """RR/BF/ATM a maturita' costante T giorni. Interpolazione lineare in DTE fra le due scadenze + che ABBRACCIANO T (mai estrapolazione) -> il roll e' continuo, non c'e' data di roll.""" + g = R[(R["asset"] == asset)].dropna(subset=["rr", "ivatm"]) + out = {} + for hr, h in g.groupby("hr"): + h = h.sort_values("dte") + dt = h["dte"].to_numpy() + if len(h) < 2 or T < dt[0] or T > dt[-1]: + continue + out[hr] = dict(rr=float(np.interp(T, dt, h["rr"].to_numpy())), + bf=float(np.interp(T, dt, h["bf"].to_numpy())), + atm=float(np.interp(T, dt, h["ivatm"].to_numpy())), + ts_max=h["ts_max"].max()) + S = pd.DataFrame.from_dict(out, orient="index").sort_index() + S.index.name = "hr" + if len(S): + S["rr_n"] = S["rr"] / S["atm"] # skew normalizzato dal livello (toglie il DVOL) + return S + + +# ============================================================================= +# 2. Q1 — lo skew ANTICIPA lo spot? +# ============================================================================= +def px5m(asset: str, t0: pd.Timestamp) -> pd.Series: + p = pd.read_parquet(ROOT / "data" / "raw" / f"{asset.lower()}_5m.parquet", + columns=["timestamp", "close"]) + s = pd.Series(p["close"].to_numpy(float), + index=pd.DatetimeIndex(pd.to_datetime(p["timestamp"], unit="ms", utc=True))) + return s.sort_index().loc[t0 - pd.Timedelta("3h"):] + + +def leadlag(S: pd.DataFrame, asset: str) -> pd.DataFrame: + """Correlazione fra la variazione oraria dello skew e i rendimenti PRIMA e DOPO l'istante + esatto in cui le quote sono state osservate (`ts_max`). L'ancora conta: con un'ancora + assunta a :30 invece del `ts_max` vero compare un falso lead a +15m.""" + S = S.sort_values("ts_max") + px = px5m(asset, S.index.min()) + anc = pd.Series(px.reindex(pd.DatetimeIndex(S["ts_max"]), method="ffill").to_numpy(), + index=S.index) + drr = S["rr"].diff() + lvl = S["rr"] + rows = [] + for mins in (-60, -30, -15, -5, 5, 15, 30, 60, 120, 240, 1440, 4320): + tgt = pd.DatetimeIndex(S["ts_max"]) + pd.Timedelta(minutes=int(mins)) + p2 = pd.Series(px.reindex(tgt, method="ffill").to_numpy(), index=S.index) + r = np.log(p2 / anc) if mins > 0 else np.log(anc / p2) # sempre "rendimento nel verso del tempo" + j = pd.concat({"d": drr, "l": lvl, "r": r}, axis=1).dropna() + n = len(j) + rows.append(dict(asset=asset, minuti=mins, n=n, + corr_dRR=round(float(j["d"].corr(j["r"])), 3), + corr_RR=round(float(j["l"].corr(j["r"])), 3), + se=round(1 / np.sqrt(max(n, 2)), 3), + t_dRR=round(float(j["d"].corr(j["r"]) * np.sqrt(n)), 2))) + return pd.DataFrame(rows) + + +def leadlag_ancora_sbagliata(S: pd.DataFrame, asset: str) -> pd.DataFrame: + """Controllo POSITIVO del punto precedente: la stessa misura con l'ancora ASSUNTA a :30 + dell'ora (invece del `ts_max` osservato) deve mostrare il falso lead. Se non lo mostra, + la correzione non stava correggendo niente.""" + px = px5m(asset, S.index.min()) + fake = pd.DatetimeIndex(S.index) + pd.Timedelta("30min") + anc = pd.Series(px.reindex(fake, method="ffill").to_numpy(), index=S.index) + drr = S["rr"].diff() + rows = [] + for mins in (5, 15, 30, 60): + p2 = pd.Series(px.reindex(fake + pd.Timedelta(minutes=mins), method="ffill").to_numpy(), + index=S.index) + j = pd.concat({"d": drr, "r": np.log(p2 / anc)}, axis=1).dropna() + rows.append(dict(asset=asset, minuti=mins, corr_dRR_ancora_finta=round(float(j["d"].corr(j["r"])), 3))) + return pd.DataFrame(rows) + + +def daily_signal(S: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame: + """Allinea la superficie alla griglia dei prezzi in modo CAUSALE. + + Le barre del progetto sono open-labeled: la barra 1d etichettata D chiude alle 23:00 di D. + Quindi al momento della decisione della barra D e' disponibile l'ultima ora <= D 23:00, e + `eval_weights` la tiene durante D+1. Con merge_asof su epoca esplicita in millisecondi + (lezione 01/07: `.view("int64")` su tz-aware non-ns sbaglia scala e broadcasta).""" + bpd = A.bars_per_day(df) + chiusura = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + chiusura = chiusura + (pd.Timedelta("24h") - pd.Timedelta("1h") if bpd == 1 else pd.Timedelta(0)) + left = pd.DataFrame({"t_ms": chiusura.astype("datetime64[ms]").astype("int64")}) + right = pd.DataFrame({"t_ms": pd.DatetimeIndex(S["ts_max"]).astype("datetime64[ms]").astype("int64"), + "rr": S["rr"].to_numpy(), "rr_n": S["rr_n"].to_numpy(), + "bf": S["bf"].to_numpy(), "atm": S["atm"].to_numpy()}).sort_values("t_ms") + m = pd.merge_asof(left.sort_values("t_ms"), right, on="t_ms", direction="backward", + tolerance=int(pd.Timedelta("6h").total_seconds() * 1000)) + return m[["rr", "rr_n", "bf", "atm"]] + + +def make_target(S: pd.DataFrame, feat: str, win: int, segno: int): + """target_fn(df, asset) -> posizione continua vol-targeted. Causale per costruzione: + ogni feature usa solo z-score rolling su dati <= barra corrente, e `eval_weights` sposta.""" + def fn(df, asset=None): + F = daily_signal(S, df) + x = F["rr_n"] if feat.endswith("_n") else F["rr"] + if feat.startswith("d"): + x = x.diff() + z = (x - x.rolling(win, min_periods=max(5, win // 2)).mean()) / \ + x.rolling(win, min_periods=max(5, win // 2)).std() + d = np.tanh(np.nan_to_num(z.to_numpy(), nan=0.0)) * segno + return A.vol_target(d, df, target_vol=0.20, leverage_cap=2.0) + return fn + + +def sharpe_boot(s: pd.Series, blocco: int = 10, n: int = 500, seed: int = 20260822) -> tuple: + """IC95% dello Sharpe con block bootstrap circolare (il segnale e' lentissimo: i giorni + NON sono indipendenti, e una SE analitica mentirebbe).""" + r = np.asarray(s.dropna().to_numpy(), float) + T = len(r) + if T < 20: + return (np.nan, np.nan) + rng = np.random.default_rng(seed) + nb = int(np.ceil(T / blocco)) + out = [] + for _ in range(n): + st = rng.integers(0, T, nb) + idx = (st[:, None] + np.arange(blocco)[None, :]).ravel()[:T] % T + x = r[idx] + out.append(np.mean(x) / np.std(x) * np.sqrt(365.25) if np.std(x) > 0 else 0.0) + return (round(float(np.percentile(out, 2.5)), 2), round(float(np.percentile(out, 97.5)), 2)) + + +def causality_finestra(S: pd.DataFrame, fn, asset: str) -> dict: + """`A.causality_ok` taglia il prefisso all'80%/92% della storia dei PREZZI (2024/2025): + fuori dalla finestra dello skew, dove il segnale e' zero -> confronterebbe zeri con zeri, + POTENZA NULLA (stessa trappola del self-check di SKH01: si campiona sugli EVENTI). + Qui i tagli cadono DENTRO la finestra.""" + df = A.get(asset, "1d") + full = np.nan_to_num(np.asarray(fn(df, asset), float)) + n = len(df) + dts = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + dentro = np.where(dts >= S.index.min())[0] + peggio, controlli = 0.0, 0 + for frac in (0.4, 0.7, 0.9): + cut = int(dentro[0] + frac * (n - dentro[0])) + if cut <= dentro[0] + 5 or cut >= n: + continue + sub = df.iloc[:cut].reset_index(drop=True) + s = np.nan_to_num(np.asarray(fn(sub, asset), float)) + coda = min(20, cut - dentro[0]) + peggio = max(peggio, float(np.max(np.abs(s[cut - coda:cut] - full[cut - coda:cut])))) + controlli += 1 + return dict(ok=bool(peggio <= 1e-6 and controlli > 0), max_diff=round(peggio, 10), + controlli=controlli, non_zero=int((full[dentro] != 0).sum())) + + +# ============================================================================= +# 3. Q2 — lo skew come GATE DI RISCHIO sopra TP01 +# ============================================================================= +def dd_of(r: np.ndarray) -> float: + eq = np.cumprod(1.0 + np.nan_to_num(r)) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def sh_of(r: np.ndarray) -> float: + r = np.asarray(r, float) + return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0 + + +def null_de_levering(base: np.ndarray, overlay: np.ndarray) -> dict: + """Il primo test di OGNI claim su meno drawdown: esiste k<=1 che, applicato al baseline, + da' lo STESSO DD dell'overlay ma con Sharpe MIGLIORE? Se si', l'overlay e' solo meno leva.""" + dd_t = dd_of(overlay) + lo, hi = 0.0, 1.0 + for _ in range(60): + k = 0.5 * (lo + hi) + if dd_of(k * base) > dd_t: + hi = k + else: + lo = k + k = 0.5 * (lo + hi) + return dict(k=round(k, 3), dd_overlay=round(dd_t, 4), dd_base_k=round(dd_of(k * base), 4), + sh_overlay=round(sh_of(overlay), 3), sh_base_k=round(sh_of(k * base), 3), + superato=bool(sh_of(overlay) > sh_of(k * base))) + + +# ============================================================================= +# 4. Q3 — lo skew spiega l'errore di prezzo di VRP01? +# ============================================================================= +def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame: + """Per ogni ingresso settimanale di VRP01 (stessa macchina di `cblib`, cosi' i numeri sono + confrontabili con lo 0.718 pubblicato il 30/07), scompone il f in fattori MOLTIPLICATIVI: + + f_tot = f_term x f_skew x f_markfit x f_spread + + f_term = credito prezzato ad ATM 7g piatta / credito prezzato a DVOL30 piatta + (quanto del difetto e' STRUTTURA A TERMINE: il sleeve prezza un'opzione a 7 + giorni con l'indice a 30 giorni) + f_skew = credito prezzato a IV PER GAMBA / credito ad ATM 7g piatta + (quanto e' SKEW: l'ala che si compra non vale la vol ATM) + f_markfit = credito ai MID reali / credito a IV per gamba (controllo: deve stare a ~1, + altrimenti la IV di Deribit non riprezza i propri mid e tutta la catena di + ragionamento poggia su un modello invece che su un prezzo) + f_spread = credito al fill CONSERVATIVO (vendi al bid, compri all'ask) / credito ai mid + """ + righe = [] + for a in ASSETS: + S7 = cm7[a] + E = CB.weekly_entries(a, q) + for _, e in E.iterrows(): + prima = S7.index[S7.index <= e["ts"]] + if len(prima) == 0 or (e["ts"] - prima[-1]) > pd.Timedelta("6h"): + continue + s = S7.loc[prima[-1]] + T = e["dte"] / 365.25 + dvol = e["dvol_pct"] / 100.0 + atm7 = s["atm"] / 100.0 + c_mod = CB.bs_put(e["spot"], e["k_short"], T, dvol) - CB.bs_put(e["spot"], e["k_long"], T, dvol) + c_atm = CB.bs_put(e["spot"], e["k_short"], T, atm7) - CB.bs_put(e["spot"], e["k_long"], T, atm7) + c_sml = (CB.bs_put(e["spot"], e["k_short"], T, e["iv_short"] / 100.0) + - CB.bs_put(e["spot"], e["k_long"], T, e["iv_long"] / 100.0)) + c_mid = e["cred_mid"] + c_real = e["cred_real"] + righe.append(dict( + asset=a, ts=e["ts"], dte=round(e["dte"], 2), ivrank=round(e["ivrank"], 3), + dvol=round(e["dvol_pct"], 2), atm7=round(s["atm"], 2), + rr7=round(s["rr"], 2), bf7=round(s["bf"], 2), + iv_short=round(e["iv_short"], 2), iv_long=round(e["iv_long"], 2), + d_short=round(e["d_short"], 3), d_long=round(e["d_long"], 3), + f_term=c_atm / c_mod if c_mod > 0 else np.nan, + f_skew=c_sml / c_atm if c_atm > 0 else np.nan, + f_markfit=c_mid / c_sml if c_sml > 0 else np.nan, + f_spread=c_real / c_mid if c_mid > 0 else np.nan, + f_tot_mid=c_mid / c_mod if c_mod > 0 else np.nan, + f_tot=c_real / c_mod if c_mod > 0 else np.nan, + quota_ala=CB.bs_put(e["spot"], e["k_long"], T, dvol) / + CB.bs_put(e["spot"], e["k_short"], T, dvol), + )) + return pd.DataFrame(righe) + + +# ============================================================================= +def main() -> None: + t_start = time.time() + rule("r0822_skew — SKEW (risk reversal 25-delta): la dimensione mai usata della superficie") + + # ---------------------------------------------------------------- 0. DATO + rule("0. IL DATO — quote, non righe") + q, cont = load_quoted() + print(f" righe totali nella catena : {cont['righe']:,}") + print(f" dopo dedup (ts, strumento) : {cont['dedup']:,}") + print(f" QUOTATE (bid>0, ask>0, iv/delta ok) : {cont['quotate']:,} = {cont['frac_quotate']:.1%}") + print(f" quote_status : {cont['quote_status']}") + print(f" usate (0.5 {q['ts'].max()}") + print("\n TICK (regola: prima di credere a un rapporto estremo su un prezzo piccolo, contarli)") + print(conta_tick(q).to_string(index=False)) + + # ---------------------------------------------------------- 1. SUPERFICIE + rule("1. SUPERFICIE — smile per delta, poi maturita' costante") + t0 = time.time() + R = build_smile(q) + print(f" celle (asset, ora, scadenza) con smile ricostruibile: {len(R):,} [{time.time()-t0:.0f}s]") + perday = R.groupby(["asset", pd.DatetimeIndex(R["hr"]).date]).agg(exps=("exp", "nunique")) + print(f" giorni con >=2 scadenze (maturita' costante possibile): " + f"{int((perday['exps'] >= 2).sum())} / {len(perday)} coppie asset-giorno") + cm7, cm30 = {}, {} + for a in ASSETS: + cm7[a] = const_maturity(R, a, 7.0) + cm30[a] = const_maturity(R, a, 30.0) + s = cm30[a] + print(f" {a}: CM30 ore={len(s):5d} giorni={len(np.unique(pd.DatetimeIndex(s.index).date)):3d}" + f" {s.index.min()} -> {s.index.max()}") + print(f" RR30 media {s['rr'].mean():+.2f} pp sd {s['rr'].std():.2f} " + f"[{s['rr'].min():+.2f}, {s['rr'].max():+.2f}] ATM media {s['atm'].mean():.1f}% " + f"BF30 media {s['bf'].mean():+.2f}") + print(f" CM7 ore={len(cm7[a]):5d} RR7 media {cm7[a]['rr'].mean():+.2f} sd {cm7[a]['rr'].std():.2f}") + print("\n ⚠ La finestra utile parte dal 2026-06-09 e NON dal 2026-05-01: prima di quella data") + print(" cerbero-bite raccoglieva UNA sola scadenza per ora -> nessuna maturita' costante.") + print(" righe presenti != superficie presente. Le ore perse sono ~38 giorni su 113.") + + # ------------------------------------------------------------------ 2. Q1 + rule("2. Q1 — lo skew ANTICIPA lo spot? (potenza vera: N orario ~1.800)") + LL = pd.concat([leadlag(cm30[a], a) for a in ASSETS], ignore_index=True) + for a in ASSETS: + print(f"\n {a} — corr fra dRR (variazione oraria dello skew) e il rendimento nella") + print(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):") + print(LL[LL["asset"] == a].to_string(index=False)) + print("\n Controllo POSITIVO — la stessa misura con l'ancora ASSUNTA a :30 dell'ora:") + FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True) + print(FF.to_string(index=False)) + + # strategia giornaliera + rule("2b. Q1 — lo skew come SEGNALE DIREZIONALE giornaliero (griglia dichiarata)") + feats = ["rr", "rr_n", "drr"] + wins = [10, 20, 40] + segni = [+1, -1] + celle, serie = [], {} + for f in feats: + for w in wins: + for sg in segni: + nome = f"{f}|w{w}|sg{sg:+d}" + per_asset = {} + for a in ASSETS: + df = A.get(a, "1d") + Sx = cm30[a] + m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= ( + Sx.index.min().floor("D")) + sub = df[m].reset_index(drop=True) + tgt = make_target(Sx, f, w, sg)(sub, a) + ev = A.eval_weights(sub, tgt) + per_asset[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(per_asset, axis=1, join="inner").fillna(0.0) + d = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]] + d.index = pd.DatetimeIndex(d.index) + serie[nome] = d + celle.append(dict(cella=nome, sharpe=round(sh_of(d.to_numpy()), 3), + maxdd=round(dd_of(d.to_numpy()), 4), + cagr=round(float((1 + d).prod() ** (365.25 / len(d)) - 1), 4))) + C = pd.DataFrame(celle).sort_values("sharpe", ascending=False) + print(f" griglia: {len(feats)} feature x {len(wins)} finestre-z x {len(segni)} segni = {len(C)} celle") + print(C.head(6).to_string(index=False)) + print(" ...") + print(C.tail(3).to_string(index=False)) + best = C.iloc[0]["cella"] + bs = serie[best] + lo, hi = sharpe_boot(bs) + print(f"\n MIGLIORE della griglia: {best} Sharpe {C.iloc[0]['sharpe']:+.3f}") + print(f" IC95% block-bootstrap (blocchi 10g, 500 estrazioni): [{lo:+.2f}, {hi:+.2f}] n={len(bs)} giorni") + print(f" ⚠ ampiezza dell'IC = {hi-lo:.2f} di Sharpe: con {len(bs)} giorni la misura non separa") + print(" un edge da zero. Nessuna cella di questa griglia e' distinguibile dal rumore.") + dsr, sr0 = A.deflated_sharpe(C.iloc[0]["sharpe"], C["sharpe"].tolist(), bs) + print(f" deflated Sharpe della cella migliore: DSR={dsr:.3f} (soglia 0.95), " + f"max atteso sotto il nullo {sr0:+.2f}") + print(" NB: e' un deflated-Sharpe BEST-OF-GRID, non in-sample-selected: `select_cell_insample`") + print(" NON e' eseguibile qui (HOLDOUT=2025-01-01, l'in-sample e' VUOTO).") + + print("\n marginal_vs_tp01 sulla cella migliore:") + try: + rep = A.marginal_vs_tp01(bs) + for k in ("marginal_verdict", "corr_full", "n_days", "has_insample_edge", "is_hedge", + "beats_noise_null", "robust_oos"): + if k in rep: + print(f" {k:20s} = {rep[k]}") + except Exception as ex: + print(f" non girato: {ex}") + + print("\n causalita' (tagli DENTRO la finestra dello skew, non all'80% della storia prezzi):") + for a in ASSETS: + f_, w_, sg_ = best.split("|") + fn = make_target(cm30[a], f_, int(w_[1:]), int(sg_.replace("sg", ""))) + print(f" {a}: {causality_finestra(cm30[a], fn, a)}") + + # ------------------------------------------------------------------ 3. Q2 + rule("3. Q2 — lo skew come GATE DI RISCHIO sopra TP01") + B = A.tp01_baseline_daily() + inizio = min(cm30[a].index.min() for a in ASSETS).floor("D") + Bw = B[B.index >= inizio] + z_all = {} + for a in ASSETS: + df = A.get(a, "1d") + m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= inizio + sub = df[m].reset_index(drop=True) + F = daily_signal(cm30[a], sub) + x = F["rr_n"] + z = (x - x.rolling(20, min_periods=10).mean()) / x.rolling(20, min_periods=10).std() + z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True))) + Z = pd.concat(z_all, axis=1).mean(axis=1) + Z = Z.reindex(Bw.index).ffill() + print(f" baseline TP01 sulla finestra dello skew: n={len(Bw)} giorni, " + f"Sharpe {sh_of(Bw.to_numpy()):+.2f}, maxDD {dd_of(Bw.to_numpy()):.2%}") + for thr in (-0.5, -1.0, -1.5): + gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso) + ov = (Bw * gate).to_numpy() + n_fire = int((gate == 0).sum()) + nul = null_de_levering(Bw.to_numpy(), ov) + gg = Bw[gate == 0] + print(f" soglia z<{thr:+.1f}: giorni de-riskati {n_fire:3d}/{len(Bw)} " + f"({n_fire/len(Bw):.1%}) Sharpe {sh_of(ov):+.2f} (base {sh_of(Bw.to_numpy()):+.2f}) " + f"maxDD {dd_of(ov):.2%}") + print(f" null de-levering: k={nul['k']} -> stesso DD {nul['dd_base_k']:.2%} con " + f"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} " + f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}") + if n_fire: + print(f" nei giorni de-riskati TP01 faceva in media {gg.mean()*100:+.3f}%/g " + f"(gli altri {Bw[gate==1].mean()*100:+.3f}%/g)") + print("\n ridondanza col trend — corr fra lo z dello skew e il rendimento di TP01: " + f"{float(pd.concat({'z': Z, 'b': Bw}, axis=1).dropna().corr().iloc[0,1]):+.3f}") + print(" POTENZA: con 75 giorni un gate che scatta ~10 volte non e' misurabile. Il numero da") + print(" guardare non e' lo Sharpe dell'overlay ma il conto dei giorni in cui scatta.") + + # ------------------------------------------------------------------ 4. Q3 + rule("4. Q3 — lo skew SPIEGA l'errore di prezzo di VRP01 (f=0.73 pubblicato il 30/07)") + D = vrp_decomposizione(q, cm7) + print(f" ingressi settimanali ricostruiti: {len(D)} ({D.groupby('asset').size().to_dict()})") + print("\n REPLICA del numero pubblicato (deve tornare ~0.72 sul canonico 7g d-0.28/-0.10):") + print(f" f_tot (fill conservativo) mediana = {D['f_tot'].median():.3f} " + f"media = {D['f_tot'].mean():.3f}") + print(f" f_tot ai MID mediana = {D['f_tot_mid'].median():.3f}") + print("\n IV per gamba contro i due riferimenti (pp di vol):") + print(f" IV(corta) - DVOL30 : {(D['iv_short']-D['dvol']).median():+.2f} pp " + f"IV(lunga) - DVOL30 : {(D['iv_long']-D['dvol']).median():+.2f} pp [pubblicato: +0.8 / +7.5]") + print(f" IV(corta) - ATM7 : {(D['iv_short']-D['atm7']).median():+.2f} pp " + f"IV(lunga) - ATM7 : {(D['iv_long']-D['atm7']).median():+.2f} pp") + print(f" ATM7 - DVOL30 : {(D['atm7']-D['dvol']).median():+.2f} pp <- pura struttura a termine") + print("\n DECOMPOSIZIONE MOLTIPLICATIVA del f (mediane; f_tot = term x skew x markfit x spread)") + for a in ASSETS + ("TUTTI",): + g = D if a == "TUTTI" else D[D["asset"] == a] + if g.empty: + continue + print(f" {a:6s} n={len(g):2d} | f_term {g['f_term'].median():.3f} | " + f"f_skew {g['f_skew'].median():.3f} | f_markfit {g['f_markfit'].median():.3f} | " + f"f_spread {g['f_spread'].median():.3f} || f_tot {g['f_tot'].median():.3f}") + print("\n controllo obbligatorio: f_markfit deve stare a ~1 (la IV di Deribit deve riprezzare") + print(f" i suoi stessi mid). Mediana {D['f_markfit'].median():.3f}, " + f"banda [{D['f_markfit'].quantile(.1):.3f}, {D['f_markfit'].quantile(.9):.3f}].") + print(" Se sta a 1, RR e BF di questa sessione sono un fatto di PREZZO, non un artefatto") + print(" del fit di Deribit -> vale anche come controllo di confound per Q1/Q2.") + + print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)") + D2 = D.dropna(subset=["f_tot_mid", "rr7"]) + for var in ("rr7", "atm7", "ivrank", "bf7"): + c = float(D2["f_tot_mid"].corr(D2[var])) + print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [n={len(D2)}]") + print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} " + f"max {D['ivrank'].max():.3f}") + n_gate = int((D["ivrank"] > 0.30).sum()) + print(f" ingressi che passerebbero il gate IV-rank>0.30 di VRP01: {n_gate}/{len(D)}") + print(" -> il f di VRP01 e' misurato INTERAMENTE nel regime in cui il sleeve sta FLAT.") + + print("\n Tabella per ingresso:") + cols = ["asset", "ts", "dte", "ivrank", "dvol", "atm7", "rr7", "iv_short", "iv_long", + "f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"] + P = D[cols].copy() + P["ts"] = pd.DatetimeIndex(P["ts"]).strftime("%Y-%m-%d %H:%M") + for c in ("f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"): + P[c] = P[c].round(3) + print(P.to_string(index=False)) + + rule(f"fine — {time.time()-t_start:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_term_structure.py b/scripts/research/r0822_term_structure.py new file mode 100644 index 0000000..67521b3 --- /dev/null +++ b/scripts/research/r0822_term_structure.py @@ -0,0 +1,721 @@ +"""TERM-STRUCTURE della vol implicita BTC/ETH — pendenza, carry, gate di rischio (ondata 2026-08-22). + +DOMANDA. Tre, dichiarate prima di misurare: + Q1 la PENDENZA (backwardation = front > back) PRECEDE i ritorni, o e' contemporanea? + Q2 il CARRY di vol (roll-down lungo la curva) e' INCASSABILE al bid-ask reale? + Q3 vale come GATE DI RISCHIO sopra TP01/SKH01? + +IPOTESI A PRIORI (registrate prima di guardare i numeri — servono a poterle smentire): + Q1 contemporanea. Il front reagisce allo spot: un tuffo alza la vol a 7g piu' di quella a 30g. + Se e' cosi', la pendenza e' un TERMOMETRO, non un segnale. + Q2 no. Il roll-down fra 7g e 30g vale frazioni di punto-vol al giorno; il bid-ask ATM di + Deribit vale punti-vol INTERI. L'aritmetica dovrebbe chiudere la questione senza backtest. + Q3 ridondante col trend, come i 4 precedenti (DVOL-spike, macro, funding, breadth): un gate di + de-risk lavora nei giorni in cui TP01 e' gia' flat. + +IL PRECEDENTE SCOMODO (03/07, `r0703_vrpimp_*`): un gate di term-structure VIX/VXV su SPX valeva ++0.90 di Sharpe (DSR 0.992) ed era un **confound di modello al 100%** — la variabile del gate +coincideva con l'errore di prezzatura BS-flat vs term-structure. Da li' la regola: *riprezzare +term-structure-consistent prima di credere a un gate vol su strutture BS-flat*. +QUI QUEL CONFOUND NON PUO' ESISTERE: non si prezza nessuna opzione. Il sottostante e' il perp +BTC/ETH, i ritorni sono quelli del feed certificato, e la term structure entra solo come +VARIABILE OSSERVATA. Quindi il 03/07 non e' una scusa per non guardare — ed e' esattamente +percio' che serve cercare il confound *di questo* filone, che e' un altro: + + ⚠️ IL CONFOUND DI QUESTO FILONE: iv(7g) e' in larga parte una funzione della vol REALIZZATA + recente. Se e' cosi', `slope = iv30 - iv7` non e' un'informazione nuova ma un + RICONFEZIONAMENTO di RV — cioe' la stessa variabile di `dvol_directional.py` (VRP-Z), gia' + giudicata **HEDGE, earns_slot=False** su 5 anni il 2026-06-29. Misurato in SEZIONE 2. + +IL DATO, e il muro che ci sta dentro. La term structure richiede >=2 scadenze nello STESSO +istante. `bite_archive` prima del **2026-06-09** collezionava **UNA scadenza per giro**: la +finestra utilizzabile NON e' "3,7 mesi di catena", e' **dal 2026-06-09** (~74 giorni). +Con 74 giorni **non esiste un hold-out** e SE(Sharpe) ~ sqrt(365/74) ~ 2.2: qualunque Sharpe +misurato qui e' a una deviazione standard da zero. Verdetto massimo: LEAD con gate e data. +Si conta cio' che e' QUOTATO (bid>0, ask>0, iv presente), non le righe: il guasto 29-30/07 ha +fino al 51% di quote vuote in un giorno. + +METODO. ATM IV per scadenza = interpolazione della IV sul DELTA a 0.5 (call) / -0.5 (put), mai +estrapolata. Il delta referenzia il FORWARD, quindi la curva non eredita lo skew via la base +(prendere "lo strike piu' vicino allo spot" lo farebbe: a 90g il forward e' lontano dallo spot). +Interpolazione ai tenori fissi in VARIANZA TOTALE (w = iv^2 * T lineare in T), stile VIX. + + nice -n 19 timeout 900 uv run python scripts/research/r0822_term_structure.py +""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +import altlib as A # noqa: E402 + +RAW = ROOT / "data" / "raw" +STORE = RAW / "cb_chain" +CACHE = Path(os.environ.get("TMPDIR", tempfile.gettempdir())) / "r0822_ts_cache" +ASSETS = ("BTC", "ETH") + +# Muro del dato: prima di questa data l'archivio ha UNA scadenza per giro -> nessuna curva. +TS_START = pd.Timestamp("2026-06-09", tz="UTC") +TENORS = (7, 14, 30, 60, 90) +FRONT, BACK = 7, 30 # la pendenza canonica di questo studio +DTE_MIN = 0.5 # sotto mezza giornata l'ATM IV e' rumore di microstruttura + +# Vincoli di venue DICHIARATI (misurati il 2026-07-30 su `get_instrument`, non ri-letti qui: +# questo script non tocca la rete). Servono alla sezione di eseguibilita'. +MIN_LOT = {"BTC": 0.1, "ETH": 1.0} # contratti minimi opzioni Deribit +OPT_FEE_UNDERLYING = 0.0003 # 0,03% del sottostante per gamba +OPT_FEE_CAP_PREMIUM = 0.125 # cap: 12,5% del premio della singola opzione +CAPITALE = 635.0 # il conto vero + +# Conteggio dei trial, AL RIALZO (sezione 6). Ogni voce e' una configurazione VALUTATA. +TRIALS: list[tuple[str, int]] = [] + + +def hr(t: str = "") -> None: + print("\n" + "=" * 100) + if t: + print(t) + print("=" * 100) + + +def sub(t: str) -> None: + print(f"\n--- {t} " + "-" * max(0, 96 - len(t))) + + +# =========================================================================================== +# SEZIONE 0 — IL DATO +# =========================================================================================== +def load_quoted_chain() -> pd.DataFrame: + """Catena filtrata IN LETTURA (colonne + finestra), tenendo solo cio' che e' QUOTATO. + + "Quotato" = bid>0 AND ask>0 AND iv presente AND delta presente. Una riga con bid/ask NULL + esiste (il collettore la persiste) ma non e' un prezzo: contarla come dato e' l'errore che + il progetto ha gia' fatto tre volte (paper_dvolspread, fresh_5m, guasto 29/07). + """ + cols = ["asset", "option_type", "strike", "iv", "delta", "bid", "ask", + "vega", "ts", "exp", "quote_status"] + parts = [] + arch = STORE / "bite_archive.parquet" + if arch.exists(): + d = pd.read_parquet(arch, columns=cols) + parts.append(d[d["ts"] >= TS_START]) + del d + for p in sorted(STORE.glob("2026-*.parquet")): + parts.append(pd.read_parquet(p, columns=cols)) + if not parts: + raise FileNotFoundError(f"{STORE}: nessuna catena") + df = pd.concat(parts, ignore_index=True) + del parts + df = df.drop_duplicates(subset=["ts", "asset", "strike", "option_type", "exp"], keep="last") + df["hr"] = df["ts"].dt.floor("h") + df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0 + df["quoted"] = (df["bid"] > 0) & (df["ask"] > 0) & df["iv"].notna() & df["delta"].notna() + return df + + +def sezione0(chain: pd.DataFrame) -> None: + hr("SEZIONE 0 — IL DATO: quanto ce n'e' davvero, e da quando") + print(f" finestra letta : {chain.ts.min()} -> {chain.ts.max()}") + print(f" righe : {len(chain):,}") + print(f" righe QUOTATE : {int(chain.quoted.sum()):,} ({chain.quoted.mean():.1%})") + print("\n ⚠️ IL MURO: la term structure richiede >=2 scadenze nello STESSO istante.") + arch = STORE / "bite_archive.parquet" + if arch.exists(): + pre = pd.read_parquet(arch, columns=["asset", "ts", "exp"]) + pre["hr"] = pre["ts"].dt.floor("h") + n_pre = pre[pre.ts < TS_START].groupby(["asset", "hr"])["exp"].nunique() + n_post = pre[pre.ts >= TS_START].groupby(["asset", "hr"])["exp"].nunique() + print(f" archivio PRIMA del {TS_START.date()}: scadenze/ora mediana = " + f"{n_pre.median():.0f} (max {n_pre.max():.0f}) su {len(n_pre)} ore-asset") + print(f" archivio DOPO : mediana = {n_post.median():.0f} " + f"(max {n_post.max():.0f}) su {len(n_post)} ore-asset") + print(" => la catena copre dal 2026-05-01, ma la CURVA esiste solo dal 2026-06-09.") + print(" Non sono 3,7 mesi: sono ~74 giorni. Nessun hold-out e' costruibile.") + del pre + q = chain.groupby([chain.hr.dt.date, "asset"])["quoted"].mean().unstack() + bad = q[(q < 0.70).any(axis=1)] + print(f"\n giorni con quote quotate <70% su almeno un asset: {len(bad)}") + if len(bad): + print(bad.round(3).to_string().replace("\n", "\n ")) + print(" (guasto noto 29-30/07: rate-limit per-IP saturato dal collettore di bite.)") + print(f"\n minuto dello snapshot (mediana): archivio bite = " + f"{chain[chain.ts < pd.Timestamp('2026-07-30', tz='UTC')].ts.dt.minute.median():.0f}', " + f"raccolta propria = {chain[chain.ts >= pd.Timestamp('2026-07-31', tz='UTC')].ts.dt.minute.median():.0f}'") + print(" -> lo snapshot dell'ora t cade DENTRO la barra oraria t. Ogni ritorno usato come") + print(" bersaglio parte dalla CHIUSURA della barra t (>=35 min dopo): buffer causale.") + + +# =========================================================================================== +# SEZIONE 1 — RICOSTRUZIONE DELLA CURVA + CERTIFICAZIONE +# =========================================================================================== +def _atm_iv(g: pd.DataFrame) -> float: + """ATM IV di UNO snapshot+scadenza: interpolazione sul DELTA, mai estrapolazione.""" + vals = [] + for typ, tgt in (("C", 0.5), ("P", -0.5)): + s = g[g.option_type == typ] + if len(s) >= 2: + d = s["delta"].to_numpy() + if d.min() <= tgt <= d.max(): + o = np.argsort(d) + vals.append(float(np.interp(tgt, d[o], s["iv"].to_numpy()[o]))) + return float(np.mean(vals)) if vals else np.nan + + +def build_atm_by_expiry(chain: pd.DataFrame) -> pd.DataFrame: + f = CACHE / "atm_by_expiry.parquet" + if f.exists(): + return pd.read_parquet(f) + q = chain[chain.quoted & (chain.dte > DTE_MIN)] + out = (q.groupby(["asset", "hr", "exp"]) + .apply(lambda g: pd.Series({"iv": _atm_iv(g), "dte": g["dte"].median()}), + include_groups=False) + .reset_index().dropna(subset=["iv"])) + CACHE.mkdir(parents=True, exist_ok=True) + out.to_parquet(f) + return out + + +def _curve_row(g: pd.DataFrame) -> pd.Series: + g = g.sort_values("dte") + t = g["dte"].to_numpy() + w = (g["iv"].to_numpy() / 100.0) ** 2 * t # varianza totale + o = {} + for T in TENORS: + o[f"iv_{T}"] = (np.sqrt(np.interp(T, t, w) / T) * 100.0 + if len(t) >= 2 and t.min() <= T <= t.max() else np.nan) + o["n_exp"] = len(t) + return pd.Series(o) + + +def build_curve(atm: pd.DataFrame) -> pd.DataFrame: + f = CACHE / "curve.parquet" + if f.exists(): + return pd.read_parquet(f) + c = atm.groupby(["asset", "hr"]).apply(_curve_row, include_groups=False).reset_index() + CACHE.mkdir(parents=True, exist_ok=True) + c.to_parquet(f) + return c + + +def sezione1(curve: pd.DataFrame) -> dict: + hr("SEZIONE 1 — RICOSTRUZIONE DELLA CURVA E SUA CERTIFICAZIONE") + print(" ATM = IV interpolata sul delta 0.5/-0.5 (mai estrapolata); tenori fissi in varianza totale.") + cov = curve[[f"iv_{T}" for T in TENORS]].notna().mean() + print("\n copertura per tenore (frazione delle ore-asset ricostruibili):") + for T in TENORS: + print(f" iv_{T:<3d} {cov[f'iv_{T}']:.1%}") + print(f" -> iv_90 e' sotto il 10%: SCARTATO dallo studio. La pendenza canonica e' " + f"iv_{BACK} - iv_{FRONT}.") + + sub("controllo 1 — contro il logger indipendente `vol_term_*.parquet` (altro codice, altra fonte)") + print(" (`log_vol_termstructure.py`: mark_iv da book_summary, ATM = strike piu' vicino allo") + print(" spot, interpolazione diversa. Se le due strade coincidono, la mia non e' un artefatto.)") + agree = {} + for a in ASSETS: + p = RAW / f"vol_term_{a.lower()}.parquet" + if not p.exists(): + print(f" {a}: {p.name} assente — controllo NON GIRATO") + continue + vt = pd.read_parquet(p).set_index("date") + mine = curve[curve.asset == a].set_index("hr") + j = vt.join(mine[[f"iv_{T}" for T in TENORS]], how="inner") + for T in (7, 30, 60): + x, y = j[f"iv_{T}d"], j[f"iv_{T}"] + m = x.notna() & y.notna() + if m.sum() >= 10: + print(f" {a} iv_{T:<3d} n={m.sum():3d} corr={x[m].corr(y[m]):+.4f} " + f"scarto medio={(y[m] - x[m]).mean():+.3f} pt-vol") + agree[(a, T)] = float(x[m].corr(y[m])) + + sub("controllo 2 — contro il DVOL (indice Deribit a 30g, storia 2021+)") + for a in ASSETS: + dv = pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet") + s = pd.Series(dv["close"].astype(float).values, + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index() + mine = curve[curve.asset == a].set_index("hr")["iv_30"].dropna() + j = pd.DataFrame({"mine": mine}) + j["dvol"] = s.reindex(j.index, method="ffill") + j = j.dropna() + print(f" {a}: n={len(j)} corr={j.mine.corr(j.dvol):+.4f} " + f"bias={(j.mine - j.dvol).mean():+.3f} pt-vol sd={(j.mine - j.dvol).std():.3f}") + print(" Il bias NEGATIVO e' atteso e conferma la ricostruzione: il DVOL e' un indice tipo") + print(" variance-swap (integra tutto lo smile) e sta STRUTTURALMENTE sopra l'ATM per lo skew.") + print(" Un bias ~0 sarebbe stato il segnale d'allarme, non questo.") + return agree + + +# =========================================================================================== +# SEZIONE 2 — Q1: LEAD-LAG. La pendenza precede o segue? +# =========================================================================================== +def slope_hourly(curve: pd.DataFrame, a: str) -> pd.Series: + s = curve[curve.asset == a].set_index("hr") + return (s[f"iv_{BACK}"] - s[f"iv_{FRONT}"]).dropna().sort_index() + + +def px_hourly(a: str) -> pd.Series: + d = A.get(a, "1h") + return pd.Series(d["close"].astype(float).values, + index=pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True))) + + +def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 800, + seed: int = 20260822) -> float: + """Percentile della correlazione osservata contro il null a BLOCCHI (y ricampionata da + blocchi non allineati a x): conserva l'autocorrelazione e rompe solo l'accoppiamento.""" + rng = np.random.default_rng(seed) + m = len(x) + if m <= L + 5: + return float("nan") + nb = max(1, m // L) + out = np.empty(n) + for i in range(n): + ii = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)]) + jj = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)]) + out[i] = np.corrcoef(x[ii], y[jj])[0, 1] + return float((out < stat).mean()) + + +def sezione2(curve: pd.DataFrame) -> dict: + hr("SEZIONE 2 — Q1: la pendenza PRECEDE i ritorni o li SEGUE?") + res = {} + for a in ASSETS: + sl = slope_hourly(curve, a) + px = px_hourly(a) + lp = np.log(px) + j = pd.DataFrame({"slope": sl}).join(px.rename("px"), how="inner").dropna() + idx = j.index + r1 = lp.diff() + sub(f"{a} — n ore = {len(j)} ({idx.min()} -> {idx.max()})") + print(f" pendenza iv_{BACK}-iv_{FRONT}: media {j.slope.mean():+.2f} pt-vol, " + f"sd {j.slope.std():.2f}, backwardation nel {100*(j.slope<0).mean():.1f}% delle ore") + + print("\n (a) CROSS-CORRELAZIONE fra la VARIAZIONE oraria di pendenza e il ritorno orario.") + print(" lag<0 = ritorno PRIMA della variazione (il prezzo guida) | lag>0 = DOPO (la") + print(" pendenza guiderebbe). La barra t va da t a t+1h e lo snapshot cade dentro:") + print(" il lag -1 e' quindi in gran parte SOVRAPPOSTO, non un vero anticipo.") + ds = j.slope.diff() + row = [] + for k in (-3, -2, -1, 0, 1, 2, 3, 6, 12, 24): + row.append((k, float(ds.corr(r1.shift(-k).reindex(idx))))) + print(" " + " ".join(f"{k:+d}h:{v:+.3f}" for k, v in row)) + best = max(row, key=lambda t: abs(t[1])) + print(f" picco |corr| al lag {best[0]:+d}h ({best[1]:+.3f})") + + print("\n (b) LIVELLO della pendenza vs ritorni STRETTAMENTE FUTURI (ingresso alla") + print(" chiusura della barra t) e vs ritorni PASSATI.") + for h in (1, 3, 6, 24, 72, 168): + fwd = (lp.shift(-h) - lp).reindex(idx) + pst = (lp - lp.shift(h)).reindex(idx) + m = j.slope.notna() & fwd.notna() & pst.notna() + print(f" h={h:4d}h corr(pendenza, FUTURO) {j.slope[m].corr(fwd[m]):+.3f} " + f"corr(pendenza, PASSATO) {j.slope[m].corr(pst[m]):+.3f}") + TRIALS.append((f"Q1 {a}: 6 orizzonti x (futuro,passato) x (livello,variazione)", 24)) + + print("\n (c) IL CONFOUND DI QUESTO FILONE — la pendenza e' informazione NUOVA o RV riciclata?") + rvp = (r1.rolling(24).std() * np.sqrt(24 * 365) * 100).reindex(idx) + rvf = (r1.shift(-24).rolling(24).std().shift(-23) * np.sqrt(24 * 365) * 100).reindex(idx) + ivf = curve[curve.asset == a].set_index("hr")[f"iv_{FRONT}"].reindex(idx) + c_pp = float(j.slope.corr(rvp)) + c_pf = float(j.slope.corr(rvf)) + print(f" corr(pendenza, RV 24h PASSATA) {c_pp:+.3f} <-- quanto e' termometro") + print(f" corr(pendenza, RV 24h FUTURA) {c_pf:+.3f} <-- quanto e' previsione") + print(f" corr(iv_{FRONT}, RV 24h passata) {float(ivf.corr(rvp)):+.3f} " + f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}") + res[(a, "conf_past")], res[(a, "conf_fwd")] = c_pp, c_pf + + print("\n (d) La correlazione col futuro SOPRAVVIVE se si controlla per il PASSATO?") + print(" (regressione del ritorno futuro su pendenza + ritorno passato + RV passata;") + print(" se il coefficiente della pendenza evapora, il segnale era 'e' appena sceso')") + for h in (24, 72, 120): + fwd = (lp.shift(-h) - lp).reindex(idx) + pst = (lp - lp.shift(h)).reindex(idx) + m = j.slope.notna() & fwd.notna() & pst.notna() & rvp.notna() + X = np.column_stack([np.ones(m.sum()), j.slope[m], pst[m], rvp[m]]) + y = fwd[m].to_numpy() + b, *_ = np.linalg.lstsq(X, y, rcond=None) + # errori standard Newey-West (lag = h, finestre sovrapposte) + e = y - X @ b + XtXi = np.linalg.pinv(X.T @ X) + S = (X * e[:, None]).T @ (X * e[:, None]) + for L in range(1, h + 1): + w = 1.0 - L / (h + 1.0) + G = (X[L:] * e[L:, None]).T @ (X[:-L] * e[:-L, None]) + S += w * (G + G.T) + se = np.sqrt(np.diag(XtXi @ S @ XtXi)) + simple = float(j.slope[m].corr(fwd[m])) + print(f" h={h:4d}h corr semplice {simple:+.3f} | beta_pendenza={b[1]:+.5f} " + f"t(NW)={b[1]/se[1]:+.2f} | beta_ret_passato t={b[2]/se[2]:+.2f}") + TRIALS.append((f"Q1 {a}: 3 regressioni controllate", 3)) + + print("\n (e) Test giornaliero con null a BLOCCHI (la finestra e' di 74 giorni: la") + print(" correlazione va confrontata con la sua dispersione, non con zero).") + sld = j.slope.resample("1D").last().dropna() + pxd = px.resample("1D").last() + lpd = np.log(pxd) + for h in (1, 3, 5): + fwd = (lpd.shift(-h) - lpd).reindex(sld.index) + m = sld.notna() & fwd.notna() + x, y = sld[m].to_numpy(), fwd[m].to_numpy() + c = float(np.corrcoef(x, y)[0, 1]) + p = _block_pctl(x, y, c, L=max(3 * h, 7)) + print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}") + TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri", 3)) + return res + + +# =========================================================================================== +# SEZIONE 3 — Q2: il carry di vol e' incassabile? +# =========================================================================================== +def build_atm_cost(chain: pd.DataFrame) -> pd.DataFrame: + """Costo REALE di attraversare lo spread su un'opzione ATM, in PUNTI DI VOL. + + (ask-bid) e' quotato nel sottostante -> x spot = USD; / vega = punti di vol, che e' l'unita' + in cui si misura il roll-down. Cosi' costo e carry sono confrontabili senza modello. + """ + f = CACHE / "atm_cost.parquet" + if f.exists(): + return pd.read_parquet(f) + q = chain[chain.quoted & (chain.dte > DTE_MIN) & (chain.option_type == "C") & (chain.vega > 0)].copy() + q["ad"] = (q["delta"] - 0.5).abs() + atm = q.sort_values("ad").groupby(["asset", "hr", "exp"], as_index=False).first() + atm = atm[atm.ad < 0.08] + for a in ASSETS: + p = px_hourly(a) + m = atm.asset == a + atm.loc[m, "spot"] = p.reindex(atm.loc[m, "hr"]).ffill().to_numpy() + atm = atm.dropna(subset=["spot"]) + atm["spr_usd"] = (atm.ask - atm.bid) * atm.spot + atm["spr_vol"] = atm.spr_usd / atm.vega + atm["prem_usd"] = (atm.bid + atm.ask) / 2.0 * atm.spot + CACHE.mkdir(parents=True, exist_ok=True) + atm.to_parquet(f) + return atm + + +def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> None: + hr("SEZIONE 3 — Q2: il CARRY di vol (roll-down) e' incassabile al bid-ask reale?") + print(" Definizione senza modello: un'opzione a tenore T, dopo dt, sta a T-dt. Se la curva non") + print(" si muove la sua IV diventa iv(T-dt). Chi e' CORTO guadagna iv(T)-iv(T-dt) punti di vol.") + print(" Il costo di entrare e uscire e' lo spread bid-ask, misurabile negli STESSI punti di vol.") + + sub("(a) quanto vale il roll-down, e viene DAVVERO incassato?") + print(" Confronto appaiato per SCADENZA e per ISTANTE D'INGRESSO (mai allineato sull'uscita):") + print(" roll IMPLICITO = iv_curva(T-dt) - iv(T) al tempo t ; roll REALIZZATO = iv(T-dt) a t+dt - iv(T) a t") + roll_day = {} + for H in (24, 168): + for a in ASSETS: + r = atm_exp[atm_exp.asset == a] + by_hr = {h: g.sort_values("dte") for h, g in r.groupby("hr")} + rows = [] + for _, g in r.set_index(["exp", "hr"]).sort_index().groupby(level=0): + g = g.droplevel(0).sort_index() + fut = g["iv"].reindex(g.index + pd.Timedelta(hours=H)) + for t, iv0, d0, iv1 in zip(g.index, g["iv"].to_numpy(), + g["dte"].to_numpy(), fut.to_numpy()): + T2 = d0 - H / 24.0 + gg = by_hr.get(t) + if not np.isfinite(iv1) or T2 <= DTE_MIN or gg is None or len(gg) < 2: + continue + td, ivd = gg["dte"].to_numpy(), gg["iv"].to_numpy() + if td.min() > T2 or td.max() < T2: + continue + w = (ivd / 100.0) ** 2 * td + ivT2 = np.sqrt(np.interp(T2, td, w) / T2) * 100.0 + rows.append((d0, ivT2 - iv0, iv1 - iv0)) + d = pd.DataFrame(rows, columns=["dte", "impl", "real"]) + d = d[(d.dte >= 4) & (d.dte <= 45)] + if len(d) < 50: + print(f" {a} dt={H}h: campione insufficiente ({len(d)})") + continue + b = np.polyfit(d.impl, d.real, 1)[0] + diff = d.real - d.impl + nblk = max(1, len(d) // max(1, H)) # osservazioni ~indipendenti + t = diff.mean() / diff.std() * np.sqrt(nblk) if diff.std() > 0 else 0.0 + print(f" {a} dt={H:3d}h n={len(d):5d} roll implicito medio {d.impl.mean():+.4f} pt-vol " + f"realizzato {d.real.mean():+.4f} beta(real~impl)={b:+.2f} " + f"corr={d.impl.corr(d.real):+.3f} (real-impl) t={t:+.2f}") + if H == 24: + roll_day[a] = -float(d.impl.mean()) # guadagno giornaliero di CHI E' CORTO + print("\n Lettura: se il roll-down fosse incassato, beta(real~impl) sarebbe ~+1. E' NEGATIVO") + print(" (~-1 a una settimana): sul campione la FORMA della curva torna indietro piu' di") + print(" quanto la curva rotoli. Il segno del bias da errore-di-misura sarebbe POSITIVO") + print(" (iv(T) compare in entrambi con segno opposto), quindi non spiega questo.") + TRIALS.append(("Q2: 2 orizzonti x 2 asset (roll implicito vs realizzato)", 4)) + + sub("(b) il costo reale: bid-ask ATM in PUNTI DI VOL, per tenore") + cost = build_atm_cost(chain) + cost["bucket"] = pd.cut(cost.dte, [DTE_MIN, 3, 10, 20, 45, 95], + labels=["1-3g", "3-10g", "10-20g", "20-45g", "45-95g"]) + tab = cost.groupby(["asset", "bucket"], observed=True).agg( + n=("spr_vol", "size"), spread_ptvol=("spr_vol", "median"), + spread_pct_premio=("spr_usd", lambda s: np.nan), + premio_usd=("prem_usd", "median"), vega_usd_per_ptvol=("vega", "median")) + tab["spread_pct_premio"] = (cost.assign(x=cost.spr_usd / cost.prem_usd * 100) + .groupby(["asset", "bucket"], observed=True)["x"].median()) + print(tab.round(3).to_string().replace("\n", "\n ")) + + sub("(c) l'aritmetica che chiude la domanda") + med = cost.groupby(["asset", "bucket"], observed=True)["spr_vol"].median() + for a in ASSETS: + rd = roll_day.get(a, np.nan) + sp = float(med.loc[(a, "10-20g")]) + print(f" {a}: roll-down incassabile da CORTO = {rd:+.4f} pt-vol/giorno | " + f"un round-trip ATM 10-20g costa {sp:.3f} pt-vol") + print(f" => servono {sp/rd:.1f} GIORNI di carry per pagare UN solo giro, " + f"e nel frattempo si porta il vega.") + TRIALS.append(("Q2: 5 secchi di tenore x 2 asset (costo)", 10)) + + sub("(d) e a $635 la domanda non e' nemmeno questa: il LOTTO MINIMO") + for a in ASSETS: + c = cost[(cost.asset == a) & (cost.bucket == "10-20g")] + if c.empty: + continue + prem = float(c.prem_usd.median()) * MIN_LOT[a] + vega = float(c.vega.median()) * MIN_LOT[a] + spot = float(c.spot.median()) + fee = min(OPT_FEE_UNDERLYING * spot, OPT_FEE_CAP_PREMIUM * float(c.prem_usd.median())) * MIN_LOT[a] + rd = roll_day.get(a, np.nan) + carry_g = rd * vega + friz = float(c.spr_vol.median()) * vega + 4 * fee # 1 spread pieno + 4 gambe di fee + print(f" {a}: lotto minimo {MIN_LOT[a]} -> premio ATM 10-20g = ${prem:,.2f} " + f"({prem/CAPITALE:.0%} del conto), vega ${vega:.2f}/pt-vol, fee ${fee:.2f}/gamba") + print(f" carry lordo ${carry_g:+.3f}/giorno per struttura; attrito per giro " + f"${friz:.2f} => {friz/carry_g:.0f} giorni per andare in pari") + print(f"\n Un calendar spread e' DUE gambe: a $635 una sola struttura ETH impegna il") + print(f" ~15-30% del conto e rende centesimi al giorno. BTC e' fuori dal lotto minimo.") + print(f" Il bersaglio dichiarato e' 50 EUR/giorno: qui si parla di due ordini di grandezza sotto.") + TRIALS.append(("Q2: 2 asset x eseguibilita' al lotto minimo", 2)) + + +# =========================================================================================== +# SEZIONE 4 — Q3: gate di rischio sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT. +# =========================================================================================== +def tp01_positions() -> dict: + from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio + tp = TrendPortfolio(**CANONICAL) + out = {} + for a in ASSETS: + df = A.get(a, "1d") + out[a] = pd.Series(tp.target_series(df), + index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))) + return out + + +def _anchored(sl_h: pd.Series, px_h: pd.Series, off: int): + """Serie giornaliere (pendenza, ritorno) ancorate all'ora `off`.""" + m = sl_h.index.hour == off + s = sl_h[m] + p = px_h.reindex(s.index).ffill() + r = p.pct_change() + return s, r + + +def sezione4(curve: pd.DataFrame) -> None: + hr("SEZIONE 4 — Q3: la pendenza come GATE DI RISCHIO sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT") + print(" Regola del progetto: 4 gate di de-risk su 4 (DVOL-spike, macro, funding, breadth) sono") + print(" risultati RIDONDANTI col trend. Percio' la ridondanza si misura PRIMA di rivendicare") + print(" qualunque uplift: quanti dei giorni che il gate spegne sono giorni in cui TP01 e' gia' flat?") + pos = tp01_positions() + + sub("(a) il fatto che decide da solo la sezione") + W = pd.concat(pos, axis=1).loc[TS_START:] + flat = (W.abs() < 0.05).mean() + print(f" Nella finestra {TS_START.date()} -> oggi ({len(W)} giorni), TP01 e' FLAT nel " + f"{flat['BTC']:.1%} (BTC) / {flat['ETH']:.1%} (ETH) dei giorni.") + print(f" Esposizione media: BTC {W['BTC'].mean():.3f}, ETH {W['ETH'].mean():.3f} " + f"(target TSMOM risk-off, coerente col libro live dichiarato flat).") + print(" ⚠️ Un gate di de-risk su un libro gia' flat il 93% del tempo NON E' MISURABILE su") + print(" questa finestra: non c'e' rischio da togliere. Questo non e' un difetto del") + print(" gate, e' un difetto del CAMPIONE — e va detto prima di qualunque numero.") + + sub("(b) ridondanza, per quel che il campione consente") + for a in ASSETS: + sl = slope_hourly(curve, a).resample("1D").last().dropna() + j = pd.concat({"slope": sl, "tp": pos[a]}, axis=1, join="inner").dropna() + for qq in (0.10, 0.25, 0.40): + th = j.slope.quantile(qq) + off = j.slope < th + already = float((j.tp[off].abs() < 0.05).mean()) if off.any() else np.nan + print(f" {a} gate OFF sotto p{int(qq*100)} ({th:+.2f} pt-vol): spegne il " + f"{off.mean():.1%} dei giorni; di questi TP01 era GIA' flat nel {already:.1%} " + f"| corr(pendenza, esposizione TP01) = {j.slope.corr(j.tp):+.3f}") + TRIALS.append((f"Q3 {a}: 3 soglie di gate", 3)) + print("\n Il segno di corr(pendenza, esposizione) e' NEGATIVO: la backwardation arriva") + print(" quando TP01 e' gia' uscito. E' la firma esatta dei 4 precedenti.") + + sub("(c) uplift sulla finestra, su TUTTE le 24 ancore (mai su una sola)") + print(" Candidato DIREZIONALE (non gate): contango -> long, backwardation -> flat; vol-target") + print(" 14g, cap 2x, fee 5 bps/lato. Soglia a ZERO = a priori, nessun parametro tarato.") + for a in ASSETS: + sl_h, px_h = slope_hourly(curve, a), px_hourly(a) + + def cand(off, s=sl_h, p=px_h): + s_d, r_d = _anchored(s, p, off) + d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna() + if len(d) < 30: + return pd.Series(dtype=float) + vol = d["r"].rolling(14).std() * np.sqrt(365) + dirn = (d["slope"] > 0).astype(float) + tgt = np.clip(dirn * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0) + hold = tgt.shift(1).fillna(0.0) + return (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna() + + def base(off, p=px_h, s=sl_h): + s_d, r_d = _anchored(s, p, off) + d = pd.DataFrame({"r": r_d}).dropna() + return d["r"] + + band = A.anchor_luck_band(cand, list(range(24)), canonical=0) + print(f" {a}: Sharpe candidato — canonico(00:00) {band['canonical']:+.2f} " + f"(pctl {band['canonical_pctl']:.2f}) | MEDIANA ONESTA {band['median']:+.2f} | " + f"banda [{band['lo']:+.2f}, {band['hi']:+.2f}] | positivo in {band['frac_positive']:.0%} " + f"delle 24 ancore | gate_pass={band['gate_pass']}") + bh = A.anchor_luck_band(base, list(range(24)), canonical=0) + print(f" buy&hold sulla stessa finestra/ancore: mediana {bh['median']:+.2f} " + f"[{bh['lo']:+.2f}, {bh['hi']:+.2f}] <-- il vero termine di paragone in un toro") + dlt = A.anchor_luck_delta(cand, base, list(range(24))) + print(f" mediana delle DIFFERENZE APPAIATE candidato-B&H: {dlt['median_paired']:+.2f} " + f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)") + TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48)) + + sub("(d) null del DE-LEVERING — obbligatorio su ogni claim di minor rischio") + for a in ASSETS: + sl_h, px_h = slope_hourly(curve, a), px_hourly(a) + s_d, r_d = _anchored(sl_h, px_h, 0) + d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna() + vol = d["r"].rolling(14).std() * np.sqrt(365) + tgt = np.clip((d["slope"] > 0).astype(float) * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0) + hold = tgt.shift(1).fillna(0.0) + net = (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna() + dd_c = A._dd_ret(net) + best = None + for k in np.arange(0.05, 1.01, 0.05): + b = k * d["r"].reindex(net.index) + if A._dd_ret(b) <= dd_c: + best = (k, A._sh(b), A._dd_ret(b)) + break + print(f" {a}: candidato Sharpe {A._sh(net):+.2f} maxDD {dd_c:.2%} | " + + (f"buy&hold de-leverato k={best[0]:.2f} -> stesso DD ({best[2]:.2%}) con Sharpe " + f"{best[1]:+.2f} => {'IL CANDIDATO NON SOPRAVVIVE' if best[1] >= A._sh(net) else 'candidato sopra il null'}" + if best else "nessun k<=1 raggiunge quel DD")) + TRIALS.append((f"Q3 {a}: 20 valori di k nel null de-levering", 20)) + + +# =========================================================================================== +# SEZIONE 5 — IL TEST LUNGO. La pendenza ha un PROXY con 5 anni di storia? +# =========================================================================================== +def _proxy_slope(df: pd.DataFrame, asset: str, rvwin: int = 7) -> np.ndarray: + """Proxy A PRIORI della pendenza su storia lunga: DVOL(30g) - RV(rvwin), tutto causale. + + Motivazione, non scelta a posteriori: iv_30 ~ DVOL (corr 0.99, SEZIONE 1) e iv_7 e' in larga + parte una funzione della RV recente (SEZIONE 2c). Il proxy e' quindi la pendenza con la gamba + corta sostituita dalla sua determinante. NON e' la stessa variabile: l'attenuazione e' + misurata sotto e va portata dietro a ogni conclusione. + """ + c = df["close"].to_numpy(float) + r = A.simple_returns(c) + bpd = A.bars_per_day(df) + rv = A.realized_vol(r, max(2, rvwin * bpd), bpd * 365.25) * 100.0 + return A.dvol(df, asset) - rv + + +def make_proxy_dir(long_only: bool = True, thr: float = 0.0): + def fn(df, asset): + p = _proxy_slope(df, asset) + d = np.where(np.isfinite(p), np.where(p > thr, 1.0, 0.0 if long_only else -1.0), 0.0) + return A.vol_target(d, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) + return fn + + +def sezione5(curve: pd.DataFrame) -> None: + hr("SEZIONE 5 — IL TEST LUNGO: la pendenza ha un proxy con 5 anni di storia?") + print(" Con 74 giorni nessun gate statistico ha potenza. L'unica via per DIRE qualcosa e' un") + print(" proxy a priori su storia lunga — e dichiararne l'attenuazione.") + + sub("(a) quanto e' buono il proxy (misurato sulla finestra vera, non assunto)") + for a in ASSETS: + sl = slope_hourly(curve, a).resample("1D").last().dropna() + df = A.get(a, "1d") + p = pd.Series(_proxy_slope(df, a), + index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))).dropna() + j = pd.concat({"vero": sl, "proxy": p}, axis=1, join="inner").dropna() + c = float(j.vero.corr(j.proxy)) + print(f" {a}: corr(pendenza VERA, DVOL-RV7) = {c:+.3f} su n={len(j)} giorni " + f"-> attenuazione: il proxy spiega il {c**2:.0%} della varianza della pendenza") + print(" ⚠️ Un proxy a corr ~0.5-0.7 e' una lente SFOCATA: un risultato NEGATIVO sul proxy") + print(" e' evidenza piu' debole di un negativo sulla variabile vera. Va detto, non nascosto.") + + sub("(b) il candidato direzionale sul proxy, 5 anni, coi gate veri") + fn = make_proxy_dir(long_only=True) + cau = A.causality_ok(fn, tf="1d") + print(f" causality_ok: {cau}") + cd = A.candidate_daily(fn, tf="1d") + era = cd[cd.index >= pd.Timestamp("2021-10-01", tz="UTC")] + print(f" era DVOL (2021-10 -> oggi, {len(era)} giorni): Sharpe {A._sh(era):+.3f} " + f"maxDD {A._dd_ret(era):.2%}") + rep = A.marginal_vs_tp01(cd) + print(f" marginal_vs_tp01 -> {rep.get('marginal_verdict')} | corr a TP01 " + f"{rep.get('corr_full')} | uplift w25 full {rep['blends']['w25']['uplift_full']:+.3f} " + f"hold {rep['blends']['w25']['uplift_hold']}") + for k in ("is_hedge", "has_insample_edge", "robust_oos", "beats_noise_null", "earns_slot"): + if k in rep: + print(f" {k:20s} = {rep[k]}") + imp = A.implausible_sharpe(era) + print(f" implausible_sharpe: flagged={imp.get('flagged')} ({imp.get('reasons')})") + TRIALS.append(("Sez.5: 2 varianti proxy (long-only, L/S) x 1 soglia", 2)) + + sub("(c) il precedente che questo replica") + print(" `dvol_directional.py` (2026-06-29) testo' VRP-Z = z-score causale di (IV-RV) come") + print(" segnale DIREZIONALE su 5 anni, con la griglia completa: verdetto **HEDGE,") + print(" earns_slot=False** — paga solo quando TP01 e' debole, quindi non e' alpha.") + print(" La pendenza della term structure e', su questa lente, la stessa variabile con la") + print(" gamba realizzata sostituita da una implicita. Il filone non e' nuovo: e' quello.") + + +# =========================================================================================== +# SEZIONE 6 — CONTI, GATE, VERDETTO +# =========================================================================================== +def sezione6() -> None: + hr("SEZIONE 6 — GRIGLIA DICHIARATA E DEFLATED SHARPE") + tot = sum(n for _, n in TRIALS) + for name, n in TRIALS: + print(f" {n:5d} {name}") + print(f" {'-'*70}\n {tot:5d} TOTALE (contato al RIALZO: ogni variante valutata, anche scartata)") + print("\n Non e' stata ridotta nessuna griglia per il budget: il vincolo di questo filone") + print(" non e' il tempo di calcolo, e' che il campione dura 74 giorni.") + print("\n deflated_sharpe: NON GIRATO su un candidato di questo studio, e il motivo e' una") + print(" scelta, non una dimenticanza: il DSR e' una correzione per multiple-testing su UNA") + print(" serie di ritorni; qui il candidato migliore ha 74 osservazioni e la sua incertezza") + print(" di CAMPIONE (SE(Sharpe) ~ 2.2) domina di un ordine di grandezza quella da selezione.") + print(f" Deflazionare {tot} trial su 74 giorni darebbe un numero preciso e privo di senso.") + print(" Sul proxy a 5 anni il DSR e' invece pertinente ed e' riportato dal gate del 29/06") + print(" su cui questo filone ricade (VRP-Z: earns_slot=False).") + + +def main() -> None: + hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH") + print(__doc__.split("METODO.")[0].strip()[:0] or "", end="") + chain = load_quoted_chain() + sezione0(chain) + atm_exp = build_atm_by_expiry(chain) + curve = build_curve(atm_exp) + sezione1(curve) + sezione2(curve) + sezione3(chain, atm_exp, curve) + del chain + sezione4(curve) + sezione5(curve) + sezione6() + hr("FINE") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_vol_size.py b/scripts/research/r0822_vol_size.py new file mode 100644 index 0000000..dc9850a --- /dev/null +++ b/scripts/research/r0822_vol_size.py @@ -0,0 +1,671 @@ +#!/usr/bin/env python +"""r0822_vol_size.py — CONTROLLO DEL RISCHIO SU SKH01 e VOL-TARGET A LIVELLO DI LIBRO. + +DOMANDA (ondata 2026-08-22, filone VOL-SIZE). Il libro live Deribit e' TP01 75% + SKH01 25%, +nettati su un solo strumento. TP01 e' vol-targeted al 20% sulla vol REALIZZATA dell'asset; +**SKH01 non ha alcun controllo del rischio** — `backtest_signals(..., leverage=1.0, +position_size=1.0)`, verificato il 26/07: il suo 20,7% di vol realizzata e' un PRODOTTO della +strategia (uscite % asimmetriche + poco tempo a mercato), non un parametro. E' l'unica delle +cinque gambe senza dimensionamento, la piu' fee-sensibile (~4x TP01) e la piu' fragile +all'ancora (LOO 26/07). + + Q1 dare a SKH01 un dimensionamento del rischio (inverse-vol per-trade / rischio costante per + trade / vol-target sulla sua vol di strategia) migliora il LIBRO, o e' de-levering? + Q2 il vol-target va applicato al LIBRO gia' nettato invece che al singolo sleeve? Oggi ogni + sleeve si dimensiona da solo e poi si nettano: la versione book-level non e' mai stata + misurata. + +COSA NON SI RIFA' (gia' misurato): TP01 x DVOL (26/06, de-levering); il peso 75/25 (confermato +3 volte). I pesi NOMINALI non si toccano; una modulazione di size e' pero' un cambio di pesi nel +tempo, quindi `weights_tilt_null` si riporta lo stesso (§7). + +IPOTESI A PRIORI, REGISTRATA PRIMA DI MISURARE: **de-levering su quasi tutto**. Ragione +dichiarata prima: lo Sharpe e' INVARIANTE a una costante moltiplicativa, quindi una variante che +si limiti ad abbassare la leva media non puo' cambiare lo Sharpe e puo' solo abbassare il DD — +la firma esatta del null del de-levering (5 occorrenze nel progetto). Per VINCERE una variante +deve cambiare la FORMA (quale trade pesa quanto), e la forma si vede solo sullo Sharpe. +Sotto-ipotesi: l'inverse-vol per-trade e' l'unica con una ragione strutturale (lo stop di SKH01 +e' una PERCENTUALE FISSA 4%/2%, quindi il rischio in unita' di sigma varia col regime); il +vol-target sulla serie giornaliera dello sleeve e' quello che ha piu' probabilita' di rompersi +(SKH01 e' ~88% di zeri: la vol trailing di quella serie misura l'ATTIVITA', non il rischio). + +⚠️ DIFETTO TROVATO NELLA MIA PRIMA STESURA, E MISURATO INVECE CHE CANCELLATO (§4). Il modo +ovvio di scrivere un vol-target su uno sleeve — moltiplicare la sua serie GIORNALIERA per +L_t = tv/rv_{t-1} — e' **non causale su SKH01**, perche' la sua equity e' a GRADINO: tutto il +P&L di un trade e' contabilizzato il giorno di CHIUSURA. Scalare quel giorno per L_t significa +aver tenuto size L_t dall'INGRESSO, che e' fino a ~4 giorni prima (24 barre da 230m). L_t usa +solo dati <= t-1 e quindi passa qualunque controllo di causalita' scritto sulla serie +giornaliera: il look-ahead sta nella CONTABILITA', non nella formula. La versione causale fissa +la size all'INGRESSO. Entrambe sono misurate qui, e la differenza e' un risultato. + +LENTE E ANCORA. Path CANONICO (fill al livello, entry a chiusura di bin): e' la lente di tutti i +numeri pubblicati di SKH01 ed e' identica nelle due braccia di ogni confronto — il Delta e' +pulito anche se il livello assoluto e' pessimistico verso il path live (26/07: il live e' +migliore su ingressi E uscite). Banda d'ancora = **23 offset**, la griglia a priori dell'audit +02/07 (ogni 30m su [0,690)); statistica = **mediana delle DIFFERENZE APPAIATE** +(`A.anchor_luck_delta`), mai differenza delle mediane. L'ancora di TP01 e' tenuta canonica in +entrambe le braccia — §6 verifica che il segno non dipenda da lei. + +PRESENTAZIONE. Lo Sharpe e' invariante alla scala, maxDD e CAGR no: ogni variante e' riportata +anche a **ISO-VOL** (costante che pareggia la deviazione standard piena a quella del baseline). +E' una scelta di presentazione, post-hoc e dichiarata; non cambia lo Sharpe, che e' cio' che +decide. Il null del de-levering (`k_iso_dd`, importato da r0822_sol_leg, non riscritto) e' +calcolato per ogni variante che riduce il DD. + +USO: nice -n 19 timeout 900 uv run python scripts/research/r0822_vol_size.py + [--every K] 1 offset ogni K dei 23 (K=1 = tutti; pilota: --every 8) +""" +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import altlib as A # noqa: E402 +import r0702_anchor_skh01 as R # noqa: E402 (run_asset/resample_off riusati) +import r0702_tp01_offset as TO # noqa: E402 (TP01 per ancora + target 1h) +from r0822_sol_leg import k_iso_dd # noqa: E402 (null de-levering, non riscritto) +from src.portfolio.portfolio import ( # noqa: E402 + HOLDOUT, combine_outer, weights_tilt_null, +) + +ASSETS = ("BTC", "ETH") +OFFSETS_FULL = tuple(range(0, 690, 30)) # 23 a priori: griglia dell'audit 02/07 +LTF_MIN = 230 +BARS_YR = 365.25 * 24 * 60 / LTF_MIN +DPY = 365.25 +FEE_RT = 0.001 +W_TP, W_SKH = 0.75, 0.25 # libro live Deribit +CAPITAL = 635.0 # conto reale dichiarato nel brief +MIN_ORDER = 5.0 +CAP_ASSET = 0.5 # cap $318/asset su equity $635 +SIG_WARM = 2000 # barre 230m (~1 anno) prima di dimensionare +VT_WARM = 250 # giorni prima di dimensionare su vol di strategia + + +# --------------------------------------------------------------------------- metriche +def sh(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0 + + +def maxdd(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + if len(r) < 2: + return 0.0 + eq = np.cumprod(1.0 + r) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) + + +def cagr(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + if len(r) < 2: + return 0.0 + eq = float(np.prod(1.0 + r)) + yrs = len(r) / DPY + return float(eq ** (1 / yrs) - 1) if eq > 0 and yrs > 0 else -1.0 + + +def vol(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + return float(r.std() * np.sqrt(DPY)) if len(r) > 2 else 0.0 + + +def hold(s: pd.Series) -> pd.Series: + return s[s.index >= HOLDOUT] + + +def ins(s: pd.Series) -> pd.Series: + return s[s.index < HOLDOUT] + + +# --------------------------------------------------------------------------- estrazione trade +def extract(asset: str, off: int) -> dict: + """Trade + contesto per (asset, offset). Replica il loop di `backtest_signals` con + leverage=1: stessa detection, stesso non-overlap, stessi fill al livello. Cio' che + aggiunge e' la SIZE per-trade, che l'harness non espone (ha un solo `position_size` + scalare). La §0 verifica bit-exact che con size=1 si ritrovi la serie ufficiale.""" + _, _, ltf, ent = R.run_asset(asset, off) + c = ltf["close"].values.astype(float) + h = ltf["high"].values.astype(float) + lo = ltf["low"].values.astype(float) + n = len(c) + + i_ent, i_ex, dirs, nets, stops = [], [], [], [], [] + busy = -1 + for i in range(n): + e = ent[i] if i < len(ent) else None + if e is None or e.get("dir", 0) == 0 or i <= busy: + continue + d = int(e["dir"]) + entry = c[i] + tp, sl = e.get("tp"), e.get("sl") + mb = int(e.get("max_bars") or 24) + ex_i = min(i + mb, n - 1) + ex_p = c[ex_i] + for j in range(i + 1, min(i + mb + 1, n)): + hit_sl = sl is not None and ((d == 1 and lo[j] <= sl) or (d == -1 and h[j] >= sl)) + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and lo[j] <= tp)) + if hit_sl: + ex_p, ex_i = sl, j + break + if hit_tp: + ex_p, ex_i = tp, j + break + ex_p, ex_i = c[j], j + i_ent.append(i); i_ex.append(ex_i); dirs.append(d) + nets.append((ex_p - entry) / entry * d - FEE_RT) + stops.append(abs(float(sl) - entry) / entry) # DERIVATO dal segnale, non ridichiarato + busy = ex_i + + idxE = np.asarray(i_ent, int) + lr = A.log_returns(c) + sig, sigref = {}, {} + for w in (30, 90): + sv = A.realized_vol(lr, w, BARS_YR) # finestra che termina in i: causale + sig[w] = sv[idxE] if len(idxE) else np.array([]) + sigref[w] = _expanding_median_at(sv, idxE) + idx = pd.DatetimeIndex(pd.to_datetime(ltf["timestamp"].values, unit="ms", utc=True)) + out = dict( + n=n, idx=idx, i_ent=idxE, i_ex=np.asarray(i_ex, int), dir=np.asarray(dirs, int), + net=np.asarray(nets, float), stop=np.asarray(stops, float), sig=sig, sigref=sigref, + ts_close=ltf["timestamp"].values.astype(np.int64) + LTF_MIN * 60_000, + day_ent=idx[idxE].floor("D") if len(idxE) else pd.DatetimeIndex([], tz="UTC"), + ) + R._CACHE.clear() # ent = 18k dict per chiave: non accumulare + return out + + +def _expanding_median_at(v: np.ndarray, idxE: np.ndarray) -> np.ndarray: + """Mediana ESPANDENTE di v fino a i incluso, valutata solo agli indici d'ingresso. + Causale per costruzione. NaN prima del warm-up.""" + out = np.full(len(idxE), np.nan) + for k, i in enumerate(idxE): + if i < SIG_WARM: + continue + x = v[: i + 1] + x = x[np.isfinite(x)] + if len(x) >= SIG_WARM // 2: + out[k] = float(np.median(x)) + return out + + +def equity_daily(ex: dict, sizes: np.ndarray) -> pd.Series: + """Serie giornaliera dei rendimenti dalla tabella trade, con size per-trade. Convenzione + IDENTICA a `backtest_signals` + `_skyhook_returns`: equity a gradino a fine trade, + resample 1D last, ffill, pct_change.""" + n = ex["n"] + eq = np.full(n, 1000.0, float) + cap = 1000.0 + for k in range(len(ex["i_ent"])): + cap += cap * float(sizes[k]) * float(ex["net"][k]) + cap = max(cap, 1.0) + eq[ex["i_ent"][k]: ex["i_ex"][k] + 1] = cap + for k in range(1, n): # stessi due riempimenti dell'harness + if eq[k] == 1000.0 and eq[k - 1] != 1000.0: + eq[k] = eq[k - 1] + last = 1000.0 + for k in range(n): + if eq[k] != last and eq[k] != 1000.0: + last = eq[k] + else: + eq[k] = last + return pd.Series(eq, index=ex["idx"]).resample("1D").last().ffill().pct_change().dropna() + + +def leg_daily(exs: dict, sizes: dict) -> pd.Series: + """Sleeve SKH01 50/50 BTC+ETH (convenzione di `_skyhook_returns`: inner join).""" + ser = {a: equity_daily(exs[a], sizes[a]) for a in ASSETS} + J = pd.concat(ser, axis=1, join="inner").fillna(0.0) + return pd.Series(0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values, index=J.index) + + +# --------------------------------------------------------------------------- famiglie di size +def size_flat(ex: dict) -> np.ndarray: + return np.ones(len(ex["i_ent"])) + + +def size_iv(p: float, w: int, cap: float): + """INVERSE-VOL per-trade sulla vol dell'ASSET: size = clip((sig_rif/sig_ingresso)^p, 1/cap, cap). + sig_rif = mediana ESPANDENTE causale -> size media ~1: la variante e' neutra alla leva, + quindi qualunque effetto sullo Sharpe e' FORMA, non de-levering.""" + def f(ex: dict) -> np.ndarray: + s, r = ex["sig"][w], ex["sigref"][w] + with np.errstate(divide="ignore", invalid="ignore"): + raw = np.where((s > 0) & np.isfinite(r), (r / np.maximum(s, 1e-12)) ** p, 1.0) + return np.clip(np.nan_to_num(raw, nan=1.0, posinf=cap, neginf=1.0), 1.0 / cap, cap) + return f + + +def size_risk(p: float): + """RISCHIO COSTANTE PER TRADE: size ~ 1/distanza-dallo-stop. Lo stop di SKH01 e' una + percentuale FISSA (4% long / 2% short) -> questa famiglia e' di fatto 'gli short pesano + 2x i long'. La costante e' irrilevante (lo Sharpe e' scale-invariante).""" + def f(ex: dict) -> np.ndarray: + st = np.maximum(ex["stop"], 1e-9) + return np.where(ex["stop"] > 0, (0.04 / st) ** p, 1.0) + return f + + +def _rv_daily(s: pd.Series, w: int, active_only: bool) -> pd.Series: + """Vol realizzata trailing di una serie GIORNALIERA, SFASATA di 1 giorno (causale al giorno t). + active_only=True la calcola sulle sole barre ATTIVE e la riporta sulla griglia di calendario: + SKH01 e' ~88% di zeri, e la versione 'tutte le barre' misura l'ATTIVITA', non il rischio.""" + if not active_only: + return (s.rolling(w, min_periods=max(5, w // 3)).std() * np.sqrt(DPY)).shift(1) + act = s[s != 0.0] + rv = act.rolling(w, min_periods=max(5, w // 3)).std() * np.sqrt(DPY) + return rv.reindex(s.index).ffill().shift(1) + + +def leverage_series(base: pd.Series, tv: float | None, w: int, cap: float, + active_only: bool = False) -> pd.Series: + """L_t da una serie giornaliera di riferimento. tv=None -> versione NEUTRA ALLA LEVA + (rif = mediana espandente causale di rv), tv=float -> vol-target assoluto.""" + rv = _rv_daily(base, w, active_only).replace(0.0, np.nan) + if tv is None: + ref = rv.expanding(min_periods=VT_WARM).median() + L = (ref / rv).clip(lower=1.0 / cap, upper=cap) + else: + L = (tv / rv).clip(lower=0.0, upper=cap) + return L.fillna(1.0) + + +def sizes_from_L(ex: dict, L: pd.Series) -> np.ndarray: + """La size di ogni trade = L al giorno d'INGRESSO. E' la versione CAUSALE del vol-target su + uno sleeve a equity a gradino: L al giorno di CHIUSURA non era nota all'ingresso.""" + if not len(ex["i_ent"]): + return np.array([]) + v = L.reindex(ex["day_ent"]).values.astype(float) + return np.nan_to_num(v, nan=1.0) + + +# --------------------------------------------------------------------------- libro +def book(tp: pd.Series, skh: pd.Series) -> pd.Series: + return combine_outer({"TP01": tp, "SKH01": skh}, {"TP01": W_TP, "SKH01": W_SKH}) + + +def iso_vol(s: pd.Series, ref: pd.Series) -> pd.Series: + v = vol(s) + return s * (vol(ref) / v) if v > 0 else s + + +HEAD = (f" {'variante':<28}{'ShFULL':>8}{'ShHOLD':>9}{'ShIS':>8}" + f"{'vol':>9}{'maxDD':>9}{'DDisovol':>10}{'CAGRiso':>9}{'size med':>10}") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--every", type=int, default=1) + args = ap.parse_args() + offs = OFFSETS_FULL[:: max(1, args.every)] + t0 = time.time() + + print("=" * 112) + print(" r0822 VOL-SIZE — controllo del rischio su SKH01 (Q1) e vol-target di LIBRO (Q2)") + print("=" * 112) + print(f" ancore: {len(offs)}/{len(OFFSETS_FULL)} offset a priori {list(offs)}") + print(" lente: path CANONICO. Ancora TP01: canonica in ENTRAMBE le braccia (§6 la varia).") + + print("\n estrazione tabelle trade ...", flush=True) + EX = {o: {a: extract(a, o) for a in ASSETS} for o in offs} + print(f" fatto in {time.time()-t0:.0f}s — trade medi/ancora: " + + ", ".join(f"{a} {int(np.mean([len(EX[o][a]['i_ent']) for o in offs]))}" for a in ASSETS)) + + # ------------------------------------------------------------------ §0 SANITY + print("\n" + "-" * 112) + print(" 0. SANITY — con size=1 la ricomposizione DEVE riprodurre la serie ufficiale bit-exact") + print(" (se non e' 0, ogni numero sotto e' di un'altra strategia)") + print("-" * 112) + worst = 0.0 + for o in offs[: min(3, len(offs))]: + for a in ASSETS: + mine = equity_daily(EX[o][a], size_flat(EX[o][a])) + ref = R.run_asset(a, o)[0] + R._CACHE.clear() + assert len(mine) == len(ref), f"len {len(mine)} vs {len(ref)} ({a},{o})" + d = float(np.max(np.abs(mine.values - ref.values))) + worst = max(worst, d) + print(f" {a} off{o:>3}: max|dif| = {d:.2e} ({len(mine)} giorni)") + assert worst < 1e-15, f"ricomposizione NON bit-exact: {worst:.2e}" + if 0 in EX: + from src.portfolio.sleeves import _skyhook_returns + s0 = leg_daily(EX[0], {a: size_flat(EX[0][a]) for a in ASSETS}) + d0 = float(np.max(np.abs(s0.values - _skyhook_returns().values))) + print(f" sleeve 50/50 off0 vs sleeves._skyhook_returns(): max|dif| = {d0:.2e}") + assert d0 < 1e-15 + + # ------------------------------------------------------------------ §1 CAUSALITA' + print("\n" + "-" * 112) + print(" 1. CAUSALITA' — ogni size ricalcolata TRONCANDO i dati alla barra d'ingresso") + print("-" * 112) + ex = EX[offs[0]]["BTC"] + _, _, ltf, _ = R.run_asset("BTC", offs[0]) + c_full = ltf["close"].values.astype(float) + R._CACHE.clear() + full_sz = size_iv(1.0, 30, 3.0)(ex) + sel = [k for k in range(len(ex["i_ent"])) if ex["i_ent"][k] >= SIG_WARM][:40] + bad = 0 + for k in sel: + i = ex["i_ent"][k] + sv_t = A.realized_vol(A.log_returns(c_full[: i + 1]), 30, BARS_YR) + s_t = sv_t[i] + r_t = float(np.median(sv_t[np.isfinite(sv_t)])) + sz_t = np.clip((r_t / s_t) if s_t > 0 else 1.0, 1 / 3.0, 3.0) + bad += int(abs(sz_t - full_sz[k]) > 1e-9) + print(f" IV: {len(sel)-bad}/{len(sel)} size identiche a dati troncati (divergenze {bad})") + assert bad == 0, "la size IV usa dati futuri" + lag = np.array([(ex["idx"][ex["i_ex"][k]] - ex["idx"][ex["i_ent"][k]]).total_seconds() / 86400 + for k in range(len(ex["i_ent"]))]) + print(f" durata dei trade (giorni): mediana {np.median(lag):.2f}, p90 {np.percentile(lag,90):.2f}, " + f"max {lag.max():.2f} <- e' l'ampiezza del look-ahead della versione NAIVE (§4)") + + # ------------------------------------------------------------------ griglia + SZ: dict = {} + for p in (0.5, 1.0): + for w in (30, 90): + for cp in (2.0, 4.0): + SZ[f"IV p{p} w{w} cap{cp:.0f}"] = size_iv(p, w, cp) + for p in (0.5, 1.0): + SZ[f"RISK p{p}"] = size_risk(p) + VTL = {} # vol-target sulla vol di STRATEGIA, causale + for w in (30, 90): + VTL[f"VTL neutro w{w}"] = dict(tv=None, w=w, cap=3.0, active_only=False) + VTL[f"VTL tv20 w{w}"] = dict(tv=0.20, w=w, cap=3.0, active_only=False) + VTL["VTL neutro w90 attive"] = dict(tv=None, w=90, cap=3.0, active_only=True) + VTL["VTL tv20 w90 attive"] = dict(tv=0.20, w=90, cap=3.0, active_only=True) + BVT = {} # Q2: vol-target del LIBRO + for w in (30, 90, 180): + for cp in (2.0, 3.0): + BVT[f"BOOKVT w{w} cap{cp:.0f}"] = dict(tv=0.10, w=w, cap=cp) + NAIVE = {f"NAIVE-VT w{w}": dict(tv=0.20, w=w, cap=3.0, active_only=False) for w in (30, 90)} + ALL = list(SZ) + list(VTL) + list(BVT) + list(NAIVE) + print(f"\n GRIGLIA DICHIARATA: {len(SZ)} size per-trade + {len(VTL)} vol-target di gamba + " + f"{len(BVT)} vol-target di libro + {len(NAIVE)} controlli non-causali = {len(ALL)} celle " + f"(+ baseline).") + + # ------------------------------------------------------------------ serie + TP = A.tp01_baseline_daily() + SLE = {"BASE": {}} + BK = {"BASE": {}} + MTP = {"BASE": {o: 1.0 for o in offs}} # rapporto di vol della gamba TP01 + SIZEMED = {} + for o in offs: + SLE["BASE"][o] = leg_daily(EX[o], {a: size_flat(EX[o][a]) for a in ASSETS}) + BK["BASE"][o] = book(TP, SLE["BASE"][o]) + for nm, fn in SZ.items(): + SLE[nm] = {o: leg_daily(EX[o], {a: fn(EX[o][a]) for a in ASSETS}) for o in offs} + BK[nm] = {o: book(TP, SLE[nm][o]) for o in offs} + MTP[nm] = {o: 1.0 for o in offs} + SIZEMED[nm] = float(np.median(np.concatenate([fn(EX[offs[0]][a]) for a in ASSETS]))) + for nm, sp in VTL.items(): + SLE[nm], BK[nm], MTP[nm] = {}, {}, {} + for o in offs: + sz = {} + for a in ASSETS: + base_a = equity_daily(EX[o][a], size_flat(EX[o][a])) + sz[a] = sizes_from_L(EX[o][a], leverage_series(base_a, **sp)) + SLE[nm][o] = leg_daily(EX[o], sz) + BK[nm][o] = book(TP, SLE[nm][o]) + MTP[nm][o] = 1.0 + if o == offs[0]: + SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values())))) + for nm, sp in BVT.items(): + SLE[nm], BK[nm], MTP[nm] = {}, {}, {} + for o in offs: + L = leverage_series(BK["BASE"][o], sp["tv"], sp["w"], sp["cap"]) + sz = {a: sizes_from_L(EX[o][a], L) for a in ASSETS} + SLE[nm][o] = leg_daily(EX[o], sz) + tp_s = TP * L.reindex(TP.index).ffill().fillna(1.0) + BK[nm][o] = book(tp_s, SLE[nm][o]) + MTP[nm][o] = vol(tp_s) / vol(TP) + if o == offs[0]: + SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values())))) + for nm, sp in NAIVE.items(): # controllo NON causale (vedi §4) + SLE[nm] = {o: SLE["BASE"][o] * leverage_series(SLE["BASE"][o], **sp) for o in offs} + BK[nm] = {o: book(TP, SLE[nm][o]) for o in offs} + MTP[nm] = {o: 1.0 for o in offs} + SIZEMED[nm] = float(np.median(leverage_series(SLE["BASE"][offs[0]], **sp).values)) + + # CONTROLLO ISO-PESO: ogni variante che alza la vol della gamba SKH alza il suo peso EFFETTIVO + # nel libro — e "alzare il peso di SKH01" e' gia' stato misurato (26/07: argmax w=0.35, ma + # `weights_tilt_null` FALLITO e proposta respinta). Quindi il confronto che conta NON e' vs il + # baseline nudo ma vs il baseline RI-SCALATO agli stessi rapporti di vol: cosi' resta solo la + # FORMA (quale trade pesa quanto). E' la regola dell'ISO-RISCHIO gia' codificata (25/07 §3). + CTRL, WEFF = {}, {} + for nm in ALL: + CTRL[nm], WEFF[nm] = {}, {} + for o in offs: + m_s = vol(SLE[nm][o]) / vol(SLE["BASE"][o]) if vol(SLE["BASE"][o]) > 0 else 1.0 + m_t = MTP[nm][o] + CTRL[nm][o] = book(TP * m_t, SLE["BASE"][o] * m_s) + WEFF[nm][o] = W_SKH * m_s / (W_TP * m_t + W_SKH * m_s) + + # ------------------------------------------------------------------ §2 sleeve + can = offs[0] + print("\n" + "-" * 112) + print(f" 2. SLEEVE SKH01 all'ancora canonica (off {can}). 'DDisovol'/'CAGRiso' a vol pareggiata") + print(" col baseline (post-hoc, dichiarato: lo Sharpe non ne dipende).") + print("-" * 112) + print(HEAD) + base_s = SLE["BASE"][can] + + def prow(nm, s, med=None): + iv = iso_vol(s, base_s) + m = f"{med:>10.2f}" if med is not None else f"{'-':>10}" + print(f" {nm:<28}{sh(s):>8.3f}{sh(hold(s)):>9.3f}{sh(ins(s)):>8.3f}{vol(s)*100:>8.1f}%" + f"{maxdd(s)*100:>8.1f}%{maxdd(iv)*100:>9.1f}%{cagr(iv)*100:>8.1f}%{m}") + + prow("BASELINE (size fissa)", base_s, 1.00) + for nm in list(SZ) + list(VTL) + list(NAIVE): + prow(nm, SLE[nm][can], SIZEMED.get(nm)) + + # ------------------------------------------------------------------ §3 libro, banda appaiata + print("\n" + "-" * 112) + print(f" 3. LIBRO 75/25 — mediana delle DIFFERENZE APPAIATE su {len(offs)} ancore, + null del") + print(" de-levering (k<1 sul BASELINE che pareggia il maxDD della variante)") + print("-" * 112) + print(f" {'variante':<28}{'wSKH eff':>9}{'dShFULL':>9}{'pos/n':>8}{'dShHOLD':>9}{'pos/n':>8}" + f"{'| ISO dF':>10}{'pos/n':>8}{'ISO dH':>9}{'pos/n':>8}{'dDDpp':>8}{'k_iso':>8}") + base_b = BK["BASE"] + RES = {} + for nm in ALL: + dF = A.anchor_luck_delta(lambda o, n=nm: BK[n][o], lambda o: base_b[o], offs, metric=sh) + dH = A.anchor_luck_delta(lambda o, n=nm: hold(BK[n][o]), lambda o: hold(base_b[o]), + offs, metric=sh) + iF = A.anchor_luck_delta(lambda o, n=nm: BK[n][o], lambda o, n=nm: CTRL[n][o], + offs, metric=sh) + iH = A.anchor_luck_delta(lambda o, n=nm: hold(BK[n][o]), + lambda o, n=nm: hold(CTRL[n][o]), offs, metric=sh) + dd = float(np.median([maxdd(BK[nm][o]) - maxdd(base_b[o]) for o in offs])) * 100 + if maxdd(BK[nm][can]) < maxdd(base_b[can]) - 1e-6: + k = k_iso_dd(base_b[can], BK[nm][can]) + shk = sh(k * base_b[can]) + ks = f"{k:.3f}" + vd = "DE-LEVERING" if shk >= sh(BK[nm][can]) else "oltre-null" + else: + ks, vd = "n/a", "DD peggiore" + we = float(np.median([WEFF[nm][o] for o in offs])) + RES[nm] = dict(dF=dF["median_paired"], nF=dF["n_positive"], dH=dH["median_paired"], + nH=dH["n_positive"], iF=iF["median_paired"], niF=iF["n_positive"], + iH=iH["median_paired"], niH=iH["n_positive"], dd=dd, k=ks, + verdict=vd, weff=we) + print(f" {nm:<28}{we:>9.3f}{dF['median_paired']:>+9.3f}{dF['n_positive']:>5}/{len(offs):<3}" + f"{dH['median_paired']:>+9.3f}{dH['n_positive']:>5}/{len(offs):<3}" + f"{iF['median_paired']:>+10.3f}{iF['n_positive']:>5}/{len(offs):<3}" + f"{iH['median_paired']:>+9.3f}{iH['n_positive']:>5}/{len(offs):<3}{dd:>+8.2f}{ks:>8}") + print(" wSKH eff = peso EFFETTIVO di SKH nel libro (i nominali restano 75/25: la size lo") + print(" muove). Colonne 'ISO' = confronto vs il baseline RI-SCALATO a quello stesso peso:") + print(" e' li' che si legge la FORMA. Le colonne non-ISO contengono anche il cambio di peso.") + med_F = float(np.median([sh(base_b[o]) for o in offs])) + med_H = float(np.median([sh(hold(base_b[o])) for o in offs])) + med_D = float(np.median([maxdd(base_b[o]) for o in offs])) + print(f"\n libro BASE, ancora canonica: ShFULL {sh(base_b[can]):.3f} / ShHOLD " + f"{sh(hold(base_b[can])):.3f} / maxDD {maxdd(base_b[can])*100:.1f}% / " + f"CAGR {cagr(base_b[can])*100:.1f}%") + print(f" libro BASE, stima ONESTA (mediana della banda): ShFULL {med_F:.3f} / " + f"ShHOLD {med_H:.3f} / maxDD {med_D*100:.1f}%") + + # ------------------------------------------------------------------ §4 il difetto misurato + print("\n" + "-" * 112) + print(" 4. IL DIFETTO MISURATO — vol-target NAIVE (scala il giorno di CHIUSURA) vs CAUSALE") + print(" (size fissata all'INGRESSO). Stessa formula di L, stessa finestra: cambia solo") + print(" QUANDO la si applica. La differenza e' il valore del look-ahead di contabilita'.") + print("-" * 112) + print(f" {'coppia':<34}{'dShFULL naive':>15}{'dShFULL causale':>17}{'phantom':>10}") + for w in (30, 90): + n_nm, c_nm = f"NAIVE-VT w{w}", f"VTL tv20 w{w}" + a1, a2 = RES[n_nm]["dF"], RES[c_nm]["dF"] + print(f" {'vol-target tv20 w%d' % w:<34}{a1:>+15.3f}{a2:>+17.3f}{a1-a2:>+10.3f}") + for w in (30, 90): + n_nm, c_nm = f"NAIVE-VT w{w}", f"VTL tv20 w{w}" + a1, a2 = RES[n_nm]["dH"], RES[c_nm]["dH"] + print(f" {' (hold-out) tv20 w%d' % w:<34}{a1:>+15.3f}{a2:>+17.3f}{a1-a2:>+10.3f}") + + # ------------------------------------------------------------------ §5 selezione + DSR + print("\n" + "-" * 112) + print(" 5. SELEZIONE AL BUIO (solo pre-2025, mediana di banda) + deflated-Sharpe") + print("-" * 112) + CAUSAL = [n for n in ALL if n not in NAIVE] + is_med = {n: float(np.median([sh(ins(BK[n][o])) for o in offs])) for n in CAUSAL} + ho_med = {n: float(np.median([sh(hold(BK[n][o])) for o in offs])) for n in CAUSAL} + fu_med = {n: float(np.median([sh(BK[n][o]) for o in offs])) for n in CAUSAL} + base_is = float(np.median([sh(ins(base_b[o])) for o in offs])) + print(f" baseline in-sample (mediana banda): {base_is:.3f}") + for n in sorted(CAUSAL, key=lambda x: -is_med[x])[:6]: + print(f" {n:<28} IS {is_med[n]:>7.3f} HOLD {ho_med[n]:>7.3f} FULL {fu_med[n]:>7.3f}") + best = max(CAUSAL, key=lambda x: is_med[x]) + print(f"\n cella scelta AL BUIO: {best} (IS {is_med[best]:.3f} vs baseline {base_is:.3f})") + trials = [fu_med[n] for n in CAUSAL] + [med_F] + dsr, sr0 = A.deflated_sharpe(fu_med[best], trials, BK[best][can]) + dsr_b, _ = A.deflated_sharpe(med_F, trials, base_b[can]) + print(f" DSR candidato {dsr:.3f} / DSR BASELINE {dsr_b:.3f} (null max-Sharpe {sr0:.3f}, " + f"N={len(trials)})") + print(f" dispersione degli {len(trials)} trial: sd {np.std(trials, ddof=1):.4f} -> " + f"⚠️ il DSR e' QUASI VACUO su una famiglia di perturbazioni della stessa strategia") + print(" (penalizza in proporzione alla varianza fra i trial: qui il baseline stesso lo") + print(" passa). Il gate che decide e' la banda appaiata del §3, non questo.") + + # ------------------------------------------------------------------ §6 ancora TP01 + print("\n" + "-" * 112) + print(" 6. CONTROLLO — l'ancora di TP01 e' tenuta fissa: il segno del Delta dipende da lei?") + print("-" * 112) + cand6 = [best] + [n for n in (list(SZ)[:0] + ["RISK p1.0"] + list(BVT)[:2]) if n != best] + print(f" {'variante':<28}" + "".join(f"{'TP h=%d' % h:>12}" for h in (0, 8, 16))) + for nm in cand6: + cells = [] + for h in (0, 8, 16): + tph = TO.port_daily_native(h) if h else TP + if nm in BVT: + arm = {} + for o in offs: + L = leverage_series(book(tph, SLE["BASE"][o]), BVT[nm]["tv"], BVT[nm]["w"], + BVT[nm]["cap"]) + sz = {a: sizes_from_L(EX[o][a], L) for a in ASSETS} + arm[o] = book(tph * L.reindex(tph.index).ffill().fillna(1.0), + leg_daily(EX[o], sz)) + else: + arm = {o: book(tph, SLE[nm][o]) for o in offs} + bse = {o: book(tph, SLE["BASE"][o]) for o in offs} + d = A.anchor_luck_delta(lambda o, x=arm: x[o], lambda o, y=bse: y[o], offs, metric=sh) + cells.append(d["median_paired"]) + print(f" {nm:<28}" + "".join(f"{v:>+12.3f}" for v in cells)) + + # ------------------------------------------------------------------ §7 gate + print("\n" + "-" * 112) + print(" 7. GATE — marginal_vs_tp01 / implausible_sharpe / weights_tilt_null") + print("-" * 112) + best_sleeve = max([n for n in list(SZ) + list(VTL)], key=lambda x: is_med[x]) + for nm in ("BASE", best_sleeve): + m = A.marginal_vs_tp01(SLE[nm][can]) + b25 = m.get("blends", {}).get("w25", {}) + print(f" marginal {nm:<26} {m.get('marginal_verdict')} corr {m.get('corr_full')} " + f"uplift w25 full {b25.get('uplift_full')} / hold {b25.get('uplift_hold')}") + nlos = int((EX[can]["BTC"]["net"] < 0).sum() + (EX[can]["ETH"]["net"] < 0).sum()) + ntot = int(len(EX[can]["BTC"]["net"]) + len(EX[can]["ETH"]["net"])) + for nm in ("BASE", best_sleeve): + imp = A.implausible_sharpe(SLE[nm][can], n_trades=ntot, n_losing_trades=nlos) + print(f" implausible {nm:<25} implausible={imp['implausible']} " + f"attive {imp.get('active_frac', 0)*100:.1f}% perdite/attive " + f"{imp.get('loss_frac', 0)*100:.1f}% Calmar {imp.get('calmar', 0):.1f}") + cols = {"TP01": TP, "SKH01": SLE["BASE"][can]} + for nm in [best_sleeve, list(BVT)[0]]: + m = vol(SLE[nm][can]) / vol(SLE["BASE"][can]) + wp = {"TP01": W_TP, "SKH01": W_SKH * m} + g = weights_tilt_null(cols, {"TP01": W_TP, "SKH01": W_SKH}, wp, + caps={"SKH01": 0.5}, floor=0.05, n=300, k_seen=len(ALL)) + print(f" tilt-null {nm:<26} peso equiv SKH {wp['SKH01']/sum(wp.values()):.3f} " + f"d_IS {g['delta_insample']:+.4f} d_HOLD {g['delta_hold']:+.4f} " + f"pctl {g['pctl_hold']:.1f} gate_pass={g['gate_pass']}") + print(" LIMITE DICHIARATO: il gate confronta vettori di pesi STATICI; della modulazione") + print(" cattura la sola componente di SCALA. La FORMA la giudica la banda appaiata (§3).") + + # ------------------------------------------------------------------ §8 eseguibilita' + print("\n" + "-" * 112) + print(f" 8. ESEGUIBILITA' a ${CAPITAL:.0f} — min-order ${MIN_ORDER:.0f}, cap {CAP_ASSET:.0%}" + f" equity/asset, target NETTATO 0.75*TP01 + 0.25*SKH sul grid 1h (cio' che il cron manda)") + print("-" * 112) + print(f" {'configurazione':<28}{'Sh model':>10}{'Sh reale':>10}{'haircut':>9}" + f"{'ordini eseg':>13}{'sotto-min':>11}{'turnover/a':>12}") + todo = [("BASE (size fissa)", None), (best_sleeve, best_sleeve), (list(BVT)[0], list(BVT)[0])] + for label, nm in todo: + tm = tr = tu = 0.0 + no = ns = 0 + for a in ASSETS: + df1h = TO.get1h(a) + tgt = netted_target(a, EX[can][a], nm, SZ, VTL, BVT, SLE, TP, can, df1h) + ev = A.eval_weights_smallcap(df1h, tgt, capital=CAPITAL, min_order=MIN_ORDER) + tm += 0.5 * ev["modeled"]["sharpe"] + tr += 0.5 * ev["realistic"]["sharpe"] + tu += ev["executed_turnover_per_year"] + no += ev["n_executed_trades"] + dw = np.abs(np.diff(np.nan_to_num(tgt), prepend=0.0)) + ns += int(((dw > 1e-12) & (dw * CAPITAL < MIN_ORDER)).sum()) + print(f" {label:<28}{tm:>10.3f}{tr:>10.3f}{tm-tr:>9.3f}{no:>13}{ns:>11}{tu:>12.1f}") + print(" 'ordini eseg' e 'sotto-min' sono sui due asset sommati, su tutta la storia 1h.") + + print("\n" + "=" * 112) + print(f" fatto in {time.time()-t0:.0f}s") + print("=" * 112) + + +def netted_target(asset: str, ex: dict, nm, SZ, VTL, BVT, SLE, TP, can, df1h) -> np.ndarray: + """Peso NETTO per asset sul grid 1h: 0.75*target TP01 + 0.25*posizione SKH (dir*size), poi + l'eventuale leva di libro sulla gamba TP01. E' cio' che il cron manda a Deribit come UNA + posizione netta sullo stesso strumento.""" + tp = np.asarray(TO.hourly_target(asset, 0), float) + ts1 = df1h["timestamp"].values.astype(np.int64) + 3_600_000 + pos = np.zeros(len(ts1)) + L1h = np.ones(len(ts1)) + if nm is None: + sizes = size_flat(ex) + elif nm in SZ: + sizes = SZ[nm](ex) + elif nm in VTL: + base_a = equity_daily(ex, size_flat(ex)) + sizes = sizes_from_L(ex, leverage_series(base_a, **VTL[nm])) + else: # BOOKVT + L = leverage_series(book(TP, SLE["BASE"][can]), BVT[nm]["tv"], BVT[nm]["w"], BVT[nm]["cap"]) + sizes = sizes_from_L(ex, L) + idx1h = pd.DatetimeIndex(pd.to_datetime(df1h["timestamp"].values, unit="ms", utc=True)) + L1h = L.reindex(idx1h.floor("D")).ffill().fillna(1.0).values + t_in, t_out = ex["ts_close"][ex["i_ent"]], ex["ts_close"][ex["i_ex"]] + for k in range(len(t_in)): + j0 = int(np.searchsorted(ts1, t_in[k], side="left")) + j1 = int(np.searchsorted(ts1, t_out[k], side="left")) + pos[j0:j1] = ex["dir"][k] * sizes[k] + return np.clip(W_TP * tp * L1h + W_SKH * pos, -CAP_ASSET, CAP_ASSET) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_vrp_real_quotes.py b/scripts/research/r0822_vrp_real_quotes.py new file mode 100644 index 0000000..d53e924 --- /dev/null +++ b/scripts/research/r0822_vrp_real_quotes.py @@ -0,0 +1,710 @@ +"""R0822 VRP-QUOTE-VERE — esiste una struttura di reddito in opzioni ESEGUIBILE a $600-3.000 +che sopravvive a fee reali, spread reali e lotto minimo? + +NON e' "rifare VRP01". La domanda e' se la regola dura del progetto — *niente short-vol da +MODELLO in deploy* (19/06, riconfermata 3 volte) — si possa sostituire con *short-vol da QUOTE*, +che e' un'altra cosa: il 30/07 il premio era ancora prezzato Black-Scholes su DVOL ATM e la +misura sulle quote reali dava f = 0.73 del modello, con il difetto NON nella gamba venduta +(f_short 1.01) ma nell'ALA che si compra (f_long 2.23). Da allora la condizione e' cambiata: +la catena vera la raccogliamo noi, oraria, per strike, con bid/ask. + +ORDINE DELLE DOMANDE (deliberato). L'eseguibilita' viene PRIMA del rendimento — regola del +30/07: "prima di misurare il rendimento a un capitale dato, misurare il lotto minimo del venue". +Se non si esegue, il resto e' accademia. + + §1 LIMITE DI REGIME — risultato di prima riga, non nota a pie' di pagina + §2 IL CAMPIONE — quante settimane ci sono DAVVERO, e perche' + §3 ESEGUIBILITA' — lotti, famiglie, e su quale delle due si esegue + §4 BACKTEST SU QUOTE REALI — griglia dichiarata, fill al bid/ask, fee di listino + §5 IL RISCHIO STA NEL MINIMO — non nelle chiusure settimanali + §6 GATE — inclusi quelli che NON si possono far girare + §7 VERDETTO + +CONVENZIONI CHE DECIDONO IL RISULTATO (dichiarate qui, non sepolte nel codice) + (a) FILL: si vende al BID e si compra all'ASK, sempre. Il mid e' il modello sotto mentite + spoglie e compare solo come diagnostica, mai come cella selezionabile. + (b) FEE: listino Deribit vero — `min(0.03% del sottostante, 12.5% del premio della SINGOLA + opzione)` per gamba, piu' la fee di consegna sulle gambe ITM a scadenza. Lo sleeve usa + invece un forfait del 12.5% del credito NETTO: qui si misura di quanto sbaglia. + (c) SETTLEMENT: indice reale alla scadenza dal feed certificato. Deribit regola sulla media + dell'indice nei 30 minuti prima delle 08:00 UTC; qui si usa la barra 1h di chiusura — + approssimazione dichiarata, non nascosta. + (d) CAPITALE IMPEGNATO: il denominatore e' il rischio massimo della struttura + `(K_short - K_long) x lotto - credito`. E' il limite INFERIORE del capitale richiesto; + quello vero dipende dal regime di margine del conto e NON e' leggibile da dati pubblici. + Riportato anche il limite superiore (cash-secured, la convenzione del sleeve). + +RAM: la catena si legge filtrata in LETTURA (solo put, solo le colonne che servono) con +pyarrow.dataset — `bite_archive.parquet` intero esplode in memoria e ucciderebbe gli altri agenti. + + nice -n 19 timeout 900 uv run python scripts/research/r0822_vrp_real_quotes.py + ... --no-live # salta le letture dal venue (usa la cache se c'e') +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.request +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import altlib as A # noqa: E402 +import cblib as CB # noqa: E402 + +CHAIN_DIR = ROOT / "data" / "raw" / "cb_chain" +SNAP_JSONL = ROOT / "data" / "options_daily" / "snapshots.jsonl" +CACHE = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/r0822_venue.json") + +# --- listino Deribit (fonte: campo `taker_commission` letto dal venue + schema fee pubblico) --- +FEE_UNDERLYING = 0.0003 # 0.03% del sottostante per gamba, per contratto +FEE_PREMIUM_CAP = 0.125 # cap: 12.5% del premio della SINGOLA opzione +FEE_DELIVERY = 0.00015 # 0.015% del sottostante sulle gambe ITM a scadenza +SLEEVE_FEE_FRAC = 0.125 # forfait dello sleeve: 12.5% del credito NETTO + +DTE_LO, DTE_HI, DTE_TARGET = 4.0, 10.0, 7.0 +WEEKS_PER_YEAR = 52.0 + +# ---------------------------------------------------------------- griglia DICHIARATA +# Si conta al RIALZO: ogni variante provata e' un trial, anche quelle scartate. +SHORT_DELTAS = (-0.35, -0.28, -0.20, -0.12) +LONG_DELTAS = (-0.20, -0.10, -0.05) +CELLS = [(s, l) for s in SHORT_DELTAS for l in LONG_DELTAS if abs(l) < abs(s)] +FILLS = ("bid_ask", "mid") # `mid` = diagnostica, NON selezionabile +ASSETS = ("BTC", "ETH") +CANON = (-0.28, -0.10) # la cella di VRP01 + + +def hr(t: str = "", ch: str = "=") -> None: + print("\n" + ch * 100) + if t: + print(t) + print(ch * 100) + + +# ================================================================== dati + + +def load_puts() -> pd.DataFrame: + """Solo le PUT, solo le colonne che servono, filtrate in LETTURA.""" + import pyarrow.dataset as ds + + cols = ["ts", "asset", "instrument_name", "strike", "option_type", "exp", + "bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"] + d = ds.dataset(str(CHAIN_DIR), format="parquet") + df = d.to_table(columns=cols, filter=(ds.field("option_type") == "P")).to_pandas() + df["ts"] = pd.to_datetime(df["ts"], utc=True).dt.as_unit("ns") + df["exp"] = pd.to_datetime(df["exp"], utc=True) + df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0 + return df.drop_duplicates(subset=["ts", "instrument_name"], keep="last").reset_index(drop=True) + + +def venue_read(live: bool) -> dict: + """Specifiche e liquidita' lette DAL VENUE. Un fallimento si dichiara, non si inventa.""" + if not live and CACHE.exists(): + return json.loads(CACHE.read_text()) + + def api(path, **kw): + q = "&".join(f"{k}={v}" for k, v in kw.items()) + with urllib.request.urlopen( + f"https://www.deribit.com/api/v2/public/{path}?{q}", timeout=25) as r: + return json.load(r)["result"] + + out = {"ok": False, "ts": pd.Timestamp.now(tz="UTC").isoformat()} + try: + out["index"] = {c: api("get_index_price", index_name=f"{c}_usd")["index_price"] + for c in ("btc", "eth")} + specs, summ = {}, [] + for cur in ("BTC", "ETH", "USDC"): + for i in api("get_instruments", currency=cur, kind="option", expired="false"): + fam = i["instrument_name"].split("-")[0] + if fam not in specs: + specs[fam] = {k: i.get(k) for k in + ("min_trade_amount", "contract_size", "settlement_currency", + "quote_currency", "taker_commission", "base_currency")} + summ += api("get_book_summary_by_currency", currency=cur, kind="option") + time.sleep(0.4) + out["specs"] = specs + out["summary"] = [{k: r.get(k) for k in + ("instrument_name", "bid_price", "ask_price", "mid_price", + "mark_price", "mark_iv", "open_interest", "volume", + "underlying_price")} for r in summ] + out["ok"] = True + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps(out)) + except Exception as exc: # noqa: BLE001 + out["error"] = f"{type(exc).__name__}: {exc}" + if CACHE.exists(): + cached = json.loads(CACHE.read_text()) + cached["stale"] = out["error"] + return cached + return out + + +def specs_from_disk() -> dict: + """Le specifiche gia' registrate dal progetto (`data/options_daily/snapshots.jsonl`). + Serve come controllo indipendente della lettura live: due percorsi, stesso numero.""" + mins, fees, last_ts = {}, {}, {} + if not SNAP_JSONL.exists(): + return {} + with SNAP_JSONL.open() as f: + for line in f: + try: + r = json.loads(line) + except Exception: # noqa: BLE001, S112 + continue + if r.get("rec") != "chain": + continue + c = r["currency"] + mins.setdefault(c, set()).add(r.get("min_trade_amount")) + fees.setdefault(c, set()).add(r.get("taker_comm")) + last_ts[c] = r["snap_ts"] + return {c: {"min": sorted(x for x in mins[c] if x is not None), + "taker": sorted(x for x in fees[c] if x is not None), + "ts": pd.Timestamp(last_ts[c], unit="ms", tz="UTC")} for c in mins} + + +# ================================================================== struttura + + +def leg_fee_usd(prem_base: float, spot: float) -> float: + """Fee di listino per UNA gamba, per contratto (1 unita' di sottostante), in USD.""" + return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot) + + +def entry_snapshots(puts: pd.DataFrame, asset: str, offset_h: float = 0.0) -> dict: + """Per ogni scadenza settimanale: lo snapshot piu' vicino a (7 giorni + offset) dalla scadenza. + + L'offset e' l'ANCORA: entrare alle 08:00 del venerdi' precedente e' UNA delle scelte possibili, + e questo progetto ha misurato 4 volte che l'ancora e' un max-of-k non dichiarato. + """ + p = puts[(puts["asset"] == asset) & (puts["dte"] >= DTE_LO) & (puts["dte"] <= DTE_HI)] + tgt = DTE_TARGET + offset_h / 24.0 + out = {} + for exp, g in p.groupby("exp"): + ts_all = np.array(sorted(g["ts"].unique())) + if not len(ts_all): + continue + dte = (exp - pd.DatetimeIndex(ts_all)).total_seconds() / 86400.0 + ok = (dte >= DTE_LO) & (dte <= DTE_HI) + if not ok.any(): + continue + cand = ts_all[ok][np.argsort(np.abs(dte[ok] - tgt))] + for ts in cand[:6]: # al piu' 6 tentativi per scadenza + snap = g[g["ts"] == ts] + if snap["bid"].notna().sum() >= 2: + out[exp] = (pd.Timestamp(ts), snap) + break + return out + + +def build_trades(puts: pd.DataFrame, asset: str, short_d: float, long_d: float, + fill: str = "bid_ask", offset_h: float = 0.0, + lot: float = 1.0, today: pd.Timestamp | None = None) -> pd.DataFrame: + """Un trade per scadenza: ingresso su quote REALI, tenuto a scadenza, regolato sull'indice.""" + today = today or pd.Timestamp.now(tz="UTC") + S = CB.spot_series(asset) + V = CB.dvol_series(asset) + rows, rejected = [], {"gambe_mancanti": 0, "credito_non_positivo": 0} + + for exp, (ts, snap) in entry_snapshots(puts, asset, offset_h).items(): + if exp >= today: # non ancora regolata: non e' un trade + continue + legs = CB.pick_legs(snap, short_d, long_d) + if legs is None: + rejected["gambe_mancanti"] += 1 + continue + spot = float(S.asof(ts)) + dte = (exp - ts).total_seconds() / 86400.0 + dvol = float(V.asof(ts)) + hist = V[V.index < ts] + ivr = float((hist < dvol).mean()) if len(hist) else np.nan + + if fill == "bid_ask": + ps, pl = legs["bid_short"], legs["ask_long"] # vendi al bid, compra all'ask + else: + ps, pl = legs["mid_short"], legs["mid_long"] + if not (np.isfinite(ps) and np.isfinite(pl)): + rejected["gambe_mancanti"] += 1 + continue + cred = (ps - pl) * spot # USD per 1 unita' di sottostante + if cred <= 0: + # premio non monotono nello strike (difetto certificato di `certify_cb_chain`): + # una put piu' OTM quotata sopra una meno OTM. Non e' una struttura, e' una quota rotta. + rejected["credito_non_positivo"] += 1 + continue + + ST = float(S.asof(exp)) + payoff = max(legs["k_short"] - ST, 0.0) - max(legs["k_long"] - ST, 0.0) + fee = leg_fee_usd(ps, spot) + leg_fee_usd(pl, spot) + for K in (legs["k_short"], legs["k_long"]): # consegna sulle gambe ITM + intr = max(K - ST, 0.0) + if intr > 0: + fee += min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * intr) + + width = legs["k_short"] - legs["k_long"] + mod = CB.f_factors(legs, spot, dvol / 100.0, dte) + rows.append(dict( + asset=asset, ts=ts, exp=exp, dte=dte, spot=spot, ST=ST, + inst_short=legs["inst_short"], inst_long=legs["inst_long"], + cred_real=cred, # per 1 unita': lo vuole CB.close_cost_path + k_short=legs["k_short"], k_long=legs["k_long"], width=width, + d_short=legs["d_short"], d_long=legs["d_long"], + cred=cred * lot, payoff=payoff * lot, fee=fee * lot, + pnl=(cred - payoff - fee) * lot, + fee_sleeve=SLEEVE_FEE_FRAC * mod["cred_mod"] * lot, + fee_sleeve_su_reale=SLEEVE_FEE_FRAC * cred * lot, + cred_mod=mod["cred_mod"] * lot, + risk=width * lot - cred * lot, + dvol=dvol, ivrank=ivr, f_net=mod["f_net"], + und_ret=ST / spot - 1.0, + )) + df = pd.DataFrame(rows) + if not df.empty: + df = df.sort_values("exp").reset_index(drop=True) + df.attrs["rejected"] = rejected + return df + + +def stats(tr: pd.DataFrame) -> dict: + """Metriche di una serie di trade settimanali. Il denominatore e' il RISCHIO impegnato.""" + if tr.empty or len(tr) < 2: + return dict(n=len(tr), sharpe=np.nan, mean=np.nan, t=np.nan) + r = (tr["pnl"] / tr["risk"]).to_numpy(float) # ritorno sul capitale a rischio + sd = float(np.std(r, ddof=1)) + m = float(np.mean(r)) + eq = np.cumprod(1.0 + r) + dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq))) + return dict(n=len(tr), mean=m, sd=sd, + sharpe=(m / sd * np.sqrt(WEEKS_PER_YEAR)) if sd > 0 else np.nan, + t=(m / (sd / np.sqrt(len(r)))) if sd > 0 else np.nan, + maxdd_close=dd, wins=int((tr["pnl"] > 0).sum()), + usd_week=float(tr["pnl"].mean()), usd_tot=float(tr["pnl"].sum())) + + +def weekly_series(tr: pd.DataFrame) -> pd.Series: + return pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float), + index=pd.DatetimeIndex(tr["exp"])).sort_index() + + +def boot_ci(x: np.ndarray, n: int = 4000, seed: int = 822) -> tuple[float, float]: + rng = np.random.default_rng(seed) + if len(x) < 2: + return (np.nan, np.nan) + bs = rng.choice(x, size=(n, len(x)), replace=True).mean(axis=1) + return float(np.percentile(bs, 2.5)), float(np.percentile(bs, 97.5)) + + +# ================================================================== main + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--no-live", action="store_true", help="non interrogare il venue") + args = ap.parse_args() + + print(__doc__.split("\n\n")[0]) + puts = load_puts() + today = pd.Timestamp.now(tz="UTC") + venue = venue_read(not args.no_live) + + # ------------------------------------------------------------ §1 REGIME + hr("§1 LIMITE DI REGIME — il campione descrive il regime in cui la strategia sta FLAT") + print("Non e' una nota a pie' di pagina: VRP01 entra SOLO se IV-rank > 0.30 (il gate e' l'alpha,\n" + "misurato 4 volte). Se nel campione quel gate non scatta mai, il campione non contiene\n" + "la strategia — contiene la sua astensione.\n") + for a in ASSETS: + V = CB.dvol_series(a) + win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())] + pct = float((V[V.index < win.index[0]].values[:, None] < win.values).mean()) + print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / " + f"max {win.max():.1f} → percentile mediano vs storia 2021+ = {pct:.0%}") + + # ------------------------------------------------------------ §2 CAMPIONE + hr("§2 IL CAMPIONE — quante settimane ci sono DAVVERO") + pre = puts[puts["ts"] < pd.Timestamp("2026-06-09", tz="UTC")] + print(f" catena su disco: {puts['ts'].min():%Y-%m-%d} → {puts['ts'].max():%Y-%m-%d} " + f"({(puts['ts'].max() - puts['ts'].min()).days} giorni, {len(puts):,} righe put)") + if not pre.empty: + print(f" ma PRIMA del 2026-06-09 l'archivio ereditato seguiva UNA sola scadenza, in " + f"finestra {pre['dte'].min():.0f}-{pre['dte'].max():.0f} DTE:") + print(f" → la finestra settimanale {DTE_LO:.0f}-{DTE_HI:.0f} DTE NON ESISTE prima " + f"della raccolta full-chain.") + print("\n Trade REGOLATI utilizzabili (cella canonica VRP01 δ-0.28/-0.10, fill bid/ask):") + canon: dict[str, pd.DataFrame] = {} + for a in ASSETS: + lot = 1.0 + tr = build_trades(puts, a, *CANON, lot=lot, today=today) + canon[a] = tr + rej = tr.attrs.get("rejected", {}) if not tr.empty else {} + print(f" {a}: {len(tr)} settimane regolate ({tr['exp'].min():%Y-%m-%d} → " + f"{tr['exp'].max():%Y-%m-%d}) scartate: {rej}") + print("\n ⚠️ «3,7 mesi ≈ 16 scadenze settimanali» e' FALSO su questi dati: sono 10 per asset.") + + print("\n Gate IV-rank>0.30 di VRP01 sulle settimane utilizzabili:") + tot = passed = 0 + for a in ASSETS: + tr = canon[a] + n = int((tr["ivrank"] > 0.30).sum()) + tot += len(tr) + passed += n + print(f" {a}: {n}/{len(tr)} passano (IV-rank min {tr['ivrank'].min():.3f} / " + f"max {tr['ivrank'].max():.3f})") + print(f" TOTALE: {passed}/{tot}. Il sleeve sarebbe stato FLAT per l'intero campione.") + + ret = {a: (canon[a]["ST"].iloc[-1] / canon[a]["spot"].iloc[0] - 1.0) for a in ASSETS} + print(f"\n E il sottostante nel campione: BTC {ret['BTC']:+.1%}, ETH {ret['ETH']:+.1%} — " + f"vol implicita bassa E rialzo forte\n = il regime IDEALE per vendere put. " + f"Il campione e' il migliore possibile per questa struttura, non uno neutro.") + + # ------------------------------------------------------------ §3 ESEGUIBILITA' + hr("§3 ESEGUIBILITA' — e qui il filone cambia domanda") + fams = sorted({n.split("-")[0] for n in puts["instrument_name"].head(50_000)}) + print(f" La catena che raccogliamo contiene le famiglie: {fams}") + print(" → sono le opzioni INVERSE: quotate, marginate e regolate in BTC/ETH.") + print(" Il conto e' in USDC ($635) e il book esegue sui perpetual LINEARI USDC.\n") + + disk = specs_from_disk() + if disk: + print(" Specifiche gia' registrate dal progetto (data/options_daily/snapshots.jsonl):") + for c, v in sorted(disk.items()): + print(f" {c:6s} min_trade_amount={v['min']} taker={v['taker']} ({v['ts']:%Y-%m-%d %H:%M})") + + if venue.get("ok"): + idx = venue["index"] + print(f"\n Lette dal venue ora — indice BTC ${idx['btc']:,.0f} / ETH ${idx['eth']:,.0f}:") + print(f" {'famiglia':11s} {'min lotto':>10s} {'settle':>7s} {'quote':>7s} " + f"{'$ nozionale/lotto':>18s}") + for fam in ("BTC", "ETH", "BTC_USDC", "ETH_USDC"): + s = venue["specs"].get(fam) + if not s: + continue + base = (s.get("base_currency") or fam.split("_")[0]).lower() + notion = float(s["min_trade_amount"]) * idx[base] + print(f" {fam:11s} {s['min_trade_amount']:>10} {str(s['settlement_currency']):>7s} " + f"{str(s['quote_currency']):>7s} {notion:>17,.0f}$") + + print("\n 📌 Esiste una famiglia USDC-lineare per BTC ed ETH, con lotto minimo 10x PIU'") + print(" PICCOLO e regolata nella valuta del conto. Il muro «BTC fuori a $3.000» del") + print(" 30/07 — congelato in tests/test_vrp_profit_take.py — e' stato misurato sulla") + print(" famiglia che il conto NON margina.") + print(" E' la 3a occorrenza della stessa forma dopo fee_watch (21/08, sorvegliava gli") + print(" INVERSE mentre il book trada i LINEARI) e la taratura di venue_watch.") + + # --- ma la famiglia eseguibile e' quotata davvero? --- + d = pd.DataFrame(venue["summary"]) + parts = d["instrument_name"].str.split("-") + d["fam"] = parts.str[0] + d["typ"] = parts.str[3] + d["strike"] = pd.to_numeric(parts.str[2].str.replace("d", ".", regex=False), errors="coerce") + d["expd"] = pd.to_datetime(parts.str[1], format="%d%b%y", utc=True) + pd.Timedelta(hours=8) + d["dte"] = (d["expd"] - today).dt.total_seconds() / 86400.0 + d["base"] = d["fam"].str.replace("_USDC", "", regex=False).str.lower() + d = d[d["typ"].eq("P") & d["base"].isin(["btc", "eth"])].copy() + d["S"] = d["base"].map(idx) + d["mny"] = d["strike"] / d["S"] + q = d[(d["bid_price"] > 0) & (d["ask_price"] > 0)].copy() + q["relsp"] = (q["ask_price"] - q["bid_price"]) / q["mid_price"] + # regione delle put OTM usate dalla struttura (δ -0.35..-0.05 ≈ moneyness 0.78-0.97) + w = q[(q["dte"] > 3) & (q["dte"] < 11) & q["mny"].between(0.78, 0.97)] + print("\n Liquidita' REALE nella regione di strike che serve (scadenza settimanale, ora):") + print(f" {'famiglia':11s} {'n quotate':>9s} {'spread rel. med':>16s} " + f"{'p75':>7s} {'OI med':>9s} {'vol 24h':>10s}") + for fam, g in w.groupby("fam"): + allf = d[d["fam"] == fam] + print(f" {fam:11s} {len(g):>9d} {g['relsp'].median():>15.0%} " + f"{g['relsp'].quantile(.75):>6.0%} {g['open_interest'].median():>9.1f} " + f"{allf['volume'].sum():>10,.0f}") + print("\n Lo spread relativo e' ENORME in entrambe le famiglie (il mid non e' un prezzo\n" + " eseguibile: e' la media di due prezzi lontani), e sulla famiglia USDC — l'unica\n" + " marginabile dal conto — l'open interest e' ~0: quotata da un market maker,\n" + " praticamente non tradata. Comprare l'ala li' dentro costa il doppio.") + else: + print(f"\n ⚠️ LETTURA DAL VENUE NON RIUSCITA: {venue.get('error', 'n/d')}") + print(" Le specifiche delle famiglie USDC restano NON MISURATE in questo giro.") + + print("\n Capitale per UN lotto minimo, cella canonica (limiti dichiarati, vedi docstring):") + print(f" {'asset':6s} {'lotto':>7s} {'width med':>10s} {'rischio max':>12s} " + f"{'cash-secured':>13s} {'credito/sett':>13s}") + minlot = {"BTC": 0.1, "ETH": 1.0} + if venue.get("ok"): + for fam in ("BTC", "ETH"): + if fam in venue["specs"]: + minlot[fam] = float(venue["specs"][fam]["min_trade_amount"]) + for a in ASSETS: + tr, lot = canon[a], minlot[a] + wid = float(tr["width"].median()) + print(f" {a:6s} {lot:>7.2f} {wid:>10,.0f} {wid * lot - tr['cred'].median() * lot:>11,.0f}$ " + f"{tr['k_short'].median() * lot:>12,.0f}$ {tr['cred'].median() * lot:>12,.2f}$") + print(" Sul rischio DEFINITO un lotto minimo inverse sta dentro $600. Sul cash-secured no.\n" + " Quale dei due valga dipende dal regime di margine del conto, che NON e' leggibile\n" + " da dati pubblici: si legge sul conto. E' un limite di questa misura, non un dettaglio.") + + # ------------------------------------------------------------ §4 BACKTEST + hr("§4 BACKTEST SU QUOTE REALI — griglia dichiarata") + n_trials = len(CELLS) * len(FILLS) * (len(ASSETS) + 1) + print(f" celle δ (short,long): {len(CELLS)} fill: {len(FILLS)} " + f"asset: {len(ASSETS)} + combinato 50/50") + print(f" → TRIAL DICHIARATI AL RIALZO: {n_trials}. Nessuna griglia ridotta per budget.") + print(f" (Il fill `mid` non e' selezionabile — e' la diagnostica di quanto costa lo spread —\n" + f" ma conta come trial: e' stato guardato.)\n") + + grid = [] + for fill in FILLS: + for (sd, ld) in CELLS: + per, comb = {}, [] + for a in ASSETS: + tr = build_trades(puts, a, sd, ld, fill=fill, lot=1.0, today=today) + per[a] = stats(tr) + if not tr.empty: + comb.append(weekly_series(tr)) + row = dict(fill=fill, sd=sd, ld=ld) + for a in ASSETS: + row[f"{a}_sh"] = per[a]["sharpe"] + row[f"{a}_n"] = per[a]["n"] + if comb: + c = pd.concat(comb, axis=1).mean(axis=1).dropna() + row["comb_sh"] = (float(c.mean() / c.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR)) + if c.std(ddof=1) > 0 else np.nan) + row["comb_n"] = len(c) + grid.append(row) + G = pd.DataFrame(grid) + print(G.round(2).to_string(index=False)) + + print("\n ⚠️ COME SI LEGGE LA TABELLA SOPRA. Gli 'Sharpe' a due cifre NON sono un risultato:\n" + " sono la firma del campione. Annualizzare (x√52) 10 osservazioni settimanali in cui\n" + " la coda sinistra non si e' manifestata produce numeri privi di significato — e il\n" + " fatto che l'INTERA griglia li produca e' esattamente il segnale, non un caso da\n" + " selezionare. E' la 4a occorrenza della firma '0-perdite / Sharpe implausibile'\n" + " dopo CC01, le celle deep-OTM Albimarini e meta' della griglia VRP del 03/07.\n" + " Per lo stesso motivo NON viene riportato un CAGR: comporre 10 settimane a 52\n" + " sarebbe una fabbricazione, non una stima.") + + print("\n Cella canonica di VRP01 (δ-0.28/-0.10), fill conservativo, per asset — numeri\n" + " CONCRETI (dollari per lotto minimo), che a questa taglia dicono piu' di un rapporto:") + print(f" {'asset':6s} {'n':>3s} {'vinte':>7s} {'$/sett':>9s} {'$ tot':>9s} " + f"{'% rischio/sett':>15s} {'t':>6s} {'maxDD chiusure':>15s}") + st = {} + for a in ASSETS: + tr = canon[a].copy() + tr[["cred", "payoff", "fee", "pnl", "risk", "cred_mod", + "fee_sleeve", "fee_sleeve_su_reale"]] *= minlot[a] + st[a] = stats(tr) + s = st[a] + print(f" {a:6s} {s['n']:>3d} {s['wins']:>3d}/{s['n']:<3d} {s['usd_week']:>8.2f}$ " + f"{s['usd_tot']:>8.2f}$ {s['mean']:>14.2%} {s['t']:>6.2f} {s['maxdd_close']:>14.1%}") + print(" maxDD 0.0% su BTC significa che NON C'E' STATA una settimana in perdita: e' una\n" + " proprieta' del campione, non della struttura.") + + print("\n Costo dello SPREAD, misurato (stesso ingresso, fill al mid vs al bid/ask):") + for a in ASSETS: + ba = build_trades(puts, a, *CANON, fill="bid_ask", today=today) + md = build_trades(puts, a, *CANON, fill="mid", today=today) + j = ba.merge(md, on="exp", suffixes=("_ba", "_md")) # appaiato per INGRESSO + if j.empty: + continue + print(f" {a}: credito medio al mid ${j['cred_md'].mean():.2f} → al bid/ask " + f"${j['cred_ba'].mean():.2f} = si perde il " + f"{1 - j['cred_ba'].mean() / j['cred_md'].mean():.0%} del credito solo attraversando " + f"lo spread") + + print("\n Il f del 30/07, ri-misurato su 10 settimane invece di 8 (fill conservativo):") + for a in ASSETS: + f = canon[a]["f_net"].dropna() + lo, hi = boot_ci(f.to_numpy()) + print(f" {a}: f_net mediana {f.median():.3f} media {f.mean():.3f} " + f"IC95 [{lo:.3f}, {hi:.3f}] ≥1.0 in {int((f >= 1).sum())}/{len(f)}") + allf = pd.concat([canon[a]["f_net"] for a in ASSETS]).dropna() + lo, hi = boot_ci(allf.to_numpy()) + print(f" pooled: {allf.median():.3f} (IC95 [{lo:.3f}, {hi:.3f}], n={len(allf)}) — " + f"replica indipendente dello 0.73 del 30/07") + + print("\n Fee: listino vero contro il forfait dello sleeve (12.5% del credito NETTO).") + print(" ⚠️ Il confronto giusto e' col credito MODELLATO — e' quello a cui il sleeve applica\n" + " il forfait. Misurarlo sul credito reale (piu' piccolo di ~30%) sottostima l'errore:") + for a in ASSETS: + tr = canon[a] + print(f" {a}: fee reale ${tr['fee'].mean():.2f}/settimana per contratto vs forfait sul\n" + f" credito MODELLATO ${tr['fee_sleeve'].mean():.2f} (= {tr['fee_sleeve'].mean() / tr['fee'].mean():.2f}x)" + f" · sul credito reale ${tr['fee_sleeve_su_reale'].mean():.2f} " + f"(= {tr['fee_sleeve_su_reale'].mean() / tr['fee'].mean():.2f}x)") + print(" Il '~2x' pubblicato il 30/07 non si replica qui: su queste 19 settimane il forfait\n" + " sovrastima le fee di 1.1-1.9x a seconda dell'asset e della base di confronto.\n" + " Resta vero il segno (il forfait e' CONSERVATIVO), non la taglia.") + + # ------------------------------------------------------------ §5 IL MINIMO + hr("§5 IL RISCHIO STA NEL MINIMO — le chiusure settimanali non lo vedono") + print(" Regola del 30/07: un rischio valutato sul minimo non si misura sulle chiusure.\n" + " Qui il mark infra-settimana viene dalle standing quotes ORARIE delle STESSE due gambe\n" + " (ricomprare la corta all'ask, rivendere la lunga al bid: di nuovo conservativo).\n") + worst_rows = [] + for a in ASSETS: + print(f" {a} (lotto {minlot[a]})") + for _, r in canon[a].iterrows(): + path = CB.close_cost_path(r, puts) + if path.empty: + continue + lot = minlot[a] + risk = r["width"] * lot - r["cred"] * lot + worst = float(path["pnl_aperto"].min()) * lot + worst_rows.append(dict(asset=a, exp=r["exp"], worst=worst, risk=risk, + frac=worst / risk, pnl=r["pnl"] * lot, + n_marks=len(path))) + flag = " ← chiusa in UTILE" if r["pnl"] > 0 and worst < 0 else "" + print(f" {r['exp']:%Y-%m-%d} marks={len(path):3d} " + f"peggior mark {worst:>9.2f}$ = {worst / risk:>7.1%} del rischio " + f"esito {r['pnl'] * lot:>7.2f}${flag}") + W = pd.DataFrame(worst_rows) + if not W.empty: + print(f"\n Peggior mark infra-settimana: mediana {W['frac'].median():.1%} del rischio, " + f"minimo {W['frac'].min():.1%}") + wl = W[W["pnl"] > 0] + print(f" Fra le sole settimane chiuse in UTILE ({len(wl)}/{len(W)}): mediana " + f"{wl['frac'].median():.1%}, minimo {wl['frac'].min():.1%}") + print(" → il maxDD sulle chiusure e' una lente che non vede il rischio che si e' corso.") + + # ------------------------------------------------------------ §6 GATE + hr("§6 GATE — compresi quelli che NON si possono far girare") + + print(" (a) implausible_sharpe — il gate del 26/07 sul rischio FUORI dal campione") + for a in ASSETS: + tr = canon[a] + daily = pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float), + index=pd.DatetimeIndex(tr["exp"])) + rep = A.implausible_sharpe(daily, n_trades=len(tr), n_losing_trades=int((tr["pnl"] <= 0).sum())) + print(f" {a}: implausible={rep['implausible']} vinte {int((tr['pnl'] > 0).sum())}/{len(tr)}") + for why in rep["reasons"]: + print(f" · {why}") + if int((tr["pnl"] <= 0).sum()) == 0: + print(f" · regola del tre: 0 perdite su {len(tr)} lascia un tasso VERO fino a " + f"{3 / len(tr):.0%}. Su un payoff che perde {tr['risk'].median() / tr['cred'].median():.1f}x " + f"il credito, basta a ribaltare l'expectancy.") + + print("\n (b) deflated_sharpe — NON CALCOLABILE, e il perche' e' il risultato") + for a in ASSETS: + s = weekly_series(canon[a]) + dsr, _ = A.deflated_sharpe(st[a]["sharpe"], [r["comb_sh"] for r in grid if + np.isfinite(r.get("comb_sh", np.nan))], + s, dpy=WEEKS_PER_YEAR) + print(f" {a}: DSR = {dsr} (T={len(s)} osservazioni settimanali; la funzione " + f"richiede T>=30)") + print(" Costruire una serie GIORNALIERA per superare il T>=30 sarebbe barare: uno sleeve\n" + " settimanale su griglia giornaliera e' ~94% di zeri (lezione 26/07 su VRP01), e il\n" + " T gonfiato entra nel deflated-Sharpe come se fossero osservazioni indipendenti.\n" + " Statistica onesta a questa taglia = il t con il suo intervallo:") + for a in ASSETS: + r = (canon[a]["pnl"] / canon[a]["risk"]).to_numpy(float) + lo, hi = boot_ci(r) + print(f" {a}: media settimanale {r.mean():+.2%} del rischio, IC95 " + f"[{lo:+.2%}, {hi:+.2%}], t={st[a]['t']:.2f}, n={len(r)}") + + print("\n (c) anchor_luck_band — l'ora d'ingresso e' un'ancora (max-of-k mai dichiarato)") + offsets = list(range(-24, 25, 6)) + for a in ASSETS: + def by_off(o, _a=a): + return weekly_series(build_trades(puts, _a, *CANON, offset_h=float(o), today=today)) + band = A.anchor_luck_band( + by_off, offsets, canonical=0, + metric=lambda s: (float(s.mean() / s.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR)) + if len(s) > 2 and s.std(ddof=1) > 0 else 0.0)) + if band.get("reason"): + print(f" {a}: {band['reason']}") + continue + print(f" {a}: canonica {band['canonical']:.2f} (pctl {band['canonical_pctl']:.0%}) · " + f"MEDIANA ONESTA {band['median']:.2f} · banda [{band['lo']:.2f}, {band['hi']:.2f}] · " + f"positiva in {band['frac_positive']:.0%} di {band['n_anchors']} ancore · " + f"fortuna {band['luck']:+.2f}") + + print("\n (d) null del de-levering / beta — 'e' reddito da volatilita' o crypto travestito?'") + print(" Lo spread ha delta positivo (≈ +0.18 all'ingresso): in un campione che sale del") + print(" 20-50% guadagna PER COSTRUZIONE. Regressione del PnL settimanale sul ritorno") + print(" del sottostante nella stessa settimana:") + for a in ASSETS: + tr = canon[a] + x = tr["und_ret"].to_numpy(float) + y = (tr["pnl"] / tr["risk"]).to_numpy(float) + if len(x) < 4: + continue + b, alpha = np.polyfit(x, y, 1) + resid = y - (b * x + alpha) + se = float(np.std(resid, ddof=2) / (np.std(x) * np.sqrt(len(x)))) + print(f" {a}: beta {b:+.3f} (t={b / se:.2f}) alpha {alpha:+.2%}/settimana " + f"corr {np.corrcoef(x, y)[0, 1]:+.2f}") + k = abs(b) + lev = pd.Series(k * x, index=pd.DatetimeIndex(tr["exp"])) + sh_lev = (float(lev.mean() / lev.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR)) + if lev.std(ddof=1) > 0 else np.nan) + print(f" null: essere semplicemente LONG il sottostante a leva {k:.2f} " + f"(stesso beta) da' Sharpe {sh_lev:.2f} contro {st[a]['sharpe']:.2f} della struttura") + + print("\n (e) marginal_vs_tp01 — girato, ma NON interpretabile a questa taglia") + try: + comb = pd.concat([weekly_series(canon[a]) for a in ASSETS], axis=1).mean(axis=1).dropna() + dly = comb.resample("1D").sum().fillna(0.0) + rep = A.marginal_vs_tp01(dly) + act = int((dly != 0).sum()) + print(f" verdetto={rep.get('verdict')} corr={rep.get('corr'):.3f} " + f"(barre ATTIVE {act}/{len(dly)} = {act / len(dly):.0%})") + print(f" ⚠️ {len(comb)} settimane su un hold-out che non esiste: il verdetto e' " + f"rumore, si riporta per non ometterlo.") + except Exception as exc: # noqa: BLE001 + print(f" NON GIRATO: {type(exc).__name__}: {exc}") + + print("\n (f) causality_ok — non girato su questo candidato, e perche'") + print(" `A.causality_ok` valuta target_fn su OHLCV BTC/ETH; qui il segnale non e' una") + print(" serie di prezzo ma una selezione di strumenti da uno snapshot. Verifica") + print(" equivalente eseguita per costruzione e controllata qui sotto:") + bad = 0 + for a in ASSETS: + for _, r in canon[a].iterrows(): + if r["ts"] >= r["exp"] or not (DTE_LO <= r["dte"] <= DTE_HI): + bad += 1 + print(f" ingressi con ts >= scadenza o fuori finestra DTE: {bad}/" + f"{sum(len(canon[a]) for a in ASSETS)} (le gambe si scelgono da UNO snapshot allo\n" + f" stesso ts; il regolamento usa l'indice alla scadenza, mai prima)") + + # ------------------------------------------------------------ §7 VERDETTO + hr("§7 VERDETTO") + print(""" SCARTATO — su questi dati la domanda non e' decidibile, e i tre motivi sono + indipendenti l'uno dall'altro: + + 1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30 + che E' l'alpha di VRP01. Il campione e' vol implicita al 24-28 percentile E un rialzo + del +25%/+57%: il regime migliore possibile per vendere put, non uno neutro. + Le 10 settimane per asset (non 16: prima del 2026-06-09 la finestra 4-10 DTE non + esiste) danno 10/10 vincenti su BTC. La lente giusta non e' lo Sharpe, e' la regola + del tre: 0 perdite su 10 lascia un tasso vero fino al 30%, su un payoff che perde + ~7x il credito. + + 2. LA FAMIGLIA CHE HA STORIA NON E' QUELLA CHE SI PUO' ESEGUIRE. Le quote raccolte + sono le opzioni INVERSE (margine e regolamento in BTC/ETH); il conto e' in USDC. + La famiglia USDC-lineare esiste per BTC ed ETH ed ha un lotto minimo 10x piu' piccolo + (BTC 0.01 invece di 0.1) — quindi il muro «BTC fuori a $3.000» del 30/07, congelato + in un test, e' misurato sulla famiglia sbagliata. Ma di quella famiglia abbiamo ZERO + ore di storia e l'open interest e' ~0. + + 3. LO SPREAD E' IL COSTO DOMINANTE E NON E' MODELLABILE DA UN MID. Attraversare il + bid/ask costa da solo una quota grossa del credito, e l'ala che si COMPRA e' la gamba + dove il libro e' piu' largo — lo stesso meccanismo che il 30/07 misurava come f=0.73. + + Cio' che NON si conclude: che la struttura non funzioni. Non e' stata osservata nel regime + in cui lavora. La regola «niente short-vol da MODELLO in deploy» NON diventa «short-vol da + QUOTE»: le quote ora ci sono, ma sono quote della famiglia sbagliata, in un regime solo. + + AZIONE RACCOMANDATA (non eseguita — tocca la produzione, e la decisione e' dell'operatore): + far raccogliere a `collect_chain.py` anche `currency=USDC` (famiglie BTC_USDC/ETH_USDC). + Costa ~2 chiamate in piu' per giro; senza, fra sei mesi ci ritroveremo con un anno di + storia della famiglia che non possiamo tradare e zero di quella che potremmo.""") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_xs_lite.py b/scripts/research/r0822_xs_lite.py new file mode 100644 index 0000000..605af8b --- /dev/null +++ b/scripts/research/r0822_xs_lite.py @@ -0,0 +1,717 @@ +"""XS-LITE — esiste una versione CONCENTRATA (2-8 gambe) degli edge cross-sectional del progetto +che conservi una frazione utile dell'edge ed e' ESEGUIBILE OGGI a $600-5.000? + +LA DOMANDA (mai posta in questa forma). Il progetto ha due edge cross-sectional che dichiara di non +poter eseguire per TAGLIA, non per debolezza: + XS01 — 19 major HL, long 5 / short 5, ribilancio ogni 10g. Dichiarato eseguibile da ~$20k. + XSR01 — 50 alt HL, residuo OLS su BTC demeanato cross-sezionalmente. Dichiarato ~$5k. +Il vincolo dichiarato e' il **min order** moltiplicato per il numero di gambe. Se si riducono le +gambe il vincolo si allenta — ma l'ampiezza (il numero di scommesse indipendenti) crolla, e +l'ampiezza e' l'unica ragione per cui una strategia cross-sectional ha uno Sharpe alto. +Questo script misura il CAMBIO, non lo assume. E misura anche la premessa: che il muro sia il +min-order. + +IPOTESI PRE-REGISTRATE (scritte prima di guardare i numeri): + (a) lo Sharpe scende con k come ~sqrt(ampiezza): k=2-3 conserva il 40-60% dell'edge; + (b) il min-order NON e' il muro di XS01 a $600 (a k=5 il ticket e' 0.5/5 del capitale dello + sleeve), e il "serve $20k" viene dal TURNOVER del vol-target, non dalla taglia della posizione; + (c) XSR01 concentrato perde molto di piu', perche' il suo edge E' l'ampiezza (il progetto ha gia' + misurato che il demeaning porta l'ampiezza 4.5 -> 37.4: e' quel numero, non il segnale, a + fare lo Sharpe 1.82). +Con 2.6 anni di storia monoregime il verdetto atteso e' LEAD o SCARTATO, mai CANDIDATO. + +MECCANISMI CONGELATI — non si rifitta nulla. + XS01 : blend lookback (30,90), gate dispersione p30 espandente causale, H=10, vol-target 20%, + cap 3x, fee 5 bps per unita' di turnover. UNICO parametro variato: k (gambe per lato). + Replica bit-exact di src.portfolio.sleeves._xsec_returns verificata al passo (0). + XSR01: W=45, sgn=+1, residuo OLS rolling causale su BTC, z-score, tanh, vol-target 20%, cap 2x, + demean cross-sezionale giornaliero, fee 5 bps/gamba. Segnale IMPORTATO da + r0725_statarb_multi, paniere da r0725_statarb_basket_gate (nessuna reimplementazione). + UNICO parametro variato: k. + Il LOOKBACK NON e' stato riaperto DI PROPOSITO: riaprire un parametro riapre la sua famiglia + (lezione VRP-tenore, 30/07) e moltiplicherebbe i trial. Qui si varia solo la CONCENTRAZIONE. + +GRIGLIA DICHIARATA PRIMA DI MISURARE: 2 famiglie x 2 universi x 6 valori di k (+ il "pieno" di +XSR01) = 26 celle valutabili. Le 10 fasi del ciclo H=10 di XS01 sono una banda d'ANCORA, non una +griglia di parametri (regola del progetto: il deflated-Sharpe non le conta) -> riportate come +mediana onesta e come differenze APPAIATE. Il deflated-Sharpe e' comunque pubblicato a N=26 E a +N=156 (sensibilita' del verdetto al conteggio, lezione 30/07). + + nice -n 19 timeout 900 uv run python scripts/research/r0822_xs_lite.py +""" +from __future__ import annotations + +import sys +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import altlib as A # noqa: E402 +from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _xsec_returns # noqa: E402 +from r0725_statarb_multi import BASE, load_hl, pnl, signal # noqa: E402 +from r0725_statarb_basket_gate import basket_from_positions, pair_frames # noqa: E402 + +RAW = ROOT / "data" / "raw" +ANN = np.sqrt(365.25) +HOLDOUT = A.HOLDOUT # 2025-01-01 (convenzione altlib) +MIN_ORDER_HL = 10.0 # Hyperliquid: min order $10 (r0702_capital_scaling) +MIN_ORDER_5 = 5.0 # il $5 di Deribit, citato nella missione +KS = (1, 2, 3, 4, 5, 6) # 5 = canonico XS01 +COST_SWEEP = (0.0, 5.0, 10.0, 20.0, 30.0, 50.0) # bps per gamba TOTALI (fee + slippage) +# capitali dello SLEEVE. Il progetto tiene XS01 al 15% del book -> a book $600 lo sleeve ha $90. +# Si misurano entrambe le letture, perche' e' esattamente qui che nasce il numero "$20k". +SLEEVE_CAPS = (90.0, 300.0, 600.0, 750.0, 2000.0, 5000.0) + + +# ===================================================================================== +# misure +# ===================================================================================== +def sh(s) -> float: + r = np.asarray(pd.Series(s).dropna().values, float) + return float(r.mean() / r.std() * ANN) if len(r) > 30 and r.std() > 0 else 0.0 + + +def dd(s) -> float: + r = np.nan_to_num(np.asarray(pd.Series(s).values, float)) + eq = np.cumprod(1.0 + np.clip(r, -0.99, None)) + return float(np.min(eq / np.maximum.accumulate(eq) - 1.0)) + + +def cagr(s) -> float: + x = pd.Series(s).dropna() + if len(x) < 30: + return 0.0 + yrs = (x.index[-1] - x.index[0]).days / 365.25 + eq = float(np.prod(1.0 + np.clip(x.values, -0.99, None))) + return float(eq ** (1.0 / yrs) - 1.0) if yrs > 0.2 and eq > 0 else 0.0 + + +def split(s): + x = pd.Series(s).dropna() + return x[x.index < HOLDOUT], x[x.index >= HOLDOUT] + + +HDR = f" {'cella':<20}{'ShFULL':>8}{'Sh IS':>8}{'Sh OOS':>8}{'maxDD':>9}{'CAGR':>9}" + + +def row(name, s, extra=""): + ins, out = split(s) + return (f" {name:<20}{sh(s):>8.2f}{sh(ins):>8.2f}{sh(out):>8.2f}" + f"{dd(s)*100:>8.1f}%{cagr(s)*100:>8.1f}%{extra}") + + +# ===================================================================================== +# (A) XS01 congelato, con k e fase parametrici e universo a copertura variabile +# ===================================================================================== +def load_closes(syms, how="inner") -> pd.DataFrame: + cols = {} + for s in syms: + p = RAW / f"hl_{s.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p, columns=["timestamp", "close"]) + cols[s] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + C = pd.concat(cols, axis=1, join=("inner" if how == "inner" else "outer")).sort_index() + return C.dropna() if how == "inner" else C + + +def xs_run(C: pd.DataFrame, k: int, phase: int = 0): + """Replica di sleeves._xsec_returns con k e fase parametrici, tollerante ai simboli con + copertura piu' corta (si valutano solo quelli con storia sufficiente a quella data: senza + questo, un outer-join su 51 alt riduce la finestra comune a ~2/3 e cancella l'in-sample). + Con tutte le colonne valide il percorso e' NUMERICAMENTE IDENTICO all'originale — provato al + passo (0) con max|diff| = 0. + Ritorna (serie netta lente-sleeve, W [n x A], scale [n], dret [n x A]).""" + px = C.values.astype(float) + n, na = px.shape + lookbacks, H, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["mode"], XS_CFG["target_vol"] + disp_pct, minhist = XS_CFG["disp_pct"], XS_CFG["disp_minhist"] + mlb = max(lookbacks) + fin = np.isfinite(px) + prev = np.vstack([np.full(na, np.nan), px[:-1]]) + dret = np.where(fin & np.isfinite(prev), px / prev - 1.0, 0.0) + dret = np.nan_to_num(dret) + W = np.zeros((n, na)) + w = np.zeros(na) + disp_hist = [] + for i in range(n): + if i >= mlb and (i - phase) % H == 0: + ok = fin[i].copy() + for L in lookbacks: + ok &= fin[i - L] + if ok.sum() >= 2 * k: + cols = np.where(ok)[0] + rLs = [px[i, cols] / px[i - L, cols] - 1.0 for L in lookbacks] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct) + if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf) + if disp_i >= thr: + score = np.zeros(len(cols)); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + score /= cnt + order = np.argsort(score) + w = np.zeros(na) + lo, hi = cols[order[:k]], cols[order[-k:]] + if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k + else: w[lo] = 0.5 / k; w[hi] = -0.5 / k + else: + w = np.zeros(na) + disp_hist.append(disp_i) + W[i] = w + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n); turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (0.001 / 2.0) + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + return pd.Series(s.values * scale, index=C.index), W, scale, dret + + +def xs_effective_weights(W: np.ndarray, scale: np.ndarray) -> np.ndarray: + """Peso EFFETTIVO tenuto durante la barra i = W[i-1]*scale[i]. Entrambi noti a close[i-1] + (scale usa una rolling std gia' shiftata) -> causale.""" + Weff = np.zeros_like(W) + Weff[1:] = W[:-1] * scale[1:, None] + return Weff + + +# ===================================================================================== +# (B) XSR01 congelato, con k parametrico +# ===================================================================================== +def xsr_positions(P: pd.DataFrame, k: int | None) -> pd.DataFrame: + """Q = posizioni demeanate cross-sezionalmente (meccanismo congelato). + Se k e' dato: tiene le k piu' positive e le k piu' negative di ogni riga, azzera il resto e + RI-demeana sulle sopravvissute (resta dollar-neutral).""" + Q = P.sub(P.mean(axis=1), axis=0) + if k is None: + return Q + V = Q.to_numpy(float) + out = np.zeros_like(V) + for i in range(V.shape[0]): + v = V[i] + m = np.isfinite(v) + if m.sum() < 2 * k or not np.any(v[m] != 0.0): + continue + idx = np.where(m)[0] + order = idx[np.argsort(v[idx])] + keep = np.concatenate([order[:k], order[-k:]]) + r = np.zeros_like(v) + r[keep] = v[keep] + r[keep] -= r[keep].mean() + out[i] = r + return pd.DataFrame(out, index=P.index, columns=P.columns) + + +def xsr_legs(Q: pd.DataFrame, S: pd.DataFrame) -> pd.DataFrame: + """Matrice [data x gamba] dei pnl per gamba, nella lente PUBBLICATA (fee su 2 gambe per + coppia: alt + copertura BTC dedicata).""" + return pd.DataFrame({c: pnl(Q[c].to_numpy(float), S[c].to_numpy(float)) for c in Q.columns}, + index=Q.index) + + +def xsr_basket(Q: pd.DataFrame, S: pd.DataFrame, norm) -> pd.Series: + """norm = 'perrow' riproduce ESATTAMENTE basket_from_positions (media per-riga sui simboli + con dato: il numero di simboli cambia nel tempo). norm = intero -> normalizzazione COSTANTE, + l'unica che rende confrontabili celle con k diverso.""" + M = xsr_legs(Q, S) + if norm == "perrow": + return M.mean(axis=1).dropna() + return (M.fillna(0.0).sum(axis=1) / float(norm)).dropna() + + +# ===================================================================================== +# (C) simulatore di libro con MIN-ORDER (lente eseguibile, comune alle due famiglie) +# ===================================================================================== +def sim_book(Weff: np.ndarray, R: np.ndarray, idx, r_hedge=None, capital=None, + min_order=0.0, cost_bps=5.0): + """Weff[i] = pesi TARGET da tenere durante la barra i (decisi a close[i-1]). + R[i] = ritorni per gamba nella barra i. r_hedge = ritorno della gamba di copertura (si tiene + -sum(w)); None = libro gia' neutrale fra le gambe. min_order>0: una variazione di NOZIONALE + sotto la soglia NON si esegue (si tiene la vecchia). cost_bps = costo per unita' di turnover.""" + n, na = Weff.shape + c = cost_bps / 1e4 + held = np.zeros(na) + hedge = 0.0 + net = np.zeros(n) + n_fill = n_skip = 0 + tickets, gross = [], [] + for i in range(n): + tgt = Weff[i] + d_not = np.abs(tgt - held) * (capital or 1.0) + if min_order > 0.0 and capital: + move = d_not >= min_order + else: + move = d_not > 1e-12 + new = np.where(move, tgt, held) + n_fill += int(move.sum()) + n_skip += int(((~move) & (d_not > 1e-12)).sum()) + tickets.extend(d_not[move].tolist()) + turn = float(np.abs(new - held).sum()) + r = float(np.dot(new, R[i])) + if r_hedge is not None: + h = -float(new.sum()) + if min_order > 0.0 and capital and abs(h - hedge) * capital < min_order: + h = hedge + turn += abs(h - hedge) + hedge = h + r += hedge * float(r_hedge[i]) + net[i] = r - c * turn + held = new + gross.append(float(np.abs(new).sum()) + abs(hedge)) + return pd.Series(net, index=idx), dict( + n_fill=n_fill, n_skip=n_skip, + exec_share=(n_fill / (n_fill + n_skip)) if (n_fill + n_skip) else 1.0, + med_ticket=float(np.median(tickets)) if tickets else 0.0, + med_gross=float(np.median(gross)), + orders_py=(n_fill / (len(idx) / 365.25)) if len(idx) else 0.0) + + +# ===================================================================================== +# (D) ampiezza +# ===================================================================================== +def breadth(legs: pd.DataFrame, n_simult: int, min_overlap: int = 30): + """Due misure, perche' una sola mente. + (1) N_eff = N/(1+(N-1)*rbar), N = gambe SIMULTANEE, rbar = correlazione media fra i contributi + di due gambe CONDIZIONATA a essere entrambe attive. E' la formula pubblicata dal progetto + (4.5 -> 37.4), qui condizionata: senza condizionare, concentrando si conterebbe come + 'ampiezza' la ROTAZIONE nel tempo, che nello Sharpe sta gia' dentro sqrt(T). + INSTABILE per costruzione: rbar leggermente NEGATIVO (normale in un libro dollar-neutral) + la fa esplodere. Si legge insieme a rbar, mai da sola. + (2) N_hhi = numero effettivo di gambe che PORTANO il rischio, giorno per giorno: + 1/sum(share_i^2) sulle quote |contributo|, mediato sui giorni attivi. Limitato da N, + stabile, e non dipende dal segno delle correlazioni.""" + M = legs.to_numpy(float) + M = np.nan_to_num(M) + act = M != 0.0 + rs = [] + for a in range(M.shape[1]): + for b in range(a + 1, M.shape[1]): + m = act[:, a] & act[:, b] + if m.sum() < min_overlap: + continue + xa, xb = M[m, a], M[m, b] + if xa.std() > 0 and xb.std() > 0: + rs.append(float(np.corrcoef(xa, xb)[0, 1])) + rbar = float(np.mean(rs)) if rs else float("nan") + N = float(n_simult) + den = 1.0 + (N - 1) * rbar if rs else float("nan") + neff = (N / den) if (rs and den > 1e-3) else float("nan") + hh = [] + for i in range(M.shape[0]): + v = np.abs(M[i][act[i]]) + t = v.sum() + if t > 0 and len(v) > 0: + sHH = float(np.sum((v / t) ** 2)) + if sHH > 0: + hh.append(1.0 / sHH) + return neff, rbar, (float(np.mean(hh)) if hh else float("nan")), len(rs) + + +# ===================================================================================== +def main() -> None: + print("=" * 108) + print(" XS-LITE — una versione CONCENTRATA degli edge cross-sectional e' eseguibile a $600-5.000?") + print("=" * 108) + + maj19 = list(XS_UNIVERSE) + all51 = sorted({p.stem.replace("hl_", "").replace("_1d", "").upper() + for p in RAW.glob("hl_*_1d.parquet")}) + + # ---------------------------------------------------------------- (0) repliche + print("\n" + "-" * 108) + print(" (0) REPLICA DEI MECCANISMI CONGELATI — prima di ogni delta") + print("-" * 108) + C19 = load_closes(maj19, "inner") + C51 = load_closes(all51, "outer") + off, _, _, _ = xs_run(C19, k=XS_CFG["k"], phase=0) + ref = _xsec_returns() + J = pd.concat({"mio": off, "sleeve": ref}, axis=1, join="inner").dropna() + d_xs = float(np.max(np.abs(J["mio"] - J["sleeve"]))) + print(f" XS01 k=5 fase 0 vs sleeves._xsec_returns : max|diff| = {d_xs:.3e} " + f"({len(J)} barre) {'OK' if d_xs < 1e-12 else 'DIVERGE'}") + + P, S = pair_frames() + Qfull = xsr_positions(P, None) + xsr_perrow = xsr_basket(Qfull, S, "perrow") + xsr_ref = basket_from_positions(P, S, cols=None, demean=True) + J2 = pd.concat({"mio": xsr_perrow, "ref": xsr_ref}, axis=1, join="inner").dropna() + d_xsr = float(np.max(np.abs(J2["mio"] - J2["ref"]))) + print(f" XSR01 pieno vs basket_from_positions(demean): max|diff| = {d_xsr:.3e} " + f"({len(J2)} barre) {'OK' if d_xsr < 1e-12 else 'DIVERGE'}") + xsr_const = xsr_basket(Qfull, S, P.shape[1]) + print(f" XSR01 pieno, lente pubblicata (media per-riga sui simboli CON DATO): " + f"Sharpe {sh(xsr_perrow):.2f} maxDD {dd(xsr_perrow)*100:.1f}% (pubblicati 1.82 / -2.6%)") + print(f" XSR01 pieno, normalizzazione COSTANTE 1/{P.shape[1]} : " + f"Sharpe {sh(xsr_const):.2f} maxDD {dd(xsr_const)*100:.1f}%") + print(" ^ le due lenti coincidono qui (la copertura e' quasi piena); da qui in poi si usa la") + print(" COSTANTE, l'unica che rende confrontabili celle con k diverso, e i numeri XSR01 vanno") + print(f" letti contro {sh(xsr_const):.2f}.") + disc = pd.Timestamp("2026-07-25", tz="UTC") # giorno di scoperta/inception monitor + xsr_disc = xsr_perrow[xsr_perrow.index <= disc] + xsr_fwd = xsr_perrow[xsr_perrow.index > disc] + print(f" XSR01 sulla FINESTRA DI SCOPERTA (<= {disc.date()}, {len(xsr_disc)} barre): " + f"Sharpe {sh(xsr_disc):.2f} maxDD {dd(xsr_disc)*100:.1f}%") + print(" ⚠ IL NUMERO PUBBLICATO (1.82) NON SI RIPRODUCE OGGI, e non e' la mia implementazione:") + print(" le due repliche qui sopra sono bit-exact col codice del progetto. Sulla finestra") + print(f" IDENTICA a quella di scoperta la lente 'paniere' da' {sh(xsr_disc):.2f} e la lente 'libro'") + print(" (fee 1 gamba alt + copertura BTC NETTA, convenzione di paper_xsr) da' 2.23: l'1.82 non") + print(" e' nessuna delle due. Spiegazione piu' probabile: data/raw e' gitignored e il cron") + print(" riscrive i parquet HL -> stesso codice, dati diversi (identico allo scoperto del 07/08") + print(" su GTAA/ADJUSTED_LAST). NON l'ho inseguito: tutte le mie conclusioni sono confronti") + print(" RELATIVI dentro UNA lente. Ma tocca un numero pubblicato e il gate del 23/10.") + print(f" XSR01 dal 25/07 a oggi (forward, {len(xsr_fwd)} barre, stessa lente): " + f"ritorno {(np.prod(1+xsr_fwd.values)-1)*100:+.2f}% " + f"Sharpe {sh(xsr_fwd) if len(xsr_fwd) > 30 else float('nan'):.2f} " + f"{'(campione troppo corto per uno Sharpe: si legge il ritorno)' if len(xsr_fwd) <= 30 else ''}") + print(" ^ la differenza fra 1.82 e il numero FULL di oggi NON e' una lente diversa (le repliche") + print(" sono bit-exact): e' un mese in piu' di dati. Il gate pre-registrato resta il 23/10.") + print(f" finestra MAJ19: {C19.index[0].date()} -> {C19.index[-1].date()} ({len(C19)} barre); " + f"in-sample = pre {HOLDOUT.date()} = {int((C19.index < HOLDOUT).sum())} barre") + cov51 = np.isfinite(C51.values).sum(axis=1) + print(f" finestra ALL51: {C51.index[0].date()} -> {C51.index[-1].date()} ({len(C51)} barre); " + f"simboli con dato: da {cov51.min()} a {cov51.max()} (outer-join: l'inner-join") + print(" taglierebbe la finestra a 627 barre e CANCELLEREBBE l'in-sample).") + print(" ⚠ l'in-sample e' UN ANNO SOLO e monoregime: ogni Sh IS qui sotto va letto cosi'.") + + # ---------------------------------------------------------------- (1) XS-LITE + print("\n" + "-" * 108) + print(" (1) FAMIGLIA A — XS-LITE: XS01 congelato, varia solo k (gambe per lato). k=5 = canonico") + print("-" * 108) + print(HDR + f"{'N_eff':>8}{'rbar':>8}{'N_hhi':>8}") + xs_series, xs_cells = {}, {} + for uni, Cx in (("MAJ19", C19), ("ALL51", C51)): + for k in KS: + s, W, sc, dr = xs_run(Cx, k=k, phase=0) + Weff = xs_effective_weights(W, sc) + legpnl = pd.DataFrame(Weff * dr, index=Cx.index, columns=Cx.columns) + ne, rbar, nhhi, _ = breadth(legpnl, 2 * k) + nm = f"{uni} k={k}" + ("*" if (uni == "MAJ19" and k == 5) else "") + xs_series[nm] = s + xs_cells[nm] = dict(uni=uni, k=k, C=Cx, W=W, scale=sc, dret=dr, + neff=ne, rbar=rbar, nhhi=nhhi) + print(row(nm, s, f"{ne:>8.1f}{rbar:>8.3f}{nhhi:>8.1f}")) + print(" (*) = configurazione canonica del sleeve XS01") + + # ---------------------------------------------------------------- (2) XSR-LITE + print("\n" + "-" * 108) + print(" (2) FAMIGLIA B — XSR-LITE: XSR01 congelato (demean), varia solo k. 'pieno' = tutte le gambe") + print("-" * 108) + maj18 = [s for s in maj19 if s != BASE] + Pm = P[[c for c in maj18 if c in P.columns]] + Sm = S[[c for c in maj18 if c in S.columns]] + print(f" ALL50: {P.shape[1]} gambe MAJ18: {Pm.shape[1]} gambe") + print(HDR + f"{'N_eff':>8}{'rbar':>8}{'N_hhi':>8}") + xsr_series, xsr_cells = {}, {} + for uni, (Pu, Su) in (("ALL50", (P, S)), ("MAJ18", (Pm, Sm))): + for k in list(KS) + [None]: + if k is not None and 2 * k > Pu.shape[1]: + continue + Q = xsr_positions(Pu, k) + norm = (2 * k) if k is not None else Pu.shape[1] + s = xsr_basket(Q, Su, norm) + legs = xsr_legs(Q, Su) / float(norm) + ne, rbar, nhhi, _ = breadth(legs, norm) + nm = f"{uni} k={k if k else 'pieno'}" + ("*" if (uni == "ALL50" and k is None) else "") + xsr_series[nm] = s + xsr_cells[nm] = dict(uni=uni, k=k, Q=Q, S=Su, norm=norm, + neff=ne, rbar=rbar, nhhi=nhhi) + print(row(nm, s, f"{ne:>8.1f}{rbar:>8.3f}{nhhi:>8.1f}")) + print(" (*) = XSR01 pieno (50 gambe demeanate), qui a normalizzazione costante") + + # banda di fase calcolata PRIMA della curva: la stima onesta di uno sleeve ancorato e' la + # mediana della banda, quindi e' quella che deve stare nella curva (la fase 0 e' un max-of-10). + base_by_phase = {ph: xs_run(C19, k=5, phase=ph)[0] for ph in range(XS_CFG["H"])} + phase_med, phase_rows = {}, {} + for k in KS: + by_ph = {ph: (base_by_phase[ph] if k == 5 else xs_run(C19, k=k, phase=ph)[0]) + for ph in range(XS_CFG["H"])} + vals = np.array([sh(by_ph[p_]) for p_ in range(XS_CFG["H"])]) + diffs = np.array([sh(by_ph[p_]) - sh(base_by_phase[p_]) for p_ in range(XS_CFG["H"])]) + phase_med[k] = float(np.median(vals)) + phase_rows[k] = (vals, diffs) + + # ---------------------------------------------------------------- (3) curva ampiezza + print("\n" + "-" * 108) + print(" (3) LA CURVA AMPIEZZA-vs-k — la domanda centrale: quanto ne resta concentrando?") + print("-" * 108) + sh_xs_star = sh(xs_series["MAJ19 k=5*"]) + sh_xsr_star = sh(xsr_series["ALL50 k=pieno*"]) + print(f" {'k':>4}{'gambe':>7} {'--------- XS-LITE MAJ19 ---------':>34} " + f"{'--------- XSR-LITE ALL50 --------':>34}") + print(f" {'':>4}{'':>7} {'N_hhi':>9}{'N_eff':>9}{'Sh f0':>8}{'Sh MED':>8}{'MED/MED*':>10} " + f"{'N_hhi':>9}{'N_eff':>9}{'Sh':>8}{'Sh/Sh*':>9}") + med5 = phase_med[5] + for k in KS: + an = f"MAJ19 k={k}" + ("*" if k == 5 else "") + bn = f"ALL50 k={k}" + ca, cb = xs_cells[an], xsr_cells[bn] + sa, sb = xs_series[an], xsr_series[bn] + print(f" {k:>4}{2*k:>7} {ca['nhhi']:>9.1f}{ca['neff']:>9.1f}{sh(sa):>8.2f}" + f"{phase_med[k]:>8.2f}{(phase_med[k]/med5 if med5 else 0):>10.0%} " + f"{cb['nhhi']:>9.1f}{cb['neff']:>9.1f}{sh(sb):>8.2f}" + f"{(sh(sb)/sh_xsr_star if sh_xsr_star else 0):>9.0%}") + cp = xsr_cells["ALL50 k=pieno*"] + print(f" {'pieno':>4}{cp['norm']:>7} {'':>9}{'':>9}{'':>8}{'':>8}{'':>10} " + f"{cp['nhhi']:>9.1f}{cp['neff']:>9.1f}{sh_xsr_star:>8.2f}{1.0:>9.0%}") + print(" 'Sh MED' = mediana delle 10 fasi d'ancora di XS01 = la stima ONESTA (la fase 0 e' un") + print(" max-of-10 non dichiarato). XSR01 e' giornaliero: non ha fase, la sua colonna e' unica.") + print(" N_hhi = gambe che PORTANO il rischio (stabile). N_eff = formula pubblicata, esplode") + print(" quando rbar e' negativo: si legge solo insieme a rbar, mai da sola.") + + # ---------------------------------------------------------------- (4) banda d'ancora + print("\n" + "-" * 108) + print(" (4) BANDA D'ANCORA — le 10 fasi del ciclo H=10 di XS01 (XSR01 e' giornaliero: nessuna") + print(" fase). Stima onesta = MEDIANA della banda, non la fase 0 con cui e' stato scoperto.") + print("-" * 108) + print(f" {'cella':<14}{'fase0':>8}{'mediana':>9}{'p10':>8}{'p90':>8}{'pctl f0':>9}" + f" d vs k=5: mediana APPAIATA (fasi >0)") + for k in KS: + vals, diffs = phase_rows[k] + pctl = float((vals <= vals[0]).mean() * 100) + print(f" MAJ19 k={k:<6}{vals[0]:>8.2f}{phase_med[k]:>9.2f}{np.percentile(vals,10):>8.2f}" + f"{np.percentile(vals,90):>8.2f}{pctl:>8.0f}% {np.median(diffs):>+8.3f}" + f" {int((diffs>0).sum())}/10") + print(" 'd vs k=5' = mediana delle DIFFERENZE APPAIATE per fase (mai differenza di mediane).") + + # ---------------------------------------------------------------- (5) eseguibilita' + print("\n" + "-" * 108) + print(" (5) ESEGUIBILITA' — contabilita' min-order per gamba, lente 'libro'") + print(" Il capitale in colonna e' quello dello SLEEVE. XS01 sta al 15% del book:") + print(" sleeve $90 = book $600, sleeve $300 = book $2.000, sleeve $750 = book $5.000,") + print(" sleeve $3.000 = book $20.000 (la soglia dichiarata dal progetto).") + print("-" * 108) + print(f" {'cella':<13}{'sleeve$':>8}{'minord':>7}{'Sh model':>10}{'Sh reale':>10}" + f"{'haircut':>9}{'gambe eseg':>12}{'tick med$':>11}{'ord/anno':>10}" + f"{'lordo/lordo*':>14}") + print(" ⚠ 'lordo/lordo*' = lordo mediano ESEGUITO / lordo mediano modellato. Sotto ~0.8 il") + print(" libro ha smesso di seguire il proprio target, e allora lo Sharpe NON e' la lettura") + print(" giusta (puo' perfino MIGLIORARE): e' il null del de-levering in veste di 'meno") + print(" costi'. Lezione GTAA-banda 27/07, riapplicata qui.") + + def xs_book_inputs(nm): + cell = xs_cells[nm] + return xs_effective_weights(cell["W"], cell["scale"]), cell["dret"], cell["C"].index, None + + def xsr_book_inputs(nm): + cell = xsr_cells[nm] + Q, Su, norm = cell["Q"], cell["S"], cell["norm"] + Wt = np.nan_to_num(Q.to_numpy(float)) / float(norm) + Weff = np.zeros_like(Wt); Weff[1:] = Wt[:-1] + rb = load_hl(BASE).reindex(Q.index).pct_change().fillna(0.0).to_numpy(float) + Ralt = np.zeros_like(Wt) + for j, c in enumerate(Q.columns): + Ralt[:, j] = np.nan_to_num(Su[c].to_numpy(float)) + rb + return Weff, Ralt, Q.index, rb + + for tag, names, getter in (("XS", ["MAJ19 k=1", "MAJ19 k=2", "MAJ19 k=3", "MAJ19 k=5*"], + xs_book_inputs), + ("XSR", ["ALL50 k=1", "ALL50 k=2", "ALL50 k=3", "ALL50 k=pieno*"], + xsr_book_inputs)): + for nm in names: + Weff, R, idx, rh = getter(nm) + base, dg0 = sim_book(Weff, R, idx, r_hedge=rh, capital=None, min_order=0.0) + for cap in SLEEVE_CAPS: + for mo in (MIN_ORDER_HL,): + real, dg = sim_book(Weff, R, idx, r_hedge=rh, capital=cap, min_order=mo) + lbl = f"{tag} {nm.split()[1]}" + trk = (dg["med_gross"] / dg0["med_gross"]) if dg0["med_gross"] > 0 else float("nan") + print(f" {lbl:<13}{cap:>8.0f}{mo:>7.0f}{sh(base):>10.2f}{sh(real):>10.2f}" + f"{sh(base)-sh(real):>9.2f}{dg['exec_share']*100:>11.0f}%" + f"{dg['med_ticket']:>11.0f}{dg['orders_py']:>10.0f}" + f"{trk:>12.2f}{' ⚠' if trk < 0.8 else ' '}") + print() + print(" 'gambe eseg' = quota delle VARIAZIONI di posizione sopra il min-order (la diagnostica") + print(" su cui si basa la soglia '$20k'). 'haircut' = quanto ne costa in Sharpe. Sono cose") + print(" DIVERSE: il turnover saltato e' la deriva del vol-target, non il segnale.") + print(" Con min-order $5 invece di $10 (Deribit invece di HL) l'haircut si dimezza per") + print(" costruzione; il caso $10 e' quello vero per Hyperliquid ed e' quello riportato.") + + # ---------------------------------------------------------------- (6) slippage + print("\n" + "-" * 108) + print(" (6) SLIPPAGE — costo TOTALE per gamba (i 5 bps di fee taker sono gia' dentro).") + print(" A che costo muore l'edge? E' il rischio #1 dichiarato di XSR01.") + print("-" * 108) + print(f" {'cella':<16}" + "".join(f"{int(c):>10}bps" for c in COST_SWEEP) + f"{'break-even':>13}") + + def cost_curve(label, Weff, R, idx, rh): + vals = [] + for c in COST_SWEEP: + s, _ = sim_book(Weff, R, idx, r_hedge=rh, capital=None, min_order=0.0, cost_bps=c) + vals.append(sh(s)) + be = None + for i in range(1, len(COST_SWEEP)): + if vals[i - 1] > 0 >= vals[i]: + x0, x1, y0, y1 = COST_SWEEP[i-1], COST_SWEEP[i], vals[i-1], vals[i] + be = x0 + (x1 - x0) * y0 / (y0 - y1) + break + print(f" {label:<16}" + "".join(f"{v:>13.2f}" for v in vals) + + (f"{be:>13.0f}" if be else f"{'>'+str(int(COST_SWEEP[-1])):>13}")) + return be + + be = {} + for nm in ("MAJ19 k=1", "MAJ19 k=2", "MAJ19 k=3", "MAJ19 k=5*"): + be["XS " + nm] = cost_curve("XS " + nm.split()[1], *xs_book_inputs(nm)) + for nm in ("ALL50 k=1", "ALL50 k=2", "ALL50 k=3", "ALL50 k=pieno*"): + be["XSR " + nm] = cost_curve("XSR " + nm.split()[1], *xsr_book_inputs(nm)) + + # ---------------------------------------------------------------- (7) selezione + DSR + print("\n" + "-" * 108) + print(" (7) SELEZIONE IN-SAMPLE-ONLY (mai sull'hold-out) + DEFLATED SHARPE") + print("-" * 108) + allcells = {**{f"XS {n}": s for n, s in xs_series.items()}, + **{f"XSR {n}": s for n, s in xsr_series.items()}} + ins_rank = sorted(allcells.items(), key=lambda kv: -sh(split(kv[1])[0])) + print(f" celle valutate: {len(allcells)} (2 famiglie x 2 universi x 6 k, + i 2 'pieno' di") + print(" XSR01, meno le celle impossibili 2k>gambe). Lookback NON riaperto -> nessun trial in piu'.") + print(f"\n {'rank':>5} {'cella':<20}{'Sh IS':>8}{'Sh OOS':>8}{'Sh FULL':>9}") + for i, (n, s) in enumerate(ins_rank[:8], 1): + ins, out = split(s) + print(f" {i:>5} {n:<20}{sh(ins):>8.2f}{sh(out):>8.2f}{sh(s):>9.2f}") + best_name, best_s = ins_rank[0] + all_sr = [sh(s) for s in allcells.values()] + print(f"\n cella scelta AL BUIO (solo in-sample): {best_name}") + for mult, label in ((1, "trial dichiarati = celle"), + (6, "conteggio conservativo x6 (se avessi riaperto il lookback)")): + pad = all_sr * mult + d, s0 = A.deflated_sharpe(sh(best_s), pad, pd.Series(best_s).dropna()) + print(f" DSR a N={len(pad):<4} ({label}): {d:.3f} " + f"{'PASS' if d >= 0.95 else 'FAIL'} (Sharpe-soglia del null {s0:.2f})") + conc = {n: s for n, s in allcells.items() if any(f" k={j}" in n for j in (1, 2, 3))} + cbest_name, cbest_s = max(conc.items(), key=lambda kv: sh(split(kv[1])[0])) + d2, _ = A.deflated_sharpe(sh(cbest_s), all_sr, pd.Series(cbest_s).dropna()) + print(f" cella CONCENTRATA (k<=3, cioe' <=6 gambe) scelta al buio: {cbest_name} " + f"Sh IS {sh(split(cbest_s)[0]):.2f} OOS {sh(split(cbest_s)[1]):.2f} FULL {sh(cbest_s):.2f}") + print(f" DSR della concentrata a N={len(all_sr)}: {d2:.3f} {'PASS' if d2 >= 0.95 else 'FAIL'}") + + # ---------------------------------------------------------------- (8) gate marginali + print("\n" + "-" * 108) + print(" (8) GATE MARGINALE vs TP01 + RIDONDANZA vs gli sleeve che gia' esistono") + print("-" * 108) + order = [best_name, cbest_name, "XS MAJ19 k=2", "XS MAJ19 k=3", "XS MAJ19 k=5*", + "XSR ALL50 k=2", "XSR ALL50 k=3", "XSR ALL50 k=pieno*"] + to_score = {} + for n in order: + if n in allcells and n not in to_score: + to_score[n] = allcells[n] + for n, s in to_score.items(): + ss = pd.Series(s).dropna(); ss.index = pd.to_datetime(ss.index, utc=True) + m = A.marginal_vs_tp01(ss) + b = m.get("blends", {}).get("w25", {}) + print(f" {n:<20} {str(m.get('marginal_verdict')):<10} corr {str(m.get('corr_full')):>7}" + f" uplift w25 full {str(b.get('uplift_full')):>7} hold {str(b.get('uplift_hold')):>7}" + f" robust={m.get('robust_oos')} noise_null={m.get('beats_noise_null')}" + f" is_edge={m.get('has_insample_edge')} hedge={m.get('is_hedge')}") + print("\n correlazione col materiale gia' in casa (un proxy eseguibile DEVE assomigliare al suo") + print(" originale: qui una corr ALTA e' l'obiettivo, non un difetto)") + ref_map = {"XS01 (sleeve)": ref, "XSR01 (pieno)": xsr_const, "TP01": A.tp01_baseline_daily()} + print(f" {'cella':<20}" + "".join(f"{k:>16}" for k in ref_map)) + for n, s in to_score.items(): + line = f" {n:<20}" + for _, r in ref_map.items(): + Jc = pd.concat({"a": pd.Series(s).dropna(), "b": r}, axis=1, join="inner").dropna() + line += f"{(Jc['a'].corr(Jc['b']) if len(Jc) > 30 else float('nan')):>16.3f}" + print(line) + print(" ⚠ corr a SKH01/VRP01/GTAA01 NON misurata: SKH01 richiede i 5m di DUE asset e il brief") + print(" vieta di caricarli insieme. Prior del progetto (25/07): XSR01 sta a |0.001|-|0.071|") + print(" da tutti e 5 gli sleeve; un suo sottoinsieme non puo' esserne lontano.") + + # ---------------------------------------------------------------- (9) null del de-levering + print("\n" + "-" * 108) + print(" (9) NULL DEL DE-LEVERING — obbligatorio su ogni confronto di maxDD (5 occorrenze note)") + print(" Domanda: esiste una LEVA del canonico che dia lo STESSO maxDD della variante?") + print(" Se a quel maxDD il canonico ha Sharpe >= alla variante, la variante non aggiunge.") + print("-" * 108) + def lev_for_dd(canon: pd.Series, target_abs: float, lo=0.01, hi=5.0): + """Leva k tale che |maxDD(canon*k)| = target. |maxDD| e' monotono crescente in k.""" + if abs(dd(canon * hi)) < target_abs: + return None # nemmeno a leva 5x si arriva al DD + for _ in range(60): + mid = 0.5 * (lo + hi) + if abs(dd(canon * mid)) < target_abs: lo = mid + else: hi = mid + return 0.5 * (lo + hi) + + def delever_null(variants: dict, canon: pd.Series, label: str): + print(f" {label:<22}{'maxDD':>9}{'Sh':>8} {'k_leva canonico':>16}{'Sh canonico':>13} esito") + for nm, s in variants.items(): + kl = lev_for_dd(canon, abs(dd(s))) + if kl is None: + print(f" {nm:<22}{dd(s)*100:>8.1f}%{sh(s):>8.2f} {'n.d. (>5x)':>16}" + f"{'':>13} maxDD irraggiungibile levando il canonico") + continue + sc_ = sh(canon * kl) + print(f" {nm:<22}{dd(s)*100:>8.1f}%{sh(s):>8.2f} {kl:>16.3f}{sc_:>13.2f} " + f"{'il canonico DOMINA' if sc_ >= sh(s) else 'la variante REGGE'}") + delever_null({n: xs_series[n] for n in xs_series if n.startswith("MAJ19") and "k=5" not in n}, + xs_series["MAJ19 k=5*"], "XS-LITE vs canonico") + print() + delever_null({n: xsr_series[n] for n in xsr_series if n.startswith("ALL50") and "pieno" not in n}, + xsr_series["ALL50 k=pieno*"], "XSR-LITE vs pieno") + + # ---------------------------------------------------------------- (10) implausible + causalita' + print("\n" + "-" * 108) + print(" (10) implausible_sharpe + CAUSALITA' (prefisso troncato + decadimento con lag)") + print("-" * 108) + for n in dict.fromkeys([best_name, cbest_name, "XS MAJ19 k=2", "XS MAJ19 k=5*", + "XSR ALL50 k=2"]): + if n not in allcells: + continue + ss = pd.Series(allcells[n]).dropna(); ss.index = pd.to_datetime(ss.index, utc=True) + r = A.implausible_sharpe(ss) + print(f" {n:<20} implausible={str(r['implausible']):<6} Sh {r.get('sharpe',0):>5.2f} " + f"perdite su barre ATTIVE {r.get('loss_frac',float('nan'))*100:>5.1f}% " + f"attive {r.get('active_frac',0)*100:>3.0f}% {'; '.join(r['reasons'])[:38]}") + cut = int(len(C19) * 0.85) + Wf = xs_run(C19, k=2, phase=0)[1] + Wp = xs_run(C19.iloc[:cut], k=2, phase=0)[1] + dmax = float(np.max(np.abs(Wp[cut - 80:cut] - Wf[cut - 80:cut]))) + print(f" XS k=2: pesi ricalcolati su prefisso troncato (cut={cut}) vs pieno, ultime 80 barre: " + f"max|diff| = {dmax:.2e} {'CAUSALE' if dmax < 1e-12 else 'LOOK-AHEAD'}") + Wq = xs_run(C19, k=2, phase=0) + Weff2 = xs_effective_weights(Wq[1], Wq[2]) + lags = [] + for lag in (0, 1, 2, 3, 5): + Wl = np.zeros_like(Weff2) + if lag: Wl[lag:] = Weff2[:-lag] + else: Wl = Weff2 + s, _ = sim_book(Wl, Wq[3], C19.index) + lags.append(sh(s)) + print(" XS k=2 decadimento con lag (0/1/2/3/5 giorni): " + + " / ".join(f"{v:.2f}" for v in lags) + + " (dolce = segnale lento, non firma di look-ahead)") + + # ---------------------------------------------------------------- (11) sintesi + print("\n" + "=" * 108) + print(" SINTESI — quanto costa concentrare, e dove sta davvero il muro") + print("=" * 108) + k2, k5 = xs_series["MAJ19 k=2"], xs_series["MAJ19 k=5*"] + print(f" XS-LITE k=2 (4 gambe): Sh fase0 {sh(k2):.2f} / fase-MEDIANA {phase_med[2]:.2f}" + f" vs canonico k=5 (10 gambe): {sh(k5):.2f} / {phase_med[5]:.2f}") + print(f" XSR-LITE k=2 (4 gambe): Sh {sh(xsr_series['ALL50 k=2']):.2f} " + f"(IS {sh(split(xsr_series['ALL50 k=2'])[0]):.2f}) vs pieno " + f"{sh_xsr_star:.2f} (IS {sh(split(xsr_series['ALL50 k=pieno*'])[0]):.2f})") + def _be(x): + return f"{x:.0f} bps" if x else f">{int(COST_SWEEP[-1])} bps" + print(f" break-even di costo per gamba: XS k=2 {_be(be.get('XS MAJ19 k=2'))}, " + f"XS k=5 {_be(be.get('XS MAJ19 k=5*'))}, XSR k=2 {_be(be.get('XSR ALL50 k=2'))}, " + f"XSR pieno {_be(be.get('XSR ALL50 k=pieno*'))}") + print("=" * 108) + + +if __name__ == "__main__": + main()