"""r0702_slow_clock.py — FILONE: clock più lenti del daily + banded rebalancing per TP01. Due idee di TIMING DI ESECUZIONE (non di segnale) sul TP01 CANONICAL (PORT LF1d): (A) CLOCK LENTI — segnale calcolato daily, posizione aggiornata solo ogni N giorni (N in {2,3,5,7}). ⚠ timing luck: si riportano TUTTE le N fasi (min/med/max) e l'ENSEMBLE delle fasi (media dei libri sfasati), MAI la fase migliore da sola. (B) BANDE DI ISTERESI — decisione daily, si esegue solo se |target − posizione| > banda (banda in frazione di equity PER ASSET, in {0, .025, .05, .10, .20}); quando si esegue si va al target pieno. Onestà: - selezione cella SOLO in-sample pre-2025 (pattern al.select_cell_insample); l'hold-out della cella scelta si RIPORTA, non si sceglie. - deflated Sharpe (al.deflated_sharpe) su TUTTI i trial esplorati (fasi incluse). - Sharpe LORDO (fee=0) accanto al netto: una variante di esecuzione onesta ha lordo ~uguale al canonico e netto >= (il guadagno è meccanico-di-costo, non fitting). - executability: replica di eval_weights_smallcap a capitale 600/2000/10000 (min order $5, capitale per-asset = C/2) per baseline vs variante scelta — a $600 la banda implicita min-order è 5/(600/2) ≈ 0.0167 di peso per asset. - causalità: target TP01 causale (verificato altrove); i filtri di esecuzione usano solo stato passato; eval_weights fa lo shift +1; check prefix-consistency inline sulla cella scelta. Nessun ffill mixed-TF, nessun .view("int64") su tz-aware. Convenzione (stessa di eval_weights/TrendPortfolio): il peso resta costante tra i ribilanciamenti (fee solo su |Δpeso|); il drift del peso intra-periodo non è modellato (secondo ordine a N<=7 giorni) — dichiarato nei caveat. Run: uv run python scripts/research/r0702_slow_clock.py """ from __future__ import annotations import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al # noqa: E402 import numpy as np # noqa: E402 import pandas as pd # noqa: E402 from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 HOLDOUT = al.HOLDOUT FEE = al.FEE_SIDE EPOCH = pd.Timestamp("1970-01-01", tz="UTC") CLOCK_NS = (2, 3, 5, 7) BANDS = (0.0, 0.025, 0.05, 0.10, 0.20) CAPITALS = (600.0, 2000.0, 10000.0) MIN_ORDER = 5.0 # --------------------------------------------------------------------------- # targets & execution filters (tutti causali: stato = solo passato) # --------------------------------------------------------------------------- def daily_targets() -> dict[str, tuple[pd.DataFrame, np.ndarray]]: tp = TrendPortfolio(**CANONICAL) out = {} for a in al.CERTIFIED: df = al.get(a, "1d") out[a] = (df, tp.target_series(df)) return out def epoch_days(df: pd.DataFrame) -> np.ndarray: dt = pd.to_datetime(df["datetime"], utc=True) return ((dt.dt.floor("D") - EPOCH) // pd.Timedelta(days=1)).values.astype(int) def slow_clock_exec(df: pd.DataFrame, tgt: np.ndarray, N: int, phase: int) -> np.ndarray: """Aggiorna la posizione solo nei giorni con epoch_day % N == phase (ancoraggio a calendario -> prefix-consistent, entrambe le gambe ribilanciano lo stesso giorno).""" days = epoch_days(df) out = np.empty(len(tgt)) cur = 0.0 for i in range(len(tgt)): if days[i] % N == phase: cur = tgt[i] out[i] = cur return out def band_exec(tgt: np.ndarray, band: float) -> np.ndarray: """Esegue (va al target pieno) solo se |target − posizione corrente| > band.""" out = np.empty(len(tgt)) cur = 0.0 for i in range(len(tgt)): if abs(tgt[i] - cur) > band: cur = tgt[i] out[i] = cur return out def smallcap_exec(tgt: np.ndarray, capital_per_asset: float, min_order: float = MIN_ORDER) -> np.ndarray: """Replica della logica di al.eval_weights_smallcap (serve la SERIE, non solo le metriche): un Δpeso il cui nozionale < min_order NON si esegue.""" out = np.empty(len(tgt)) cur = 0.0 for i in range(len(tgt)): if abs(tgt[i] - cur) * capital_per_asset >= min_order: cur = tgt[i] out[i] = cur return out # --------------------------------------------------------------------------- # valutazione book 50/50 (netto + lordo) # --------------------------------------------------------------------------- def _series(df: pd.DataFrame, et: np.ndarray, fee_side: float) -> pd.Series: ev = al.eval_weights(df, et, fee_side=fee_side) return pd.Series(ev["net"], index=ev["idx"]) def book_eval(pairs: dict[str, tuple[pd.DataFrame, np.ndarray]]) -> dict: """pairs: {asset: (df, exec_target)} -> metriche book 50/50 nette e lorde.""" net_s, gro_s = {}, {} turn_book = 0.0 orders_y = 0.0 for a, (df, et) in pairs.items(): net_s[a] = _series(df, et, FEE) gro_s[a] = _series(df, et, 0.0) years = (df["datetime"].iloc[-1] - df["datetime"].iloc[0]).total_seconds() / 86400 / 365.25 pos = np.zeros(len(et)); pos[1:] = et[:-1] turn = np.abs(np.diff(pos, prepend=0.0)) turn_book += 0.5 * turn.sum() / years # in unità di equity del book orders_y += float(np.sum(turn > 1e-12) / years) # ordini reali (entrambe le gambe) NET = pd.concat(net_s, axis=1, join="inner").fillna(0.0) GRO = pd.concat(gro_s, axis=1, join="inner").fillna(0.0) assets = list(pairs) net = 0.5 * NET[assets[0]] + 0.5 * NET[assets[1]] gro = 0.5 * GRO[assets[0]] + 0.5 * GRO[assets[1]] def _hold(s): return s[s.index >= HOLDOUT] def _ins(s): return s[s.index < HOLDOUT] eqn = np.cumprod(1.0 + np.clip(net.values, -0.99, None)) span_y = (net.index[-1] - net.index[0]).total_seconds() / 86400 / 365.25 cagr = eqn[-1] ** (1 / span_y) - 1 if span_y > 0 else 0.0 return dict( sh_full_net=al._sh(net), sh_hold_net=al._sh(_hold(net)), sh_ins_net=al._sh(_ins(net)), sh_full_gro=al._sh(gro), sh_hold_gro=al._sh(_hold(gro)), maxdd=al._dd_ret(net), cagr=cagr, turnover_y=turn_book, orders_y=orders_y, net=net, gross=gro, ) def row(tag: str, m: dict) -> str: return (f"{tag:<26} | net F {m['sh_full_net']:5.2f} H {m['sh_hold_net']:5.2f} " f"(IS {m['sh_ins_net']:5.2f}) | gross F {m['sh_full_gro']:5.2f} " f"H {m['sh_hold_gro']:5.2f} | DD {m['maxdd']*100:4.1f}% | CAGR {m['cagr']*100:5.1f}% " f"| turn/y {m['turnover_y']:5.1f} | ord/y {m['orders_y']:6.1f}") # --------------------------------------------------------------------------- def main() -> None: pairs = daily_targets() # ---- sanity check: riproduci al.tp01_baseline_daily() ------------------- base = book_eval(pairs) ref = al.tp01_baseline_daily() common = base["net"].index.intersection(ref.index) diff = float(np.max(np.abs(base["net"].reindex(common).values - ref.reindex(common).values))) print("=" * 118) print("SANITY — baseline daily vs al.tp01_baseline_daily():", f"max|Δdaily ret| = {diff:.2e}", f"(Sharpe qui {base['sh_full_net']:.3f} / ref {al._sh(ref):.3f})") assert diff < 1e-9, "baseline non riprodotta!" print(row("BASELINE daily band=0", base)) all_trial_sharpes: list[float] = [base["sh_full_net"]] candidates: dict[str, dict] = {"baseline_daily": base} # ---- (A) CLOCK LENTI: tutte le fasi + ensemble --------------------------- print("\n" + "=" * 118) print("(A) CLOCK LENTI — TP01 daily-signal, ribilanciamento ogni N giorni " "(tutte le fasi: min/med/max; ensemble = media dei libri sfasati)") print("=" * 118) clock_tbl = {} for N in CLOCK_NS: phase_ms, phase_nets, phase_gros = [], [], [] for p in range(N): pp = {a: (df, slow_clock_exec(df, tgt, N, p)) for a, (df, tgt) in pairs.items()} m = book_eval(pp) phase_ms.append(m) phase_nets.append(m["net"]); phase_gros.append(m["gross"]) all_trial_sharpes.append(m["sh_full_net"]) ens_net = pd.concat(phase_nets, axis=1, join="inner").mean(axis=1) ens_gro = pd.concat(phase_gros, axis=1, join="inner").mean(axis=1) eqn = np.cumprod(1.0 + np.clip(ens_net.values, -0.99, None)) span_y = (ens_net.index[-1] - ens_net.index[0]).total_seconds() / 86400 / 365.25 ens = dict( sh_full_net=al._sh(ens_net), sh_hold_net=al._sh(ens_net[ens_net.index >= HOLDOUT]), sh_ins_net=al._sh(ens_net[ens_net.index < HOLDOUT]), sh_full_gro=al._sh(ens_gro), sh_hold_gro=al._sh(ens_gro[ens_gro.index >= HOLDOUT]), maxdd=al._dd_ret(ens_net), cagr=eqn[-1] ** (1 / span_y) - 1, turnover_y=float(np.mean([m["turnover_y"] for m in phase_ms])), orders_y=float(np.mean([m["orders_y"] for m in phase_ms])), net=ens_net, gross=ens_gro, ) all_trial_sharpes.append(ens["sh_full_net"]) candidates[f"clock_N{N}_ensemble"] = ens clock_tbl[N] = (phase_ms, ens) fn = [m["sh_full_net"] for m in phase_ms] hn = [m["sh_hold_net"] for m in phase_ms] fg = [m["sh_full_gro"] for m in phase_ms] hg = [m["sh_hold_gro"] for m in phase_ms] dd = [m["maxdd"] for m in phase_ms] oy = [m["orders_y"] for m in phase_ms] print(f"N={N} fasi ({N}): net FULL min/med/max {min(fn):.2f}/{np.median(fn):.2f}/{max(fn):.2f}" f" HOLD {min(hn):.2f}/{np.median(hn):.2f}/{max(hn):.2f}" f" | gross FULL {min(fg):.2f}/{np.median(fg):.2f}/{max(fg):.2f}" f" HOLD {min(hg):.2f}/{np.median(hg):.2f}/{max(hg):.2f}" f" | DD {min(dd)*100:.1f}-{max(dd)*100:.1f}% | ord/y {min(oy):.0f}-{max(oy):.0f}") print(row(f" N={N} ENSEMBLE", ens)) # ---- (B) BANDE DI ISTERESI ---------------------------------------------- print("\n" + "=" * 118) print("(B) BANDE DI ISTERESI — decisione daily, esecuzione solo se |target−pos| > banda " "(frazione di equity per asset); si va al target pieno") print("=" * 118) band_tbl = {} for b in BANDS: pp = {a: (df, band_exec(tgt, b)) for a, (df, tgt) in pairs.items()} m = book_eval(pp) band_tbl[b] = m all_trial_sharpes.append(m["sh_full_net"]) if b > 0: candidates[f"band_{b:g}"] = m saved = base["turnover_y"] - m["turnover_y"] print(row(f"banda {b:5.3f}", m) + f" | turn risparmiato {saved:5.1f}/y (fee ~{saved*FEE*100*2:.2f}%/y su RT)") # ---- selezione IN-SAMPLE (pre-2025) e hold-out riportato ----------------- print("\n" + "=" * 118) print("SELEZIONE CELLA — solo in-sample pre-2025 (l'hold-out si riporta, non si sceglie)") print("=" * 118) ranked = sorted(candidates.items(), key=lambda kv: kv[1]["sh_ins_net"], reverse=True) for name, m in ranked: print(f" IS {m['sh_ins_net']:5.3f} | HOLD {m['sh_hold_net']:5.3f} | FULL {m['sh_full_net']:5.3f} {name}") chosen_name, chosen = ranked[0] n_trials = len(all_trial_sharpes) dsr, sr0 = al.deflated_sharpe(chosen["sh_full_net"], all_trial_sharpes, chosen["net"]) print(f"\nCELLA SCELTA IN-SAMPLE: {chosen_name}") print(row(" scelta", chosen)) print(f" trials totali esplorati: {n_trials} (fasi singole incluse)") print(f" deflated Sharpe (vs {n_trials} trial): DSR={dsr:.3f}, null-max atteso={sr0:.3f} " f"(NB: candidato = variante di TP01, correlatissima al baseline — l'asticella " f"giusta è lordo~uguale/netto-migliore, non earns_slot)") dgro = chosen["sh_full_gro"] - base["sh_full_gro"] dnet = chosen["sh_full_net"] - base["sh_full_net"] dgro_h = chosen["sh_hold_gro"] - base["sh_hold_gro"] dnet_h = chosen["sh_hold_net"] - base["sh_hold_net"] print(f" Δ vs baseline — FULL: gross {dgro:+.3f} / net {dnet:+.3f} " f"HOLD: gross {dgro_h:+.3f} / net {dnet_h:+.3f}") print(f" fee drag baseline: turn {base['turnover_y']:.1f}/y × {2*FEE*100:.2f}%RT " f"≈ {base['turnover_y']*FEE*100:.2f}%/y di equity — questo è il TETTO del guadagno meccanico") # ---- prefix-consistency (causalità dell'exec filter) --------------------- ok = True for a, (df, tgt) in pairs.items(): if chosen_name.startswith("band"): b = float(chosen_name.split("_")[1]) full_e = band_exec(tgt, b) cut = int(len(df) * 0.8) sub = df.iloc[:cut].reset_index(drop=True) sub_t = TrendPortfolio(**CANONICAL).target_series(sub) sub_e = band_exec(sub_t, b) elif chosen_name.startswith("clock"): N = int(chosen_name.split("_")[1][1:]) full_e = slow_clock_exec(df, tgt, N, 0) cut = int(len(df) * 0.8) sub = df.iloc[:cut].reset_index(drop=True) sub_t = TrendPortfolio(**CANONICAL).target_series(sub) sub_e = slow_clock_exec(sub, sub_t, N, 0) else: continue d = float(np.max(np.abs(sub_e[-60:] - full_e[cut - 60:cut]))) ok &= d < 1e-9 print(f" prefix-consistency exec-filter (fase 0 per i clock): {'OK' if ok else 'FAIL'}") # ---- (6) EXECUTABILITY small-cap a 600 / 2000 / 10000 -------------------- print("\n" + "=" * 118) print("(6) EXECUTABILITY — min order $5, capitale per-asset = C/2 " "(banda implicita = 5/(C/2) in peso per asset)") print("=" * 118) def chosen_exec(a, df, tgt): if chosen_name.startswith("band"): return band_exec(tgt, float(chosen_name.split("_")[1])) if chosen_name.startswith("clock"): N = int(chosen_name.split("_")[1][1:]) # deploy reale = UNA fase; usiamo fase 0 e dichiariamo la timing luck return slow_clock_exec(df, tgt, N, 0) return tgt.copy() for C in CAPITALS: cpa = C / 2.0 implicit = MIN_ORDER / cpa print(f"\ncapitale ${C:.0f} (banda implicita min-order = {implicit:.4f} peso/asset)") for label, mk in (("baseline daily", lambda a, df, t: t.copy()), (f"variante [{chosen_name}]", chosen_exec)): pp = {a: (df, smallcap_exec(mk(a, df, tgt), cpa)) for a, (df, tgt) in pairs.items()} m = book_eval(pp) # cross-check con l'utility ufficiale (per-asset, solo full) hc = {a: al.eval_weights_smallcap(df, mk(a, df, tgt), capital=cpa)["sharpe_haircut"] for a, (df, tgt) in pairs.items()} print(row(f" {label}", m) + f" | haircut/asset vs modellato: " + ", ".join(f"{a} {h:+.3f}" for a, h in hc.items())) print("\nNOTA: se la banda ottimale ≈ banda implicita a $600 (0.0167), il vincolo " "small-cap del libro live sta GIÀ facendo il lavoro della banda.") if __name__ == "__main__": main()