#!/usr/bin/env python """r0702_tp01_offset.py — TIMING-LUCK del ribilanciamento giornaliero di TP01. TP01 CANONICAL (PORT LF1d) decide sulla barra daily chiusa alle 00:00 UTC. L'ancora e' arbitraria (Hoffstein, "rebalance timing luck"): la STESSA strategia con parametri IDENTICI ancorata alle h:00 (h=0..23) puo' dare Sharpe diversi. Questo script: 1. costruisce 24 serie daily (resample 24h del 1h certificato, offset h, label/closed left, stessa convenzione di trend_portfolio.resample_tf) — SANITY OBBLIGATORIO: h=0 riproduce ESATTAMENTE al.tp01_baseline_daily() (stesso Sharpe FULL/HOLD); 2. misura Sharpe/CAGR/maxDD FULL, IS (pre-2025) e HOLD-OUT per offset -> percentile di h=0; 3. ENSEMBLE (tranching 1/K del capitale per ancora): 24-way + K=2 (0,12), K=3 (0,8,16), K=4 (0,6,12,18) — scelte A PRIORI simmetriche, zero tuning per-offset, zero selezione sull'hold-out. L'ensemble e' valutato sul BOOK AGGREGATO su griglia 1h (posizione = media delle tranche, fee sul turnover netto reale) — non media di equity separate; 4. dispersione: std/range dello Sharpe fra le 24 ancore vs fra TUTTE le rotazioni possibili di K=2 (12), K=3 (8), K=4 (6) — la riduzione di varianza e' il criterio strutturale; 5. small-cap: haircut min-order $5 a capitale 600/2k/10k per K=1 vs K=2/4/24 (il tranching divide gli ordini per K -> piu' rebalance sotto min-order). Causalita': targets TP01 causali per costruzione; guardia ricalcolo-su-prefisso sia sul daily resampled sia sul troncamento del 1h; mappatura daily->1h via merge_asof backward su EPOCA MS ESPLICITA (mai .view su tz-aware); eval_weights fa lo shift (held durante t+1). Vincoli: nessun file toccato fuori da questo script. Runtime ~1-2 min. """ from __future__ import annotations import sys from functools import lru_cache from pathlib import Path import numpy as np import pandas as pd ROOT = Path("/opt/docker/PythagorasGoal") sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) sys.path.insert(0, str(ROOT)) import altlib as al # noqa: E402 from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 TP = TrendPortfolio(**CANONICAL) HOLDOUT = al.HOLDOUT ASSETS = ("BTC", "ETH") MS_H = 3_600_000 MS_D = 86_400_000 # ancore a priori, simmetriche, NON ottimizzate HEADLINE = { "K=1 (canonico h=0)": (0,), "K=2 (0,12)": (0, 12), "K=3 (0,8,16)": (0, 8, 16), "K=4 (0,6,12,18)": (0, 6, 12, 18), "K=24 (tutte)": tuple(range(24)), } # --------------------------------------------------------------------------- # Resample con ancora h — stessa convenzione di trend_portfolio.resample_tf # --------------------------------------------------------------------------- def resample_offset(df_1h: pd.DataFrame, h: int) -> pd.DataFrame: g = df_1h.copy() idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True) idx.name = "dt" g.index = idx out = g.resample("24h", offset=pd.Timedelta(hours=h), label="left", closed="left").agg( {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) out = out.dropna(subset=["open"]) out["datetime"] = out.index epoch = pd.Timestamp("1970-01-01", tz="UTC") out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") return out.reset_index(drop=True)[ ["timestamp", "open", "high", "low", "close", "volume", "datetime"]] @lru_cache(maxsize=8) def get1h(asset: str) -> pd.DataFrame: return al.get(asset, "1h") @lru_cache(maxsize=64) def daily_off(asset: str, h: int) -> pd.DataFrame: return resample_offset(get1h(asset), h) # --------------------------------------------------------------------------- # Metriche su serie daily (convenzione identica al baseline: _to_daily + _sh) # --------------------------------------------------------------------------- def dmetrics(s: pd.Series) -> dict: s = s.dropna() is_ = s[s.index < HOLDOUT] ho = s[s.index >= HOLDOUT] eq = float(np.prod(1.0 + s.values)) yrs = len(s) / 365.25 cagr = eq ** (1 / yrs) - 1 if eq > 0 and yrs > 0 else -1.0 return dict(sh_full=al._sh(s), sh_is=al._sh(is_), sh_hold=al._sh(ho), cagr=cagr, dd=al._dd_ret(s), dd_hold=al._dd_ret(ho), n=len(s)) # --------------------------------------------------------------------------- # Path DAILY-NATIVO per offset (counterfactual "e se l'ancora fosse h") # --------------------------------------------------------------------------- def port_daily_native(h: int) -> pd.Series: series = {} for a in ASSETS: df = daily_off(a, h) net, ts = TP.net_returns(df) series[a] = pd.Series(net, index=pd.DatetimeIndex(pd.to_datetime(ts.values, utc=True))) J = pd.concat(series, axis=1, join="inner").fillna(0.0) return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) # --------------------------------------------------------------------------- # Path 1h AGGREGATO (book unico): target daily-offset mappato causale sul 1h # --------------------------------------------------------------------------- @lru_cache(maxsize=64) def hourly_target(asset: str, h: int) -> tuple: """Target TP01 (ancora h) sul grid 1h: per ogni barra 1h prendi il target dell'ultima barra daily-offset il cui CLOSE nominale (label+24h, epoca ms) e' <= close della barra 1h (ts+1h). merge_asof backward su int ms espliciti. eval_weights poi SHIFTA (held t+1).""" d = daily_off(asset, h) tgt = TP.target_series(d) right = pd.DataFrame({"cms": d["timestamp"].values.astype("int64") + MS_D, "tgt": tgt}) left = pd.DataFrame({"cms": get1h(asset)["timestamp"].values.astype("int64") + MS_H}) m = pd.merge_asof(left, right, on="cms", direction="backward") return tuple(np.nan_to_num(m["tgt"].values, nan=0.0)) def ens_target(asset: str, hs: tuple) -> np.ndarray: return np.mean([np.asarray(hourly_target(asset, h)) for h in hs], axis=0) def port_hourly(hs: tuple) -> tuple[pd.Series, float]: """Serie daily del book aggregato (0.5 BTC + 0.5 ETH su grid 1h) + turnover/anno.""" nets, turns = {}, 0.0 for a in ASSETS: df = get1h(a) ev = al.eval_weights(df, ens_target(a, hs)) nets[a] = pd.Series(ev["net"], index=ev["idx"]) turns += 0.5 * ev["turnover_per_year"] J = pd.concat(nets, axis=1, join="inner").fillna(0.0) return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]), turns # --------------------------------------------------------------------------- # Small-cap: min-order $5, capitale condiviso 50/50 fra i 2 asset # --------------------------------------------------------------------------- def smallcap_net(df: pd.DataFrame, tgt: np.ndarray, capital: float, min_order: float = 5.0) -> tuple[pd.Series, int]: """Copia locale della logica di al.eval_weights_smallcap che restituisce la serie net (serve per combinare il book 2-asset). Cambi di nozionale < min_order NON eseguiti.""" c = df["close"].values.astype(float) tgt = np.clip(np.nan_to_num(np.asarray(tgt, float)), -10, 10) held = np.empty(len(tgt)) cur, n_tr = 0.0, 0 for i in range(len(tgt)): if abs(tgt[i] - cur) * capital >= min_order: cur = tgt[i] n_tr += 1 held[i] = cur r = al.simple_returns(c) pos = np.zeros(len(held)) pos[1:] = held[:-1] turn = np.abs(np.diff(pos, prepend=0.0)) net = pos * r - al.FEE_SIDE * turn net[0] = 0.0 idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) return pd.Series(net, index=idx), n_tr def smallcap_port(hs: tuple, capital: float) -> dict: """Book realistico a `capital`: target per-asset = 0.5*ensemble (quota 50/50). modeled = stesso book senza vincolo min-order (fee identiche proporzionali).""" nets_r, nets_m, ntr = {}, {}, 0 for a in ASSETS: df = get1h(a) t = 0.5 * ens_target(a, hs) nr, n = smallcap_net(df, t, capital) nets_r[a] = nr ntr += n ev = al.eval_weights(df, t) nets_m[a] = pd.Series(ev["net"], index=ev["idx"]) Jr = pd.concat(nets_r, axis=1, join="inner").fillna(0.0) Jm = pd.concat(nets_m, axis=1, join="inner").fillna(0.0) dr = al._to_daily(Jr[ASSETS[0]] + Jr[ASSETS[1]]) dm = al._to_daily(Jm[ASSETS[0]] + Jm[ASSETS[1]]) yrs = len(dr) / 365.25 return dict(sh_real=al._sh(dr), sh_model=al._sh(dm), haircut=al._sh(dm) - al._sh(dr), dd_real=al._dd_ret(dr), trades_per_year=ntr / yrs) # --------------------------------------------------------------------------- # Guardie # --------------------------------------------------------------------------- def sanity_h0() -> None: """h=0 deve riprodurre ESATTAMENTE il baseline (dati + strategia + metriche).""" for a in ASSETS: d0 = daily_off(a, 0) ref = al.get(a, "1d") for col in ("timestamp", "open", "high", "low", "close", "volume"): assert np.allclose(d0[col].values.astype(float), ref[col].values.astype(float), atol=0, rtol=0), \ f"resample_offset(h=0) != al.get('{a}','1d') su {col}" mine = port_daily_native(0) base = al.tp01_baseline_daily() assert len(mine) == len(base) and np.allclose(mine.values, base.values, atol=1e-12), \ "portafoglio h=0 != tp01_baseline_daily" mm, bb = dmetrics(mine), dmetrics(base) print(f"[SANITY] h=0 == baseline: OK (Sharpe FULL {mm['sh_full']:.4f} == " f"{bb['sh_full']:.4f}, HOLD {mm['sh_hold']:.4f} == {bb['sh_hold']:.4f})") def causality_guards() -> None: """(a) prefix-recompute sul daily resampled: target[i] non cambia aggiungendo futuro. (b) troncando il 1h, le barre daily complete restano identiche (solo l'ultima e' parziale).""" for a in ASSETS: for h in (0, 7, 13, 21): d = daily_off(a, h) t_full = TP.target_series(d) cut = len(d) - 250 t_pref = TP.target_series(d.iloc[:cut].reset_index(drop=True)) assert np.allclose(t_full[:cut], t_pref, atol=1e-12), \ f"prefix-recompute FAIL {a} h={h}" df1h = get1h(a) d_tr = resample_offset(df1h.iloc[:-500].reset_index(drop=True), h) k = len(d_tr) - 1 # l'ultima barra del troncato e' parziale per costruzione for col in ("timestamp", "close"): assert np.allclose(d_tr[col].values[:k].astype(float), d[col].values[:k].astype(float)), \ f"1h-truncation FAIL {a} h={h} {col}" print("[SANITY] guardie causalita' (prefix-recompute daily + troncamento 1h): OK") # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: print("=" * 96) print("r0702 — TP01 rebalance timing-luck: 24 ancore daily + ensemble tranching") print(f"CANONICAL={CANONICAL} fee 0.10% RT HOLD-OUT >= {HOLDOUT.date()}") print("=" * 96) sanity_h0() causality_guards() # ---- (1) per-offset, path daily-nativo -------------------------------- rows = [] for h in range(24): m = dmetrics(port_daily_native(h)) rows.append(dict(h=h, **m)) T = pd.DataFrame(rows).set_index("h") print("\n--- (1) PER-OFFSET (daily nativo, ancora h:00 UTC) ---") print(f"{'h':>3} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} {'maxDD':>7}") for h, r in T.iterrows(): tag = " <- canonico" if h == 0 else "" print(f"{h:>3} {r.sh_full:>7.3f} {r.sh_is:>7.3f} {r.sh_hold:>7.3f} " f"{r.cagr:>6.1%} {r.dd:>6.1%}{tag}") def pctl(col: str) -> float: v = T[col].values return float((v < v[0]).mean() + 0.5 * (v == v[0]).mean()) * 100 print("\nDistribuzione fra le 24 ancore (min / mediana / max / std) [percentile di h=0]:") for col, lbl in (("sh_full", "Sharpe FULL"), ("sh_is", "Sharpe IS(pre-2025)"), ("sh_hold", "Sharpe HOLD"), ("dd", "maxDD"), ("cagr", "CAGR")): v = T[col] print(f" {lbl:<20} {v.min():>7.3f} / {v.median():>7.3f} / {v.max():>7.3f} " f"/ std {v.std():.3f} h=0 al {pctl(col):.0f}° pctl (val {v.iloc[0]:.3f})") # ---- (2) ensemble headline, book aggregato su 1h ---------------------- print("\n--- (2) ENSEMBLE (book aggregato su grid 1h, fee su turnover netto) ---") print(f"{'config':<22} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} " f"{'maxDD':>7} {'DDhold':>7} {'turn/y':>7}") head = {} for name, hs in HEADLINE.items(): s, tpy = port_hourly(hs) m = dmetrics(s) head[name] = m print(f"{name:<22} {m['sh_full']:>7.3f} {m['sh_is']:>7.3f} {m['sh_hold']:>7.3f} " f"{m['cagr']:>6.1%} {m['dd']:>6.1%} {m['dd_hold']:>6.1%} {tpy:>7.1f}") print("(nota: 'K=1 (canonico h=0)' qui e' lo stesso book valutato sul grid 1h — " "differenze vs riga h=0 sopra = sola granularita' di compounding, non strategia)") # ---- (3) varianza della stima: rotazioni complete per famiglia -------- print("\n--- (3) DISPERSIONE fra rotazioni (nessuna selezione: TUTTE le rotazioni) ---") fams = { "singole (24)": [(h,) for h in range(24)], "K=2 h,h+12 (12)": [(h, h + 12) for h in range(12)], "K=3 h,h+8,h+16 (8)": [(h, h + 8, h + 16) for h in range(8)], "K=4 h,h+6,.. (6)": [tuple(h + 6 * j for j in range(4)) for h in range(6)], } print(f"{'famiglia':<20} {'ShFULL μ':>9} {'σ':>6} {'range':>13} " f"{'ShHOLD μ':>9} {'σ':>6} {'range':>13}") for name, rot in fams.items(): mf = [dmetrics(port_hourly(hs)[0]) for hs in rot] f = np.array([m["sh_full"] for m in mf]) ho = np.array([m["sh_hold"] for m in mf]) print(f"{name:<20} {f.mean():>9.3f} {f.std():>6.3f} " f"[{f.min():>5.3f},{f.max():>5.3f}] " f"{ho.mean():>9.3f} {ho.std():>6.3f} [{ho.min():>5.3f},{ho.max():>5.3f}]") # ---- (4) small-cap: haircut min-order per capitale -------------------- print("\n--- (4) SMALL-CAP (min order $5, capitale 50/50 sui 2 asset) ---") print(f"{'config':<22} {'cap':>7} {'Sh model':>9} {'Sh real':>8} {'haircut':>8} " f"{'DD real':>8} {'trade/y':>8}") for name, hs in HEADLINE.items(): for cap in (600, 2000, 10000): r = smallcap_port(hs, cap) print(f"{name:<22} {cap:>7} {r['sh_model']:>9.3f} {r['sh_real']:>8.3f} " f"{r['haircut']:>8.3f} {r['dd_real']:>7.1%} {r['trades_per_year']:>8.1f}") print("\nFatto. Nessuna selezione sull'hold-out: ensemble a priori, giudizio su " "struttura (varianza) + IS pre-2025; l'hold-out e' solo riportato.") if __name__ == "__main__": main()