"""intraday_regime.py — FILONE B: "INTRADAY REGIME BTC/ETH" (eseguibile) — 2026-06-29. TESI. Cercare un meccanismo SUB-DAILY sui dati certificati 1h/4h/.../12h BTC/ETH che sia ORTOGONALE sia a TP01 (TSMOM trend daily, long-flat) sia a SKH01 (regime BuzVola/BuzVolume + Donchian breakout a 230m). SKH01 prova che il sub-daily PUO' funzionare ed essere quasi-ortogonale: qui si esplora un MECCANISMO DIVERSO, basato sulla QUALITA' del moto intraday (efficiency-ratio / vol-expansion / thrust) come REGIME che condiziona una posizione direzionale tenuta ~1 giorno. Il killer ricorrente del progetto sotto le 12h e' il MURO-FEE (0.10% RT) + overfitting. La ricetta che SKH01 usa per sopravvivere: DECISIONE sub-daily ma HOLD ~1 giorno -> pochi trade -> la fee non uccide. Qui ogni meccanismo e' costruito per essere a basso turnover (gate di regime che tiene flat la maggior parte del tempo, lookback non microscopici) e viene giudicato col fee-sweep ALLA SUA FREQUENZA REALE. Se muore appena si mette la fee -> SCARTATO e documentato (e' un risultato valido). MECCANISMI (tutti come posizione CONTINUA decisa <= close[i], cosi' passano nativamente per eval_weights / study_marginal / day_boundary_robust / eval_weights_smallcap di altlib): ERM Efficiency-Ratio regime momentum. ER = |moto netto su L barre| / |percorso| (Kaufman): alto = moto intraday "pulito"/direzionale, basso = chop. Prendi la direzione del moto netto SOLO quando ER >= soglia (regime trendy intraday), altrimenti flat. Vol-target. Storia economica: quando il prezzo intraday e' EFFICIENTE il momentum continua; quando e' choppy non c'e' edge. DIVERSO da SKH01 (regime vol/volume) e da TP01 (TSMOM 1-6 mesi). VEM Vol-Expansion Momentum. Direzione = segno del moto su Lmom barre, ATTIVA solo quando la vol realizzata corta > vol realizzata lunga (espansione di volatilita'). Vol-target. VBR Volatility/thrust breakout (Larry-Williams-style, ROLLING, no calendario). Segui solo i movimenti significativi: posizione = segno(c[i]-c[i-1]) quando |Δ| > k*ATR, altrimenti tieni la precedente. Momentum-continuation di thrust. TOD Time-of-day seasonality (CONTROLLO calendario). Direzione per ora-del-giorno via media espandente causale. Incluso APPOSTA per passarlo a day_boundary_robust: e' il tipo di effetto che ha ucciso open_drive (artefatto di etichettatura del giorno UTC). GATE (CLAUDE.md): causale/no-leak, NETTO fee 0.10% RT + sweep 0.00-0.20% a freq reale, OOS hold-out + griglia + plateau, day_boundary_robust per effetti calendario, MARGINAL vs TP01 (earns_slot / has_insample_edge / multi-cut / non-hedge), corr con SKH01, haircut $600. Esecuzione: uv run python scripts/research/intraday_regime.py Idempotente, niente scritture su disco (solo report a stdout). """ from __future__ import annotations import sys from functools import lru_cache import numpy as np import pandas as pd sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al # noqa: E402 ASSETS = ("BTC", "ETH") SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h") # =========================================================================== # TARGET FACTORIES (ogni fattoria ritorna un target_fn(df) causale, posizione continua) # =========================================================================== def make_erm(tf: str, L_days: float, thr: float, long_flat: bool, target_vol: float = 0.20): """Efficiency-Ratio regime momentum. L_days = lunghezza finestra in GIORNI (-> barre via bpd).""" def fn(df): c = df["close"].values.astype(float) n = len(c) L = max(2, round(L_days * al.bars_per_day(df))) net = np.full(n, np.nan) net[L:] = c[L:] - c[:-L] step = np.abs(np.diff(c, prepend=c[0])) # |c[k]-c[k-1]|, causale path = pd.Series(step).rolling(L, min_periods=L).sum().values er = np.divide(np.abs(net), path, out=np.zeros(n), where=(path > 0)) active = (er >= thr) & np.isfinite(net) raw = np.where(active, np.sign(net), 0.0) if long_flat: raw = np.clip(raw, 0.0, None) return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) return fn def make_vem(tf: str, Lmom_days: float, Lshort_days: float, Llong_days: float, long_flat: bool, target_vol: float = 0.20): """Vol-expansion momentum: momentum attivo solo quando rv_corta > rv_lunga (espansione).""" def fn(df): c = df["close"].values.astype(float) n = len(c) bpd = al.bars_per_day(df) Lmom = max(2, round(Lmom_days * bpd)) ws, wl = max(2, round(Lshort_days * bpd)), max(3, round(Llong_days * bpd)) r = al.simple_returns(c) rv_s = al.rolling_std(r, ws) rv_l = al.rolling_std(r, wl) expand = (rv_s > rv_l) & np.isfinite(rv_s) & np.isfinite(rv_l) net = np.full(n, np.nan) net[Lmom:] = c[Lmom:] - c[:-Lmom] raw = np.where(expand & np.isfinite(net), np.sign(net), 0.0) if long_flat: raw = np.clip(raw, 0.0, None) return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) return fn def make_vbr(tf: str, k: float, atr_win: int, long_flat: bool, target_vol: float = 0.20): """Thrust-breakout rolling: segui i moti significativi (|Δ| > k*ATR), altrimenti hold.""" def fn(df): c = df["close"].values.astype(float) a = al.atr(df, atr_win) a_prev = np.roll(a, 1); a_prev[0] = a[0] # ATR noto a inizio barra (causale) delta = np.diff(c, prepend=c[0]) sig = np.where(np.abs(delta) > k * a_prev, np.sign(delta), np.nan) raw = pd.Series(sig).ffill().fillna(0.0).values if long_flat: raw = np.clip(raw, 0.0, None) return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) return fn def make_tod(tf: str, long_flat: bool, target_vol: float = 0.20, min_obs: int = 20): """Time-of-day seasonality (controllo calendario). Direzione = segno della media espandente causale del rendimento della stessa ora-del-giorno. Da passare a day_boundary_robust.""" def fn(df): c = df["close"].values.astype(float) r = al.simple_returns(c) hour = pd.to_datetime(df["datetime"], utc=True).dt.hour.values n = len(c) sums = {}; cnts = {} raw = np.zeros(n) for i in range(1, n): h_prev = int(hour[i - 1]) # aggiorna con la barra GIA' chiusa sums[h_prev] = sums.get(h_prev, 0.0) + r[i - 1] cnts[h_prev] = cnts.get(h_prev, 0) + 1 h = int(hour[i]) if cnts.get(h, 0) >= min_obs: raw[i] = 1.0 if sums[h] >= 0 else -1.0 if long_flat: raw = np.clip(raw, 0.0, None) return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) return fn # =========================================================================== # SCREENING — griglia leggera per (asset,tf,params) via eval_weights (vettoriale). # =========================================================================== def _screen_cell(fn, tf): """Min-asset full/hold Sharpe, fee@0.10 e @0.20 RT, turnover, time-in-market.""" fulls, holds, f10, f20, turn, tim = [], [], [], [], [], [] for a in ASSETS: df = al.get(a, tf) tgt = fn(df) ev = al.eval_weights(df, tgt, fee_side=0.0005) # 0.10% RT ev0 = al.eval_weights(df, tgt, fee_side=0.001) # 0.20% RT fulls.append(ev["full"]["sharpe"]); holds.append(ev["holdout"].get("sharpe", 0.0)) f10.append(ev["full"]["sharpe"]); f20.append(ev0["full"]["sharpe"]) turn.append(ev["turnover_per_year"]); tim.append(ev["time_in_market"]) return dict(tf=tf, min_full=round(min(fulls), 3), min_hold=round(min(holds), 3), min_f10=round(min(f10), 3), min_f20=round(min(f20), 3), turnover=round(float(np.mean(turn)), 1), tim=round(float(np.mean(tim)), 2)) def screen_family(name, factory, grid, tfs=SCREEN_TFS): """Esegue la griglia, ritorna lista di dict ordinata per min_hold (solo fee-surviving in cima).""" rows = [] for tf in tfs: for params in grid: fn = factory(tf=tf, **params) m = _screen_cell(fn, tf) m["params"] = params m["fee_ok"] = bool(m["min_f20"] > 0) rows.append(m) rows.sort(key=lambda r: (r["fee_ok"], r["min_hold"]), reverse=True) print(f"\n===== {name}: top celle (di {len(rows)}) =====") print(f" {'tf':>4} {'minFull':>7} {'minHold':>7} {'f@.10':>6} {'f@.20':>6} " f"{'turn/y':>7} {'tim':>5} feeOK params") for r in rows[:10]: print(f" {r['tf']:>4} {r['min_full']:+7.2f} {r['min_hold']:+7.2f} {r['min_f10']:+6.2f} " f"{r['min_f20']:+6.2f} {r['turnover']:>7.0f} {r['tim']:>5.2f} " f"{str(r['fee_ok']):>5} {r['params']}") return rows # =========================================================================== # DEEP-DIVE sul vincitore: marginal vs TP01 + day_boundary + corr SKH01 + haircut $600. # =========================================================================== @lru_cache(maxsize=1) def _skh_daily() -> pd.Series: """Rendimenti giornalieri SKH01-V2-DD (50/50 BTC+ETH) dallo sleeve di progetto (read-only).""" from src.portfolio.sleeves import _skyhook_returns s = _skyhook_returns() if s.index.tz is None: s.index = s.index.tz_localize("UTC") return s def corr_to_skh(fn, tf) -> dict: cand = al.candidate_daily(fn, tf=tf) skh = _skh_daily() J = pd.concat({"C": cand, "S": skh}, axis=1, join="inner").dropna() JH = J[J.index >= al.HOLDOUT] return dict(n=int(len(J)), corr_full=round(float(J["C"].corr(J["S"])), 3) if len(J) > 5 else None, corr_hold=round(float(JH["C"].corr(JH["S"])), 3) if len(JH) > 5 else None) def haircut_600(fn, tf) -> dict: """Sharpe onesto a $600: salta i ribilanci < $5 (eval_weights_smallcap), per asset + media.""" out = {} for a in ASSETS: df = al.get(a, tf) sc = al.eval_weights_smallcap(df, fn(df), capital=600.0, min_order=5.0) out[a] = dict(modeled=sc["modeled"]["sharpe"], real=sc["realistic"]["sharpe"], haircut=sc["sharpe_haircut"], n_tr=sc["n_executed_trades"]) return out def plateau_erm(tf="8h"): """Plateau fine L_days x thr al TF vincente (min-asset full/hold/f@.20). Un edge vero ha un PLATEAU, non una cella isolata.""" print("\n" + "=" * 78) print(f"PLATEAU ERM @ {tf} (min-asset; L_days righe, thr colonne) — full / hold / f@.20") print("=" * 78) Ls = (1.5, 2.0, 2.5, 3.0); thrs = (0.30, 0.35, 0.40, 0.45, 0.50) print(" L\\thr " + "".join(f"{t:>16.2f}" for t in thrs)) for L in Ls: cells = [] for t in thrs: m = _screen_cell(make_erm(tf=tf, L_days=L, thr=t, long_flat=False), tf) cells.append(f"{m['min_full']:+.2f}/{m['min_hold']:+.2f}/{m['min_f20']:+.2f}") print(f" {L:>4.1f} " + "".join(f"{c:>16}" for c in cells)) def vs_book(fn, tf): """Il test decisivo del gate #5: ERM AGGIUNGE oltre il book esistente (TP01+SKH01), o e' SKH01 travestito? Sharpe/DD full & hold dei blend incrementali su griglia giornaliera.""" cand = al.candidate_daily(fn, tf=tf) tp = al.tp01_baseline_daily() skh = _skh_daily() J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna() JH = J[J.index >= al.HOLDOUT] blends = [ ("TP01", (1.0, 0.0, 0.0)), ("TP01+SKH 75/25", (0.75, 0.25, 0.0)), ("TP01+SKH+ERM 60/25/15", (0.60, 0.25, 0.15)), ("TP01+SKH+ERM 55/20/25", (0.55, 0.20, 0.25)), ] print("\n" + "=" * 78) print("vs BOOK ESISTENTE (TP01+SKH01) — ERM aggiunge oltre SKH? (gate #5)") print("=" * 78) print(f" {'blend':<26} {'FULL Sh':>8} {'FULL DD':>8} {'HOLD Sh':>8} {'HOLD DD':>8}") for label, (wt, ws, wc) in blends: bf = wt * J["T"] + ws * J["S"] + wc * J["C"] bh = wt * JH["T"] + ws * JH["S"] + wc * JH["C"] print(f" {label:<26} {al._sh(bf):>+8.2f} {al._dd_ret(bf) * 100:>7.1f}% " f"{al._sh(bh):>+8.2f} {al._dd_ret(bh) * 100:>7.1f}%") def deep_dive(name, fn, tf, calendar=False): print("\n" + "#" * 78) print(f"# DEEP-DIVE: {name} (tf={tf})") print("#" * 78) caus = al.causality_ok(fn, tf=tf) print(f"\n[CAUSALITA'] ok={caus['ok']} max_tail_diff={caus['max_tail_diff']} " f"(checked={caus['checked']})") print("\n[FEE-SWEEP a frequenza reale] (study_weights su entrambi gli asset)") sw = al.study_weights(name, fn, tfs=(tf,)) print(al.fmt(sw)) print("\n[MARGINAL vs TP01]") sm = al.study_marginal(name, fn, tf=tf) print(al.fmt_marginal(sm)) sk = corr_to_skh(fn, tf) print(f"\n[CORR con SKH01] full={sk['corr_full']} hold={sk['corr_hold']} " f"(n_giorni={sk['n']})") if calendar: print("\n[DAY-BOUNDARY ROBUST] (OBBLIGATORIO per effetti ora/sessione/giorno)") else: print("\n[DAY-BOUNDARY ROBUST] (sanity: un segnale di prezzo dev'essere ~INVARIANT)") db = al.day_boundary_robust(fn, tf=tf) print(f" verdict={db['verdict']} spread={db.get('spread')} " f"min={db.get('min')} max={db.get('max')} per_offset={db.get('per_offset')}") print("\n[HAIRCUT $600] (eval_weights_smallcap: salta ribilanci < $5)") hc = haircut_600(fn, tf) for a, d in hc.items(): print(f" {a}: modeled Sh {d['modeled']:+.2f} -> real Sh {d['real']:+.2f} " f"(haircut {d['haircut']:+.2f}, trade eseguiti {d['n_tr']})") return dict(name=name, tf=tf, causal=caus["ok"], earns_slot=sm["earns_slot"], marginal=sm["marginal_verdict"], corr_skh=sk, day_boundary=db["verdict"], haircut=hc) # =========================================================================== # MAIN # =========================================================================== def main(): print("=" * 78) print("FILONE B — INTRADAY REGIME BTC/ETH (intraday_regime.py)") print("=" * 78) tp01 = al.tp01_baseline_daily() print(f"Baseline TP01 (50/50) full Sharpe ~{al._sh(tp01):.2f} " f"hold ~{al._sh(tp01[tp01.index >= al.HOLDOUT]):.2f} (riferimento marginale)") # ---- Griglie (compatte: plateau leggibile, no overfit di griglia gigante) ---- erm_grid = [dict(L_days=L, thr=t, long_flat=lf) for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)] vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf) for lm in (1.0, 3.0) for lf in (False, True)] vbr_grid = [dict(k=k, atr_win=14, long_flat=lf) for k in (0.5, 1.0, 1.5) for lf in (False, True)] tod_grid = [dict(long_flat=lf) for lf in (False, True)] fam = { "ERM": (make_erm, erm_grid, SCREEN_TFS), "VEM": (make_vem, vem_grid, ("4h", "6h", "8h", "12h")), "VBR": (make_vbr, vbr_grid, ("4h", "6h", "8h", "12h")), "TOD": (make_tod, tod_grid, ("1h",)), } screens = {} for name, (factory, grid, tfs) in fam.items(): screens[name] = screen_family(name, factory, grid, tfs) # ---- Vincitore per famiglia (best min_hold tra le fee-surviving con min_full>0) ---- print("\n" + "=" * 78) print("VINCITORI PER FAMIGLIA (best min_hold tra fee-surviving, min_full>0)") print("=" * 78) winners = {} for name, (factory, grid, tfs) in fam.items(): ok = [r for r in screens[name] if r["fee_ok"] and r["min_full"] > 0] pool = ok if ok else screens[name] w = max(pool, key=lambda r: r["min_hold"]) winners[name] = w print(f" {name}: tf={w['tf']} {w['params']} minFull={w['min_full']:+.2f} " f"minHold={w['min_hold']:+.2f} f@.20={w['min_f20']:+.2f} feeOK={w['fee_ok']}") # ---- Deep-dive sui due meccanismi piu' promettenti (per min_hold) + il controllo TOD ---- ranked = sorted(["ERM", "VEM", "VBR"], key=lambda n: winners[n]["min_hold"], reverse=True) deep = [] for name in ranked[:2]: w = winners[name] factory = fam[name][0] fn = factory(tf=w["tf"], **w["params"]) deep.append(deep_dive(f"{name} {w['params']}", fn, w["tf"], calendar=False)) # controllo calendario: TOD passa SEMPRE per day_boundary_robust wt = winners["TOD"] fn_tod = make_tod(tf=wt["tf"], **wt["params"]) deep.append(deep_dive(f"TOD {wt['params']}", fn_tod, wt["tf"], calendar=True)) # ---- Analisi extra sul vincitore ERM (plateau fine + vs book TP01+SKH01) ---- we = winners["ERM"] fn_erm = make_erm(tf=we["tf"], **we["params"]) plateau_erm(we["tf"]) vs_book(fn_erm, we["tf"]) # ---- Verdetto sintetico ---- print("\n" + "=" * 78) print("SINTESI") print("=" * 78) for d in deep: print(f" {d['name']:<26} tf={d['tf']:>3} | marginal={d['marginal']:<9} " f"earns_slot={d['earns_slot']!s:<5} corrSKH(full/hold)=" f"{d['corr_skh']['corr_full']}/{d['corr_skh']['corr_hold']} " f"day_boundary={d['day_boundary']}") any_slot = any(d["earns_slot"] for d in deep) print(f"\n => earns_slot su qualche meccanismo? {any_slot}") print(" (vedi diario docs/diary/2026-06-29-intraday-regime.md per il verdetto ragionato)") if __name__ == "__main__": main()