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:
@@ -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()
|
||||
Reference in New Issue
Block a user