#!/usr/bin/env python """r0702_regime_speed — VELOCITÀ DEL TREND CONDIZIONATA DAL REGIME DI VOL (2026-07-02). DOMANDA: TP01 media TRE orizzonti TSMOM (30/90/180g) a PESI FISSI. Condizionare i PESI TRA GLI ORIZZONTI (la velocità del segnale, NON la leva — l'overlay DVOL sul vol-target è già stato scartato il 2026-06-26) al regime di volatilità migliora il fixed-blend canonico? Ipotesi A: alta vol → trend più veloci → più peso all'orizzonte corto (hv_fast). Ipotesi B: il contrario (hv_slow). METODO (onesto): * TSMOM per orizzonte separato, long-flat, vol-target 20% / cap 2x come il canonico. Sanity: pesi fissi 1/3-1/3-1/3 deve riprodurre il baseline TP01 (stesso code-path). * REGIME = percentile ESPANDENTE CAUSALE (rank del valore di oggi nella storia fino a oggi inclusa, min 365 osservazioni) di DUE misure: realized vol 30g (storia 2019+) e DVOL Deribit (dal 2021-03, allineato causale via al.dvol / merge_asof backward su epoca ms esplicita). Dove il percentile non è ancora definito → pesi EQUAL (canonico). * FAMIGLIA via al.study_family_honest (selezione IN-SAMPLE + deflated Sharpe automatici): griglia = misura {rv, dvol} × soglia {0.60, 0.75} × mappa {hv_fast, hv_slow} × blend {hard, linear} = 16 celle (UNA famiglia sola: il DSR conta TUTTI i trial). * ASTICELLA: il candidato è quasi-TP01 (corr ~1) → il criterio NON è earns_slot ma la DOMINANZA del fixed-blend canonico: Sharpe FULL e HOLD >= canonico su BTC, ETH e 50/50, uplift positivo a più date di taglio (2023/2024/2025), DSR >= 0.95. * CONTROLLO NULL: 300 draw di PESI FISSI casuali (Dirichlet) sui 3 orizzonti — il regime-conditioning deve battere il ~p90 del null, altrimenti è rumore di pesatura. * Causalità: percentili espandenti (mai full-sample), eval_weights shifta la posizione, al.causality_ok sulla cella scelta; niente .view("int64") su indici tz-aware. Run: uv run python scripts/research/r0702_regime_speed.py """ from __future__ import annotations import bisect import sys import numpy as np import pandas as pd sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al # noqa: E402 HORIZONS_D = (30, 90, 180) FAST_W = np.array([0.65, 0.25, 0.10]) # tilt forte sull'orizzonte corto SLOW_W = np.array([0.10, 0.25, 0.65]) # tilt forte sull'orizzonte lungo EQ_W = np.array([1 / 3, 1 / 3, 1 / 3]) # canonico TP01 MIN_REGIME_OBS = 365 # storia minima prima di fidarsi del percentile RAMP = 0.25 # semi-larghezza del blend lineare attorno alla soglia CUTS = ("2023-01-01", "2024-01-01", "2025-01-01") NULL_DRAWS = 300 SEED = 20260702 # --------------------------------------------------------------------------- # Blocchi causali # --------------------------------------------------------------------------- def horizon_signs(c: np.ndarray, bpd: int) -> np.ndarray: """S[i, j] = sign(close[i]/close[i-h_j] - 1), NaN dove la storia non basta.""" n = len(c) S = np.full((n, len(HORIZONS_D)), np.nan) for j, hd in enumerate(HORIZONS_D): h = hd * bpd if h < n: S[h:, j] = np.sign(c[h:] / c[:-h] - 1.0) return S def direction_from_weights(S: np.ndarray, W: np.ndarray) -> np.ndarray: """Direzione long-flat = media pesata dei sign sugli orizzonti VALIDI (pesi rinormalizzati sui validi, come tsmom_blend rinormalizza sul conteggio).""" V = np.isfinite(S) Wv = np.where(V, W, 0.0) norm = Wv.sum(axis=1) num = (np.where(V, S, 0.0) * Wv).sum(axis=1) d = np.where(norm > 0, num / np.where(norm > 0, norm, 1.0), 0.0) return np.clip(d, 0.0, None) # LONG-FLAT come TP01 canonico def expanding_pctl(v: np.ndarray, min_n: int = MIN_REGIME_OBS) -> np.ndarray: """Percentile espandente CAUSALE: mid-rank di v[i] nella storia v[<=i] (NaN esclusi). Nessuna statistica full-sample; identico ricomputato su qualunque prefisso.""" v = np.asarray(v, float) out = np.full(len(v), np.nan) hist: list[float] = [] for i in range(len(v)): x = v[i] if not np.isfinite(x): continue bisect.insort(hist, x) if len(hist) >= min_n: lo = bisect.bisect_left(hist, x) hi = bisect.bisect_right(hist, x) out[i] = (lo + hi) / 2.0 / len(hist) return out def regime_pctl(df: pd.DataFrame, asset: str, measure: str) -> np.ndarray: bpd = al.bars_per_day(df) if measure == "rv": r = al.simple_returns(df["close"].values.astype(float)) v = al.realized_vol(r, 30 * bpd, bpd * 365.25) elif measure == "dvol": v = al.dvol(df, asset) # merge_asof backward, epoca ms esplicita else: raise ValueError(measure) return expanding_pctl(v) def weight_matrix(pct: np.ndarray, thr: float, mapping: str, blend: str) -> np.ndarray: """Pesi per barra sui 3 orizzonti. lam=1 → peso di regime ALTO, lam=0 → BASSO. hv_fast: alto → FAST_W; hv_slow: alto → SLOW_W. hard = switch alla soglia; linear = rampa lineare col percentile centrata sulla soglia (larghezza 2*RAMP). Dove il percentile non è definito → EQ_W (canonico) — causale e conservativo.""" n = len(pct) hi_w, lo_w = (FAST_W, SLOW_W) if mapping == "hv_fast" else (SLOW_W, FAST_W) if blend == "hard": lam = (pct > thr).astype(float) else: lam = np.clip(0.5 + (pct - thr) / (2.0 * RAMP), 0.0, 1.0) W = lam[:, None] * hi_w[None, :] + (1.0 - lam[:, None]) * lo_w[None, :] bad = ~np.isfinite(pct) W[bad] = EQ_W return W def make_target(thr: float, mapping: str, blend: str, measure: str): def target(df: pd.DataFrame, asset: str) -> np.ndarray: c = df["close"].values.astype(float) bpd = al.bars_per_day(df) S = horizon_signs(c, bpd) W = weight_matrix(regime_pctl(df, asset, measure), thr, mapping, blend) d = direction_from_weights(S, W) return al.vol_target(d, df, 0.20, 30, 2.0) return target def fixed_target(weights: np.ndarray): def target(df: pd.DataFrame, asset: str = "") -> np.ndarray: c = df["close"].values.astype(float) S = horizon_signs(c, al.bars_per_day(df)) d = direction_from_weights(S, np.tile(weights, (len(c), 1))) return al.vol_target(d, df, 0.20, 30, 2.0) return target def factory(tf: str = "1d", thr: float = 0.6, mapping: str = "hv_fast", blend: str = "hard", measure: str = "rv"): return make_target(thr, mapping, blend, measure) # --------------------------------------------------------------------------- # Valutazione: per-asset + 50/50 (stessa convenzione di candidate_daily) # --------------------------------------------------------------------------- def per_asset_series(target_fn) -> dict[str, pd.Series]: out = {} for a in al.CERTIFIED: df = al.get(a, "1d") ev = al.eval_weights(df, al._call_target(target_fn, df, a)) out[a] = pd.Series(ev["net"], index=ev["idx"]) return out def combo_5050(series: dict[str, pd.Series]) -> pd.Series: J = pd.concat(series, axis=1, join="inner").fillna(0.0) return al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]]) def sh_full_hold(s: pd.Series) -> tuple[float, float]: return al._sh(s), al._sh(s[s.index >= al.HOLDOUT]) def dominance_table(cand: dict[str, pd.Series], ctrl: dict[str, pd.Series]) -> dict: """Sharpe FULL/HOLD per BTC, ETH, 50/50: candidato vs controllo fixed-blend.""" rows = {} for k in ["BTC", "ETH", "5050"]: cs = combo_5050(cand) if k == "5050" else al._to_daily(cand[k]) bs = combo_5050(ctrl) if k == "5050" else al._to_daily(ctrl[k]) cf, chd = sh_full_hold(cs) bf, bh = sh_full_hold(bs) rows[k] = dict(cand_full=round(cf, 3), ctrl_full=round(bf, 3), d_full=round(cf - bf, 3), cand_hold=round(chd, 3), ctrl_hold=round(bh, 3), d_hold=round(chd - bh, 3)) return rows def multicut(cand_5050: pd.Series, ctrl_5050: pd.Series) -> dict: out = {} for cut in CUTS: t = pd.Timestamp(cut, tz="UTC") c, b = cand_5050[cand_5050.index >= t], ctrl_5050[ctrl_5050.index >= t] out[cut] = round(al._sh(c) - al._sh(b), 3) return out def dd_of(s: pd.Series) -> float: return round(al._dd_ret(s), 4) # --------------------------------------------------------------------------- # NULL: 300 pesi fissi casuali sui 3 orizzonti (fast path vettoriale) # --------------------------------------------------------------------------- def null_fixed_weights(n_draws: int = NULL_DRAWS, seed: int = SEED): pre = {} for a in al.CERTIFIED: df = al.get(a, "1d") c = df["close"].values.astype(float) bpd = al.bars_per_day(df) r = al.simple_returns(c) vol = al.realized_vol(r, 30 * bpd, bpd * 365.25) scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0) idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) pre[a] = dict(S=horizon_signs(c, bpd), scal=scal, r=r, idx=idx) rng = np.random.default_rng(seed) draws = rng.dirichlet(np.ones(3), size=n_draws) fulls, holds = [], [] for W in draws: nets = {} for a, p in pre.items(): d = direction_from_weights(p["S"], np.tile(W, (len(p["r"]), 1))) tgt = np.clip(d * p["scal"], 0.0, 2.0) tgt[~np.isfinite(tgt)] = 0.0 pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] turn = np.abs(np.diff(pos, prepend=0.0)) net = pos * p["r"] - al.FEE_SIDE * turn net[0] = 0.0 nets[a] = pd.Series(net, index=p["idx"]) s = combo_5050(nets) f, h = sh_full_hold(s) fulls.append(f); holds.append(h) return np.array(fulls), np.array(holds), draws # --------------------------------------------------------------------------- def main(): print("=" * 88) print("r0702 REGIME-SPEED: pesi tra orizzonti TSMOM condizionati al regime di vol") print("=" * 88) # ---- 1) SANITY: pesi fissi EQUAL devono riprodurre il baseline TP01 ------------ ctrl = per_asset_series(fixed_target(EQ_W)) ctrl_5050 = combo_5050(ctrl) base = al.tp01_baseline_daily() J = pd.concat({"mine": ctrl_5050, "tp01": base}, axis=1, join="inner").dropna() mf, mh = sh_full_hold(J["mine"]); bf, bh = sh_full_hold(J["tp01"]) max_diff = float(np.max(np.abs(J["mine"].values - J["tp01"].values))) print(f"\n[SANITY] EQ-weight per-orizzonte vs TP01 canonico (50/50 daily):") print(f" mine full {mf:+.3f} hold {mh:+.3f} tp01 full {bf:+.3f} hold {bh:+.3f}" f" max|Δdaily-ret| = {max_diff:.2e}") sanity_ok = abs(mf - bf) < 0.02 and max_diff < 1e-9 print(f" sanity_ok = {sanity_ok}") # ---- 2) FAMIGLIA ONESTA: 16 celle, selezione in-sample + DSR automatici -------- grid = [dict(thr=thr, mapping=m, blend=b, measure=meas) for meas in ("rv", "dvol") for thr in (0.60, 0.75) for m in ("hv_fast", "hv_slow") for b in ("hard", "linear")] print(f"\n[FAMIGLIA] study_family_honest su {len(grid)} celle (1d)...") fam = al.study_family_honest("R0702-REGIME-SPEED", factory, grid, tfs=("1d",)) ch = fam["chosen"] print(f" cella scelta IN-SAMPLE: {ch['params']} (IS Sharpe {ch['insample_sharpe']}," f" full {ch['full_sharpe']})") print(f" n_cells={fam['n_cells']} deflated_sharpe={fam['deflated_sharpe']}" f" expected_null_max={fam['expected_null_max']} dsr_pass={fam['dsr_pass']}") print(f" earns_slot_marginal={fam['earns_slot_marginal']} (atteso False: quasi-TP01)" f" verdict marginale={fam['marginal']['marginal_verdict']}") print(" tutte le celle (ordinate per IS Sharpe):") for r in fam["rows"]: print(f" IS {r['insample_sharpe']:+.3f} full {r['full_sharpe']:+.3f} {r['params']}") # ---- 3) DOMINANZA della cella scelta vs fixed-blend canonico ------------------- chosen_fn = factory(**{"tf": ch["tf"], **ch["params"]}) cand = per_asset_series(chosen_fn) cand_5050 = combo_5050(cand) dom = dominance_table(cand, ctrl) print("\n[DOMINANZA] cella scelta vs fixed-blend canonico (Sharpe, netto 0.10% RT):") for k, d in dom.items(): print(f" {k:>4s}: FULL {d['cand_full']:+.3f} vs {d['ctrl_full']:+.3f} (Δ{d['d_full']:+.3f})" f" HOLD {d['cand_hold']:+.3f} vs {d['ctrl_hold']:+.3f} (Δ{d['d_hold']:+.3f})") dominates = all(d["d_full"] >= 0 and d["d_hold"] >= 0 for d in dom.values()) print(f" DD 50/50: cand {dd_of(cand_5050)*100:.1f}% ctrl {dd_of(ctrl_5050)*100:.1f}%") print(f" dominates_all_6 = {dominates}") mc = multicut(cand_5050, ctrl_5050) mc_ok = all(v > 0 for v in mc.values()) print(f" multi-cut ΔSharpe (50/50, dal taglio a fine): {mc} all_positive={mc_ok}") corr = float(pd.concat({"c": cand_5050, "b": ctrl_5050}, axis=1, join="inner") .dropna().corr().iloc[0, 1]) print(f" corr(cand, ctrl) daily = {corr:.4f} (attesa ~1: è un tilt di TP01)") # ---- 4) CAUSALITÀ --------------------------------------------------------------- caus = al.causality_ok(chosen_fn, tf="1d") print(f"\n[CAUSALITÀ] causality_ok = {caus['ok']} max_tail_diff={caus['max_tail_diff']}" f" checked={caus['checked']}") # ---- 5) NULL: pesi fissi casuali ------------------------------------------------ print(f"\n[NULL] {NULL_DRAWS} draw Dirichlet di pesi FISSI sui 3 orizzonti (50/50)...") nf, nh, _ = null_fixed_weights() cf, chd = sh_full_hold(cand_5050) p_full = float(np.mean(nf <= cf)); p_hold = float(np.mean(nh <= chd)) print(f" null FULL: mean {nf.mean():+.3f} p90 {np.percentile(nf, 90):+.3f}" f" max {nf.max():+.3f} cella {cf:+.3f} → pctl {p_full:.3f}") print(f" null HOLD: mean {nh.mean():+.3f} p90 {np.percentile(nh, 90):+.3f}" f" max {nh.max():+.3f} cella {chd:+.3f} → pctl {p_hold:.3f}") beats_null = p_full >= 0.90 and p_hold >= 0.90 print(f" beats_null_p90 (FULL e HOLD) = {beats_null}") # ---- 6) RV vs DVOL come regime --------------------------------------------------- print("\n[RV vs DVOL] migliore cella per misura (full / IS Sharpe):") for meas in ("rv", "dvol"): rows = [r for r in fam["rows"] if r["params"]["measure"] == meas] if rows: b = max(rows, key=lambda r: r["insample_sharpe"]) print(f" {meas:>4s}: best-IS {b['insample_sharpe']:+.3f} (full {b['full_sharpe']:+.3f})" f" {b['params']}") # ---- 7) VERDETTO ------------------------------------------------------------------ crit = dict(sanity_ok=sanity_ok, dominates=dominates, multicut_ok=mc_ok, dsr_pass=bool(fam["dsr_pass"]), beats_null_p90=beats_null, causal=bool(caus["ok"])) n_pass = sum(crit.values()) if all(crit.values()): verdict = "PASS" elif crit["sanity_ok"] and crit["causal"] and crit["dominates"] and crit["multicut_ok"]: verdict = "LEAD" else: verdict = "FAIL" print(f"\n[VERDETTO] {verdict} criteri={crit} ({n_pass}/{len(crit)})") return verdict if __name__ == "__main__": main()