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>
This commit is contained in:
Adriano Dal Pastro
2026-07-02 22:12:22 +00:00
parent e6657fcb16
commit 73d74c5e53
12 changed files with 4205 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
#!/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()