"""TRACK I — ALTERNATIVE MOMENTUM FORMULATIONS + LONG-HORIZON REVERSAL (BTC & ETH, >=12h). Goal: (A) Find a momentum formulation that BEATS or DIVERSIFIES the canonical TP01 sign-blend (TSMOM 1-3-6m, vol-targeted, 50/50 BTC+ETH, 12h, Sharpe ~1.32). (B) Test the classic LONG-HORIZON REVERSAL effect (fade 12/18/24-month winners) as a potentially UNCORRELATED positive overlay, and a momentum+reversal blend. Honest harness (mirrors src/strategies/trend_portfolio.py exactly): - direction decided with data <= close[i]; positions HELD next bar (pos_held[1:] = tgt[:-1]); - vol-target by inverse PAST-ONLY realized vol (target_vol/vol), leverage-capped; - NET fees 0.10% RT (0.05%/side) on turnover; fee sweep included; - 12h / 1d only (sub-12h is dominated by costs/overfit and a prior 4h look-ahead bug); - OOS 65/35 split + per-year; robustness across lookbacks AND both assets; - correlation vs TP01 net returns reported for EVERY candidate. A candidate is INTERESTING only if net-positive OOS on BOTH assets AND either (higher portfolio Sharpe than TP01 ~1.32) OR (|corr to TP01| < ~0.3 and positive). Run: uv run python scripts/research/trackI_momentum_reversal.py """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from src.backtest.harness import load from src.strategies.trend_portfolio import resample_tf, simple_returns, realized_vol ASSETS = ["BTC", "ETH"] FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT TARGET_VOL = 0.20 LEVERAGE = 2.0 VOL_WIN_DAYS = 30 OOS_FRAC = 0.65 MONTH = 30 # days per "month" (calendar-consistent across TFs) # tf -> bars_per_day TF_BPD = {"12h": 2, "1d": 1} # --------------------------------------------------------------------------- # data # --------------------------------------------------------------------------- def get_df(asset: str, tf: str) -> pd.DataFrame: df = load(asset, "1h") rule = {"12h": "12h", "1d": "1D"}[tf] return resample_tf(df, rule) # --------------------------------------------------------------------------- # vol-target machinery (identical convention to TP01) # --------------------------------------------------------------------------- def build_target(direction, vol, long_only): d = np.clip(direction, 0, None) if long_only else direction scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0) tgt = np.clip(d * scal, -LEVERAGE, LEVERAGE) tgt[~np.isfinite(tgt)] = 0.0 return tgt def net_from_target(tgt, r, fee_side=FEE_SIDE): pos_held = np.zeros(len(tgt)) pos_held[1:] = tgt[:-1] gross = pos_held * r turn = np.abs(np.diff(pos_held, prepend=0.0)) net = gross - fee_side * turn net[0] = 0.0 return np.clip(net, -0.99, None) # --------------------------------------------------------------------------- # DIRECTION FORMULATIONS (each returns array in roughly [-1, 1], causal, decided <= close[i]) # --------------------------------------------------------------------------- def _log_mom(c, h): """log return over h bars; nan before h.""" m = np.full(len(c), np.nan) m[h:] = np.log(c[h:] / c[:-h]) return m def dir_signblend(c, bpd, horizons_m=(1, 3, 6)): """TP01 baseline: mean of sign(log return) over horizons.""" n = len(c) acc = np.zeros(n); cnt = np.zeros(n) for hm in horizons_m: h = hm * MONTH * bpd s = np.full(n, np.nan) s[h:] = np.sign(c[h:] / c[:-h] - 1.0) v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def dir_zscore(c, bpd, horizons_m=(1, 3, 6), std_win_m=12): """(i) Continuous momentum: z-scored cumulative log-return, tanh-bounded, multi-horizon avg.""" n = len(c); w = std_win_m * MONTH * bpd acc = np.zeros(n); cnt = np.zeros(n) for hm in horizons_m: h = hm * MONTH * bpd m = _log_mom(c, h) s = pd.Series(m) sd = s.rolling(w, min_periods=w // 3).std().values z = np.where((sd > 0) & np.isfinite(sd), m / sd, np.nan) d = np.tanh(z) v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def dir_riskadj(c, bpd, horizons_m=(1, 3, 6)): """(ii) Risk-adjusted momentum: h-horizon return / vol-of-that-horizon, tanh, multi-horizon.""" n = len(c); r = simple_returns(c) acc = np.zeros(n); cnt = np.zeros(n) for hm in horizons_m: h = hm * MONTH * bpd ret = np.full(n, np.nan); ret[h:] = c[h:] / c[:-h] - 1.0 # vol of the h-bar return = per-bar std over last h bars * sqrt(h) sd = pd.Series(r).rolling(h, min_periods=h // 2).std().values * np.sqrt(h) ra = np.where((sd > 0) & np.isfinite(sd), ret / sd, np.nan) d = np.tanh(ra) v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def _ema(c, span): return pd.Series(c).ewm(span=span, adjust=False).mean().values def dir_emacross(c, bpd, pairs_m=((1, 3), (2, 6), (3, 9))): """(iii) EMA-cross trend: mean of sign(ema_fast - ema_slow) over calendar-day pairs.""" n = len(c) acc = np.zeros(n); cnt = np.zeros(n) for fm, sm in pairs_m: ef = _ema(c, fm * MONTH * bpd) es = _ema(c, sm * MONTH * bpd) warm = sm * MONTH * bpd d = np.sign(ef - es) d[:warm] = np.nan v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def dir_macd(c, bpd): """(iii-b) Classic MACD with calendar spans (fast~1m, slow~2m, signal~0.75m): sign(macd-signal).""" n = len(c) fast = int(round(1.0 * MONTH * bpd)); slow = int(round(2.0 * MONTH * bpd)) sig = int(round(0.75 * MONTH * bpd)) macd = _ema(c, fast) - _ema(c, slow) signal = pd.Series(macd).ewm(span=sig, adjust=False).mean().values d = np.sign(macd - signal) d[:slow] = 0.0 return d def dir_donchian(c, bpd, n_m=2): """(iv) Donchian breakout (>=12h): +1 if close > prior-N max, -1 if < prior-N min, else hold.""" n = len(c); N = n_m * MONTH * bpd hi = pd.Series(c).rolling(N, min_periods=N).max().shift(1).values lo = pd.Series(c).rolling(N, min_periods=N).min().shift(1).values d = np.zeros(n); state = 0.0 for i in range(n): if np.isfinite(hi[i]) and c[i] >= hi[i]: state = 1.0 elif np.isfinite(lo[i]) and c[i] <= lo[i]: state = -1.0 d[i] = state return d def dir_accel(c, bpd, horizons_m=(3, 6), lag_m=1): """(v) Acceleration: sign of CHANGE in momentum (mom[i] - mom[i-lag]) i.e. 2nd derivative.""" n = len(c); lag = lag_m * MONTH * bpd acc = np.zeros(n); cnt = np.zeros(n) for hm in horizons_m: h = hm * MONTH * bpd m = _log_mom(c, h) dm = np.full(n, np.nan) dm[lag:] = m[lag:] - m[:-lag] d = np.sign(dm) v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def dir_mom12_1(c, bpd, lookbacks_m=(6, 12), skip_m=1): """(vi) 12-1 momentum: return from (i-L) to (i-skip), skipping the most-recent `skip` month. For index i (>=L): sign( c[i-skip] / c[i-L] - 1 ). Causal (uses data <= close[i-skip]).""" n = len(c); skip = skip_m * MONTH * bpd acc = np.zeros(n); cnt = np.zeros(n) for Lm in lookbacks_m: L = Lm * MONTH * bpd s = np.full(n, np.nan) # i runs L..n-1: c[i-skip] = c[L-skip : n-skip], c[i-L] = c[0 : n-L] s[L:] = np.sign(c[L - skip:n - skip] / c[:n - L] - 1.0) v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out def make_reversal(lookbacks_m): """(B) long-horizon reversal: -sign of long-horizon return (short past winners).""" def fn(c, bpd): n = len(c) acc = np.zeros(n); cnt = np.zeros(n) for Lm in lookbacks_m: L = Lm * MONTH * bpd s = np.full(n, np.nan) s[L:] = -np.sign(c[L:] / c[:-L] - 1.0) v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1 out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz] return out return fn def make_mom_minus_rev(mom_m, rev_m, rev_w=0.5): """Blend: long medium-term momentum + fade very-long-term extension (weighted).""" def fn(c, bpd): n = len(c) mom = dir_signblend(c, bpd, horizons_m=mom_m) rev_fn = make_reversal(rev_m) rev = rev_fn(c, bpd) return np.clip(mom + rev_w * rev, -1.0, 1.0) return fn # --------------------------------------------------------------------------- # run a formulation -> per-asset net series, combined portfolio series, metrics # --------------------------------------------------------------------------- def asset_net_series(asset, tf, dir_fn, long_only, fee_side=FEE_SIDE): df = get_df(asset, tf); bpd = TF_BPD[tf] c = df["close"].values.astype(float) r = simple_returns(c) bpy = bpd * 365.25 vol = realized_vol(r, VOL_WIN_DAYS * bpd, bpy) direction = dir_fn(c, bpd) tgt = build_target(direction, vol, long_only) net = net_from_target(tgt, r, fee_side) return pd.Series(net, index=pd.to_datetime(df["datetime"].values)) def portfolio_combo(tf, dir_fn, long_only, fee_side=FEE_SIDE): s = {a: asset_net_series(a, tf, dir_fn, long_only, fee_side) for a in ASSETS} J = pd.concat(s, axis=1, join="inner").fillna(0.0) combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values return pd.Series(combo, index=J.index), s def sharpe_of(series, bpy): r = series.values[np.isfinite(series.values)] return float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 def metrics_of(combo: pd.Series, bpy): idx = combo.index equity = np.cumprod(1.0 + np.clip(combo.values, -0.99, None)) sharpe = sharpe_of(combo, bpy) peak = np.maximum.accumulate(equity) dd = float(np.max((peak - equity) / peak)) years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25 total = equity[-1] / equity[0] cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0 eq = pd.Series(equity, index=idx) yearly = {} for y, g in eq.groupby(eq.index.year): if len(g) > 1 and g.iloc[0] > 0: v = g.values; pk = np.maximum.accumulate(v) yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((pk - v) / pk))) # OOS split k = int(len(combo) * OOS_FRAC) is_sh = sharpe_of(combo.iloc[:k], bpy) oos_sh = sharpe_of(combo.iloc[k:], bpy) return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1, yearly=yearly, is_sharpe=is_sh, oos_sharpe=oos_sh, equity=eq) ALL_YEARS = list(range(2018, 2027)) def fmt_yearly(yearly): return "".join((" . " if y not in yearly else f"{yearly[y][0]*100:>+6.0f}") for y in ALL_YEARS) # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- PART_A = [ ("baseline signblend 1-3-6m", dir_signblend), ("(i) z-score cum-ret", dir_zscore), ("(ii) risk-adj momentum", dir_riskadj), ("(iii) EMA-cross trend", dir_emacross), ("(iii-b) MACD", dir_macd), ("(iv) Donchian breakout", dir_donchian), ("(v) acceleration", dir_accel), ("(vi) 12-1 skip momentum", dir_mom12_1), ] def report_block(title, items, tf, long_only, tp_combo, bpy): mode = "LONG-FLAT" if long_only else "LONG-SHORT" print(f"\n{'='*112}\n {title} | TF={tf} mode={mode}\n{'='*112}") print(f" {'formulation':<26s} {'Shrp':>5s} {'IS':>5s} {'OOS':>5s} {'CAGR':>6s} " f"{'maxDD':>6s} {'corrTP':>7s} {'aBTC':>5s} {'aETH':>5s} per-year PnL%") print(f" {'':<26s} {'':>5s} {'':>5s} {'':>5s} {'':>6s} {'':>6s} {'':>7s} {'':>5s} {'':>5s} " + "".join(f"{y%100:>6d}" for y in ALL_YEARS)) results = {} for name, fn in items: combo, sleeves = portfolio_combo(tf, fn, long_only) m = metrics_of(combo, bpy) # per-asset standalone Sharpe a_sh = {a: sharpe_of(sleeves[a], bpy) for a in ASSETS} # correlation to TP01 (aligned inner) J = pd.concat([combo.rename("x"), tp_combo.rename("t")], axis=1, join="inner").dropna() corr = float(np.corrcoef(J["x"], J["t"])[0, 1]) if len(J) > 2 else float("nan") print(f" {name:<26s} {m['sharpe']:>5.2f} {m['is_sharpe']:>5.2f} {m['oos_sharpe']:>5.2f} " f"{m['cagr']*100:>+5.0f}% {m['max_dd']*100:>5.1f}% {corr:>7.2f} " f"{a_sh['BTC']:>5.2f} {a_sh['ETH']:>5.2f} {fmt_yearly(m['yearly'])}") results[name] = dict(metrics=m, corr=corr, combo=combo, a_sh=a_sh) return results def main(): print("#" * 112) print("# TRACK I — alternative momentum formulations + long-horizon reversal (BTCÐ, >=12h)") print("# vol-target 20%, lev cap 2x, fee 0.10% RT, positions +1 bar, 50/50 BTC+ETH. OOS 65/35.") print("#" * 112) for tf in ("12h", "1d"): bpy = TF_BPD[tf] * 365.25 # TP01 reference combo at this TF (long-flat canonical) for correlation tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True) tp_m = metrics_of(tp_combo, bpy) print(f"\n>>> TP01 reference @ {tf} (long-flat 1-3-6m): " f"Sharpe {tp_m['sharpe']:.2f} IS {tp_m['is_sharpe']:.2f} OOS {tp_m['oos_sharpe']:.2f} " f"CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%") # PART A — long-flat (fair vs canonical) and long-short report_block("PART A — momentum formulations", PART_A, tf, True, tp_combo, bpy) if tf == "12h": report_block("PART A — momentum formulations (long-short)", PART_A, tf, False, tp_combo, bpy) # ----- PART B: reversal + blends, focus 12h ----- tf = "12h"; bpy = TF_BPD[tf] * 365.25 tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True) rev_items = [ ("reversal 12m", make_reversal((12,))), ("reversal 18m", make_reversal((18,))), ("reversal 24m", make_reversal((24,))), ("reversal 12-18-24m", make_reversal((12, 18, 24))), ] print("\n\n" + "#" * 112) print("# PART B — LONG-HORIZON REVERSAL (fade past winners). Must be net-positive AND uncorrelated.") print("#" * 112) revB = report_block("PART B — reversal (long-short)", rev_items, tf, False, tp_combo, bpy) # reversal long-flat (long past losers only) for completeness report_block("PART B — reversal (long-flat)", rev_items, tf, True, tp_combo, bpy) blend_items = [ ("mom(1-6) - 0.5*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5)), ("mom(1-6) - 1.0*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 1.0)), ("mom(1-3) - 0.5*rev(18-24)", make_mom_minus_rev((1, 3), (18, 24), 0.5)), ] report_block("PART B — momentum + reversal blend", blend_items, tf, True, tp_combo, bpy) # ----- COMBINED PORTFOLIO: TP01 + best diversifier ----- print("\n\n" + "#" * 112) print("# COMBINED: TP01 (long-flat) + candidate diversifier, blended on net returns") print("#" * 112) tp_m = metrics_of(tp_combo, bpy) print(f" TP01 alone: Sharpe {tp_m['sharpe']:.3f} CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%") # candidates to try as overlay: the best A formulations + reversal variants overlays = { "z-score": (dir_zscore, True), "risk-adj": (dir_riskadj, True), "12-1 skip": (dir_mom12_1, True), "reversal 12-18-24 LS": (make_reversal((12, 18, 24)), False), "reversal 24m LS": (make_reversal((24,)), False), } for name, (fn, lo) in overlays.items(): cand, _ = portfolio_combo(tf, fn, lo) J = pd.concat([tp_combo.rename("t"), cand.rename("c")], axis=1, join="inner").fillna(0.0) corr = float(np.corrcoef(J["t"], J["c"])[0, 1]) for w in (0.5, 0.3, 0.2): mix = pd.Series((1 - w) * J["t"].values + w * J["c"].values, index=J.index) mm = metrics_of(mix, bpy) tag = f"TP01 + {w:.0%} {name}" print(f" {tag:<30s} Sharpe {mm['sharpe']:.3f} CAGR {mm['cagr']*100:+5.0f}% " f"maxDD {mm['max_dd']*100:4.1f}% OOS {mm['oos_sharpe']:.2f} (corr={corr:+.2f})") # ----- FEE SWEEP (robustness): 0.00 .. 0.40% RT ----- print("\n\n" + "#" * 112) print("# FEE SWEEP — portfolio Sharpe @12h across round-trip fees (0.00-0.40% RT)") print("#" * 112) sweep = [ ("baseline 1-3-6m (LF)", dir_signblend, True), ("z-score cum-ret (LF)", dir_zscore, True), ("MACD (LF)", dir_macd, True), ("mom(1-6)-0.5rev(12-24)(LF)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5), True), ("reversal 24m (LS)", make_reversal((24,)), False), ] rts = [0.0, 0.0005, 0.0010, 0.0020, 0.0040] print(f" {'formulation':<28s}" + "".join(f"{rt*100:>7.2f}%" for rt in rts) + " (RT)") for name, fn, lo in sweep: row = [sharpe_of(portfolio_combo(tf, fn, lo, fee_side=rt / 2)[0], bpy) for rt in rts] print(f" {name:<28s}" + "".join(f"{v:>8.2f}" for v in row)) print("\nDone. See verdict in the script docstring / diary.") if __name__ == "__main__": main()