Files
PythagorasGoal/scripts/research/r0702_regime_speed.py
T
Adriano Dal Pastro 73d74c5e53 research(wave-0702): ondata timing + CRT — 8 filoni, 0 nuovi sleeve, finding anchor timing-luck TP01
Goal: "altre strategie su Deribit con timing differenti". 8 filoni multi-agente + scettico:
- event-clock bars, expiry calendar Deribit, clock lenti/bande, regime-speed: SCARTATI
- CRT (Candle Range Theory) base/multi-TF/contesto: SCARTATA 3/3 (DSR~0, ritest =
  informazione negativa; sottoprodotto: FOLLOW>FADE sui livelli prior-day ogni anno,
  conferma il lead prevday)
- FINDING (confermato da scettico indipendente): hold-out 0.31 di TP01 = migliore delle
  24 ancore orarie (mediana 0.04, banda [-0.13,+0.30]) -> narrativa corretta in CLAUDE.md
  e docstring: l'hold-out non risolve l'edge di ritorno, regge il taglio DD a ogni ancora.
  Tranching K=2/4 = solo varianza della stima, no deploy a $600. Audit d'ancora pendente
  su XS01/SKH01. Book live e portafoglio INVARIATI. Test 168/168.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:22 +00:00

332 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()