"""TRACK D — ROBUST WALK-FORWARD TREND PORTFOLIO (BTC+ETH), vol-targeted + leverage. Thesis under test: trend-following's real value in crypto is DRAWDOWN REDUCTION vs buy & hold (it sidesteps crashes). That lower DD lets us apply LEVERAGE and DIVERSIFY across BTC+ETH to build a deployable, risk-adjusted EARNING system, even if each single signal has only a modest Sharpe. Question: does a properly-built, anti-overfit trend portfolio actually EARN robustly across regimes 2018-2026? METHOD (strict, honest): * NO LOOK-AHEAD. We build equity directly from a TARGET-POSITION series. - target[i] is decided using ONLY data <= close[i]. - target[i] is HELD during the next bar (close[i] -> close[i+1]). - bar return r[t] = close[t]/close[t-1] - 1 (uses close[t], close[t-1]; both <= t). - pnl on bar t = target[t-1] * r[t] (shift positions by 1 -> no leakage). - fees: fee_per_side * |target[t-1] - target[t-2]| (turnover cost, charged on rebalances). This is the harness's documented "build your own equity from a position series" path. * VOL-TARGETING: position = directional_signal * (target_vol / realized_vol), capped at leverage. realized_vol uses past returns only (rolling std up to close[i]). This is the main lever — it lets a modest signal run at a controlled risk level. * WALK-FORWARD / MULTI-REGIME: per-year returns for ALL years 2018-2026. Plus an explicit EARLY (2018-2021) tune / LATE (2022-2026) confirm split. ONE robust param set, both assets. * PORTFOLIO: equal-weight BTC+ETH sleeves, rebalanced each bar. Report combined Sharpe/DD/CAGR. * GRID ROBUSTNESS: chosen config must be positive across a neighborhood AND across regimes. * FEE & LEVERAGE SWEEP: fee/side 0.0005..0.002 (0.10..0.40% RT); leverage cap 1x..3x. Run: uv run python scripts/research/trackD_trendport.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 ASSETS = ["BTC", "ETH"] TF = "1h" BARS_PER_YEAR = 24 * 365.25 # 1h bars FEE_SIDE = 0.0005 # 0.05% per side = 0.10% round trip (Deribit taker) # horizons in 1h bars ~ 1 / 3 / 6 "months" (30d months) H1, H3, H6 = 30 * 24, 90 * 24, 180 * 24 # --------------------------------------------------------------------------- # Core building blocks (all <= close[i]) # --------------------------------------------------------------------------- def simple_returns(c: np.ndarray) -> np.ndarray: r = np.zeros(len(c)) r[1:] = c[1:] / c[:-1] - 1.0 return r def realized_vol(r: np.ndarray, win: int) -> np.ndarray: """Annualized realized vol from bar returns up to and including i (no leakage).""" vol = pd.Series(r).rolling(win, min_periods=win // 2).std().values return vol * np.sqrt(BARS_PER_YEAR) def sig_tsmom_blend(c: np.ndarray, horizons=(H1, H3, H6)) -> np.ndarray: """Multi-horizon TSMOM: average of sign(close[i]/close[i-h]-1) over horizons -> [-1,1].""" n = len(c) acc = np.zeros(n) cnt = np.zeros(n) for h in horizons: s = np.full(n, np.nan) s[h:] = np.sign(c[h:] / c[:-h] - 1.0) valid = np.isfinite(s) acc[valid] += s[valid] cnt[valid] += 1 out = np.zeros(n) nz = cnt > 0 out[nz] = acc[nz] / cnt[nz] return out def sig_ma_slope(c: np.ndarray, span: int, slope_win: int = 24) -> np.ndarray: """Sign of the slope of an EMA: ema[i] vs ema[i-slope_win]. -> {-1,0,+1}.""" ema = pd.Series(c).ewm(span=span, adjust=False).mean().values n = len(c) out = np.zeros(n) out[slope_win:] = np.sign(ema[slope_win:] - ema[:-slope_win]) return out def sig_donchian_state(c, h, l, n_break: int, n_exit: int) -> np.ndarray: """Donchian breakout with trailing (channel) stop, returns a stateful {-1,0,+1} series. Long when close[i] > prior n_break high; exit/flip via prior n_exit low channel (trailing). Detection uses prior-window extremes EXCLUDING current bar (shift 1) and close[i] -> honest.""" hh = pd.Series(h).rolling(n_break).max().shift(1).values ll = pd.Series(l).rolling(n_break).min().shift(1).values xh = pd.Series(h).rolling(n_exit).max().shift(1).values # trailing exit for shorts xl = pd.Series(l).rolling(n_exit).min().shift(1).values # trailing exit for longs n = len(c) state = np.zeros(n) pos = 0 for i in range(n): if not np.isfinite(hh[i]): state[i] = 0 continue if pos == 1: if c[i] < xl[i]: pos = 0 elif pos == -1: if c[i] > xh[i]: pos = 0 if pos == 0: if c[i] > hh[i]: pos = 1 elif c[i] < ll[i]: pos = -1 state[i] = pos return state # --------------------------------------------------------------------------- # Position construction (vol-targeting + leverage cap + long/flat option) # --------------------------------------------------------------------------- def build_target(direction: np.ndarray, vol: np.ndarray, target_vol: float, leverage: float, long_only: bool) -> np.ndarray: """target[i] = direction[i] * (target_vol / vol[i]), clipped to [-leverage, leverage]. direction[i] in [-1,1]; vol[i] annualized realized vol (<= close[i]). long_only clips <0 to 0.""" d = direction.copy() if long_only: d = np.clip(d, 0, None) scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0) tgt = d * scal tgt = np.clip(tgt, -leverage, leverage) tgt[~np.isfinite(tgt)] = 0.0 return tgt def equity_from_target(target: np.ndarray, r: np.ndarray, fee_side: float): """Build equity from a target-position series with NO look-ahead. pos held during bar t = target[t-1]; pnl[t] = target[t-1]*r[t]; fee on turnover.""" n = len(target) pos_held = np.zeros(n) pos_held[1:] = target[:-1] # held during bar t = decided at close[t-1] gross = pos_held * r turn = np.abs(np.diff(pos_held, prepend=0.0)) net = gross - fee_side * turn net[0] = 0.0 net = np.clip(net, -0.99, None) # cannot lose more than capital on a bar equity = np.cumprod(1.0 + net) return equity, net # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- def metrics(equity: np.ndarray, net: np.ndarray, ts: pd.Series) -> dict: rr = net[np.isfinite(net)] sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(BARS_PER_YEAR)) if np.std(rr) > 0 else 0.0 peak = np.maximum.accumulate(equity) dd = float(np.max((peak - equity) / peak)) span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400 years = span_days / 365.25 total = equity[-1] / equity[0] cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0 eq_s = pd.Series(equity, index=ts) yearly = {} for y, g in eq_s.groupby(eq_s.index.year): if len(g) > 1 and g.iloc[0] > 0: yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1) daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0 return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1, yearly=yearly, daily_2k=daily_2k, vol_ann=float(np.std(rr) * np.sqrt(BARS_PER_YEAR))) def avg_gross(target: np.ndarray) -> float: """Average absolute position = average gross leverage actually deployed.""" t = target[np.isfinite(target)] return float(np.mean(np.abs(t))) if len(t) else 0.0 def fmt(m, label): return (f" {label:<34s} ret={m['total']*100:>+9.0f}% CAGR={m['cagr']*100:>+6.1f}% " f"Sh={m['sharpe']:>5.2f} DD={m['max_dd']*100:>4.1f}% volA={m['vol_ann']*100:>4.0f}% " f"€/d(2k)={m['daily_2k']:>+7.2f}") # --------------------------------------------------------------------------- # Strategy assembly # --------------------------------------------------------------------------- def make_direction(df: pd.DataFrame, kind: str, params: dict) -> np.ndarray: c = df["close"].values.astype(float) if kind == "TSMOM": return sig_tsmom_blend(c, params.get("horizons", (H1, H3, H6))) if kind == "MASLOPE": return sig_ma_slope(c, params["span"], params.get("slope_win", 24)) if kind == "DONCHIAN": h = df["high"].values.astype(float) l = df["low"].values.astype(float) return sig_donchian_state(c, h, l, params["n_break"], params["n_exit"]) raise ValueError(kind) def run_asset(df, kind, params, target_vol, leverage, long_only, fee_side=FEE_SIDE): c = df["close"].values.astype(float) r = simple_returns(c) vol = realized_vol(r, params.get("vol_win", 30 * 24)) direction = make_direction(df, kind, params) tgt = build_target(direction, vol, target_vol, leverage, long_only) equity, net = equity_from_target(tgt, r, fee_side) ts = df["datetime"] m = metrics(equity, net, ts) m["target"] = tgt m["net"] = net m["ts"] = ts m["equity"] = equity return m def buy_hold(df): c = df["close"].values.astype(float) r = simple_returns(c) equity = np.cumprod(1.0 + np.clip(r, -0.99, None)) return metrics(equity, r, df["datetime"]) # --------------------------------------------------------------------------- # Portfolio (equal-weight BTC+ETH, rebalanced each bar on common timestamps) # --------------------------------------------------------------------------- def portfolio(net_btc_df, net_eth_df, w=(0.5, 0.5)): """Combine two per-bar net-return series aligned on common timestamps.""" a = pd.Series(net_btc_df["net"], index=net_btc_df["ts"].values) b = pd.Series(net_eth_df["net"], index=net_eth_df["ts"].values) j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0) combo = w[0] * j["a"].values + w[1] * j["b"].values equity = np.cumprod(1.0 + np.clip(combo, -0.99, None)) ts = pd.Series(pd.to_datetime(j.index)) return metrics(equity, combo, ts) # --------------------------------------------------------------------------- # Reporting helpers # --------------------------------------------------------------------------- ALL_YEARS = list(range(2018, 2027)) def print_yearly_row(label, m): cells = [] for y in ALL_YEARS: v = m["yearly"].get(y) cells.append(" . " if v is None else f"{v*100:>+6.0f}%") print(f" {label:<26s} " + " ".join(cells)) def yearly_header(): print(f" {'config':<26s} " + " ".join(f"{y:>7d}" for y in ALL_YEARS)) # --------------------------------------------------------------------------- # Experiments # --------------------------------------------------------------------------- def main(): pd.set_option("display.width", 220) dfs = {a: load(a, TF) for a in ASSETS} print("=" * 130) print("# TRACK D — VOL-TARGETED TREND PORTFOLIO (BTC+ETH, 1h, Deribit certified)") print("# Equity built from target-position series; positions shifted +1 bar (no look-ahead);") print("# fee = 0.05%/side (0.10% RT) on turnover. Vol-targeting scales by inverse realized vol.") print("=" * 130) print("\n# BUY & HOLD BENCHMARK (the DD/return bar trend must beat on risk-adjusted basis)") yearly_header() bh = {} for a in ASSETS: bh[a] = buy_hold(dfs[a]) print(fmt(bh[a], f"B&H {a}")) print_yearly_row(f"B&H {a} yearly", bh[a]) bh_port = portfolio({"net": simple_returns(dfs["BTC"]["close"].values), "ts": dfs["BTC"]["datetime"]}, {"net": simple_returns(dfs["ETH"]["close"].values), "ts": dfs["ETH"]["datetime"]}) print(fmt(bh_port, "B&H 50/50 BTC+ETH")) print_yearly_row("B&H port yearly", bh_port) # ---------------------------------------------------------------------- # 1. BROAD SCAN: strategies x vol-target x leverage x long-only, per asset & portfolio # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 1) BROAD SCAN — per-asset & 50/50 portfolio, vol-target=20%, leverage cap 2x") print("# (TSMOM 1-3-6m blend / MA-slope / Donchian-trailing; long-short vs long-flat)") print("=" * 130) strat_defs = [ ("TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24)), ("MASLOPE", dict(span=200, slope_win=48, vol_win=30 * 24)), ("DONCHIAN", dict(n_break=200, n_exit=100, vol_win=30 * 24)), ] for long_only in (False, True): mode = "LONG-FLAT" if long_only else "LONG-SHORT" print(f"\n --- {mode} ---") for kind, params in strat_defs: sleeves = {} for a in ASSETS: m = run_asset(dfs[a], kind, params, target_vol=0.20, leverage=2.0, long_only=long_only) sleeves[a] = m print(fmt(m, f"{kind} {a}")) port = portfolio(sleeves["BTC"], sleeves["ETH"]) print(fmt(port, f"{kind} PORTFOLIO 50/50")) print_yearly_row(f"{kind} port yearly", port) # ---------------------------------------------------------------------- # 2. GRID ROBUSTNESS on the portfolio: vol-target x leverage x vol-window # using the multi-horizon TSMOM blend (the most diversified trend signal) # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 2) GRID ROBUSTNESS — TSMOM 1-3-6m blend, 50/50 portfolio (LONG-SHORT)") print("# Sweep target-vol x leverage-cap. A real config is positive across the neighborhood.") print("=" * 130) hdr = " " + "tvol\\lev".ljust(8) + "".join(f"{lev:.0f}x".rjust(26) for lev in (1.0, 1.5, 2.0, 3.0)) print(hdr) grid = {} for tvol in (0.10, 0.15, 0.20, 0.30, 0.40): row = f" {tvol*100:>6.0f}% " for lev in (1.0, 1.5, 2.0, 3.0): sleeves = {} for a in ASSETS: sleeves[a] = run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=tvol, leverage=lev, long_only=False) port = portfolio(sleeves["BTC"], sleeves["ETH"]) grid[(tvol, lev)] = port row += f" Sh{port['sharpe']:>4.2f} DD{port['max_dd']*100:>3.0f} C{port['cagr']*100:>+4.0f}" print(row) # ---------------------------------------------------------------------- # 3. HORIZON-SET robustness (is the 1-3-6m blend a plateau or a lucky combo?) # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 3) HORIZON-SET ROBUSTNESS — TSMOM blend, portfolio, tvol=20% lev=2x (LONG-SHORT)") print("=" * 130) horizon_sets = { "1m only": (H1,), "3m only": (H3,), "6m only": (H6,), "1-3m": (H1, H3), "3-6m": (H3, H6), "1-3-6m": (H1, H3, H6), "1-2-4m": (30 * 24, 60 * 24, 120 * 24), "2-4-8m": (60 * 24, 120 * 24, 240 * 24), } yearly_header() for name, hs in horizon_sets.items(): sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) print(fmt(port, f"TSMOM {name}")) print() for name, hs in horizon_sets.items(): sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) print_yearly_row(f"{name}", port) # ---------------------------------------------------------------------- # 4. WALK-FORWARD: EARLY (<=2021) tune / LATE (>=2022) confirm # Same single param set for BOTH assets; we just split the equity by date. # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 4) WALK-FORWARD — split portfolio equity into EARLY (2018-2021) vs LATE (2022-2026)") print("# One param set, both assets. Both halves must earn for the edge to be regime-robust.") print("=" * 130) cfg = dict(kind="TSMOM", params=dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False) sleeves = {a: run_asset(dfs[a], cfg["kind"], cfg["params"], cfg["target_vol"], cfg["leverage"], cfg["long_only"]) for a in ASSETS} a = pd.Series(sleeves["BTC"]["net"], index=sleeves["BTC"]["ts"].values) b = pd.Series(sleeves["ETH"]["net"], index=sleeves["ETH"]["ts"].values) j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0) combo = 0.5 * j["a"].values + 0.5 * j["b"].values idx = pd.to_datetime(j.index) for lab, mask in (("EARLY 2018-2021", idx.year <= 2021), ("LATE 2022-2026", idx.year >= 2022)): sub = combo[mask] eq = np.cumprod(1.0 + np.clip(sub, -0.99, None)) m = metrics(eq, sub, pd.Series(idx[mask])) print(fmt(m, lab)) print_yearly_row(f"{lab} yearly", m) # ---------------------------------------------------------------------- # 5. FEE & LEVERAGE SWEEP on the headline portfolio config # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 5) FEE & LEVERAGE SWEEP — TSMOM 1-3-6m blend portfolio, tvol=20%") print("=" * 130) print(" fee sweep (leverage cap 2x):") for fee in (0.0005, 0.00075, 0.001, 0.0015, 0.002): sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False, fee_side=fee) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) print(fmt(port, f"fee/side={fee:.5f} (RT={2*fee*100:.2f}%)")) print(" leverage sweep (fee 0.05%/side):") for lev in (1.0, 1.5, 2.0, 2.5, 3.0): sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=lev, long_only=False) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) print(fmt(port, f"leverage cap={lev:.1f}x")) # ---------------------------------------------------------------------- # 6. HEADLINE ROBUST CONFIG — full per-year table + sleeves + portfolio # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 6) HEADLINE ROBUST CONFIG: TSMOM 1-3-6m blend, vol-target 20%, leverage cap 2x, LONG-SHORT") print("=" * 130) yearly_header() sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS} for a in ASSETS: print(fmt(sleeves[a], f"sleeve {a}")) print_yearly_row(f"sleeve {a} yearly", sleeves[a]) port = portfolio(sleeves["BTC"], sleeves["ETH"]) print(fmt(port, "PORTFOLIO 50/50")) print_yearly_row("PORTFOLIO yearly", port) # also long-flat headline (deployable variant — no shorts/funding complexity) print() sleeves_lf = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=True) for a in ASSETS} port_lf = portfolio(sleeves_lf["BTC"], sleeves_lf["ETH"]) print(fmt(port_lf, "PORTFOLIO 50/50 LONG-FLAT")) print_yearly_row("PORTFOLIO LF yearly", port_lf) # ---------------------------------------------------------------------- # 7. €/DAY ON 2000 — what leverage gets us toward 50/day, and the DD it costs # ---------------------------------------------------------------------- print("\n" + "=" * 130) print("# 7) PATH TO ~50 EUR/day on 2000 — the REAL lever is TARGET-VOL, not the leverage cap.") print("# At tvol=20%% on 60-80%% crypto vol, positions stay sub-1x: the leverage cap NEVER binds.") print("# To deploy real leverage you raise target-vol; Sharpe is ~constant, DD scales ~linearly.") print("# 'avg gross' = mean |position| = leverage actually used. (cap fixed at 3x here)") print("=" * 130) print(f" {'target_vol':<12s}{'avgGross':>10s}{'CAGR':>9s}{'Sharpe':>9s}{'maxDD':>8s}" f"{'€/day(2k,avg)':>16s}{'final/2k':>12s}") for tvol in (0.20, 0.40, 0.60, 0.80, 1.00): sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=tvol, leverage=3.0, long_only=False) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) ag = 0.5 * (avg_gross(sleeves["BTC"]["target"]) + avg_gross(sleeves["ETH"]["target"])) print(f" {tvol*100:>8.0f}% {ag:>9.2f}x{port['cagr']*100:>+8.1f}%{port['sharpe']:>9.2f}" f"{port['max_dd']*100:>7.1f}%{port['daily_2k']:>+16.2f}{(1+port['total']):>12.1f}x") # steady-state €/day at current capital under headline CAGR print("\n Steady-state €/day implied by headline CAGR (NOT path-dependent), at various capital:") sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24), target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS} port = portfolio(sleeves["BTC"], sleeves["ETH"]) g = port["cagr"] daily_rate = (1 + g) ** (1 / 365.25) - 1 for cap in (2000, 5000, 10000, 50000, 100000): print(f" capital={cap:>7d} ~€/day = {cap*daily_rate:>+8.2f} (CAGR={g*100:+.1f}%)") need = 50.0 / daily_rate if daily_rate > 0 else float("inf") print(f"\n To average ~50 EUR/day at this CAGR you'd need ~{need:,.0f} capital " f"(at leverage 2x, maxDD~{port['max_dd']*100:.0f}%).") print("\nDONE. See the report/diary for the honest verdict.") if __name__ == "__main__": main()