research wave 1: 5 honest tracks on certified BTC/ETH + synthesis
- trackA trend, trackB ML, trackC mean-rev, trackD trend-portfolio, trackE xsec/ensemble - VERDICT: Track D vol-targeted BTC+ETH trend portfolio is the one robust deployable earner (Sharpe 1.0-1.32, DD 13-19%, positive every year 2019-2026) - mean-reversion confirmed dead on clean data; weak-but-real ML/trend residuals - honest: EUR50/day on 2000 in 1-2y is not reachable (needs ~137k capital or ruinous DD)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
"""TRACK A — TREND / MOMENTUM research on certified BTC/ETH (Deribit mainnet).
|
||||
|
||||
Honest harness only (src.backtest.harness). Rules enforced:
|
||||
* Direction & entry price decided with data <= close[i]; fill at close[i].
|
||||
* Net of fees (0.10% RT baseline) + fee sweep + leverage stress.
|
||||
* IS / OOS split (65/35). Grid robustness across params AND both assets.
|
||||
|
||||
Run: uv run python scripts/research/trackA_trend.py
|
||||
|
||||
This script is deliberately skeptical: it prints full grids so the reader can see
|
||||
whether an "edge" is a single lucky cell or a robust neighborhood. The verdict at the
|
||||
end is printed from the actual numbers, not asserted.
|
||||
"""
|
||||
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, backtest_signals, oos_split
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
TFS = ["1h", "15m", "5m"]
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal builders. Each returns a list[dict|None] of length len(df).
|
||||
# All features use ONLY data up to and including close[i]. Entry fills at close[i].
|
||||
# Position is approximated as a chained, non-overlapping hold of `hold` bars whose
|
||||
# direction is recomputed at each (free) bar -> amortizes fee over `hold` bars while
|
||||
# staying honest about responsiveness.
|
||||
# ---------------------------------------------------------------------------
|
||||
def sig_tsmom(df, lookback, hold, long_only=False):
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ent = [None] * n
|
||||
dirs = np.where(c[lookback:] > c[:-lookback], 1, -1)
|
||||
for k, d in enumerate(dirs):
|
||||
if long_only and d < 0:
|
||||
continue
|
||||
ent[lookback + k] = {"dir": int(d), "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def _ema(x, span):
|
||||
return pd.Series(x).ewm(span=span, adjust=False).mean().values
|
||||
|
||||
|
||||
def sig_ema_cross(df, fast, slow, hold, long_only=False):
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ef = _ema(c, fast)
|
||||
es = _ema(c, slow)
|
||||
ent = [None] * n
|
||||
for i in range(slow, n):
|
||||
d = 1 if ef[i] > es[i] else -1
|
||||
if long_only and d < 0:
|
||||
ent[i] = None
|
||||
continue
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def sig_donchian(df, lookback, hold, long_only=False):
|
||||
"""Breakout: close[i] strictly above prior `lookback` highs -> long; below lows -> short.
|
||||
Detection AND entry both at close[i] (honest)."""
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
n = len(c)
|
||||
ent = [None] * n
|
||||
# prior-window high/low EXCLUDING current bar (shift by 1) -> honest
|
||||
hh = pd.Series(h).rolling(lookback).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(lookback).min().shift(1).values
|
||||
for i in range(lookback, n):
|
||||
if not np.isfinite(hh[i]):
|
||||
continue
|
||||
if c[i] > hh[i]:
|
||||
d = 1
|
||||
elif c[i] < ll[i]:
|
||||
d = -1
|
||||
else:
|
||||
continue
|
||||
if long_only and d < 0:
|
||||
continue
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def sig_vol_scaled_tsmom(df, lookback, hold, vol_win, z_gate):
|
||||
"""Momentum gated by trend strength: only take a position when |past return| exceeds
|
||||
z_gate * rolling stdev of bar returns (regime gate). Honest: all <= close[i]."""
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
logret = np.zeros(n)
|
||||
logret[1:] = np.diff(np.log(c))
|
||||
vol = pd.Series(logret).rolling(vol_win).std().values
|
||||
ent = [None] * n
|
||||
start = max(lookback, vol_win) + 1
|
||||
for i in range(start, n):
|
||||
r = np.log(c[i] / c[i - lookback])
|
||||
v = vol[i] * np.sqrt(lookback)
|
||||
if not np.isfinite(v) or v == 0:
|
||||
continue
|
||||
z = r / v
|
||||
if abs(z) < z_gate:
|
||||
continue
|
||||
d = 1 if z > 0 else -1
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def eval_is_oos(df, entries, asset, tf, fee=FEE, lev=1.0):
|
||||
cut = oos_split(df, 0.65)
|
||||
full = backtest_signals(df, entries, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
ent_is = [e if i < cut else None for i, e in enumerate(entries)]
|
||||
ent_oos = [e if i >= cut else None for i, e in enumerate(entries)]
|
||||
m_is = backtest_signals(df, ent_is, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
m_oos = backtest_signals(df, ent_oos, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
return full, m_is, m_oos
|
||||
|
||||
|
||||
def buy_hold(df, cut=None):
|
||||
c = df["close"].values
|
||||
if cut is None:
|
||||
cut = oos_split(df, 0.65)
|
||||
return c[-1] / c[0] - 1, c[-1] / c[cut] - 1 # (full, oos)
|
||||
|
||||
|
||||
def print_benchmarks():
|
||||
print("\n" + "=" * 110)
|
||||
print("# BUY & HOLD BENCHMARK (the bar any long/short trend edge must clear)")
|
||||
print("# NOTE: OOS window is the LAST 35% = ~late-2023 -> 2026, a single (mostly bull) regime.")
|
||||
print("# 2018-2022 (bear+crash+bull+bear) is ENTIRELY in-sample. 'positive OOS' is weak evidence.")
|
||||
print("=" * 110)
|
||||
for tf in TFS:
|
||||
for asset in ASSETS:
|
||||
df = load(asset, tf)
|
||||
cut = oos_split(df, 0.65)
|
||||
bf, bo = buy_hold(df, cut)
|
||||
print(f" {asset} {tf:>3s} OOS starts {df['datetime'].iloc[cut].date()} "
|
||||
f"B&H full={bf*100:>+7.0f}% B&H OOS={bo*100:>+7.0f}%")
|
||||
|
||||
|
||||
def line(label, m):
|
||||
print(f" {label:<30s} tr={m.n_trades:>6d} wr={m.win_rate:>4.1f}% "
|
||||
f"ret={m.net_return*100:>+8.0f}% CAGR={m.cagr*100:>+6.1f}% "
|
||||
f"Sh={m.sharpe:>5.2f} DD={m.max_dd*100:>4.1f}% mkt={m.time_in_market*100:>3.0f}% "
|
||||
f"€/d={m.daily_profit(2000):>+6.2f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Experiments
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_grid(name, builder, param_grid, builder_kwargs_fn, tfs=TFS, assets=ASSETS):
|
||||
"""Generic grid runner. Prints OOS-focused table. Returns list of result dicts."""
|
||||
print("\n" + "=" * 110)
|
||||
print(f"# {name}")
|
||||
print("=" * 110)
|
||||
results = []
|
||||
for tf in tfs:
|
||||
for asset in assets:
|
||||
df = load(asset, tf)
|
||||
print(f"\n -- {asset} {tf} (n={len(df)}) --")
|
||||
for params in param_grid:
|
||||
ent = builder(df, **builder_kwargs_fn(params))
|
||||
full, m_is, m_oos = eval_is_oos(df, ent, asset, tf)
|
||||
tag = ",".join(f"{k}={v}" for k, v in params.items())
|
||||
line(f"{tag} [OOS]", m_oos)
|
||||
results.append(dict(name=name, asset=asset, tf=tf, params=params,
|
||||
full=full, is_=m_is, oos=m_oos))
|
||||
return results
|
||||
|
||||
|
||||
def summarize_survivors(all_results):
|
||||
print("\n" + "#" * 110)
|
||||
print("# SURVIVOR SCREEN — positive OOS net return AND positive full-sample, Sharpe(OOS)>0")
|
||||
print("#" * 110)
|
||||
survivors = [r for r in all_results
|
||||
if r["oos"].net_return > 0 and r["full"].net_return > 0
|
||||
and r["oos"].sharpe > 0 and r["oos"].n_trades >= 20]
|
||||
if not survivors:
|
||||
print(" NONE. No config is net-positive OOS with positive full-sample and Sharpe>0.")
|
||||
return []
|
||||
survivors.sort(key=lambda r: r["oos"].sharpe, reverse=True)
|
||||
# precompute B&H OOS per (asset,tf)
|
||||
bh = {}
|
||||
for tf in TFS:
|
||||
for a in ASSETS:
|
||||
bh[(a, tf)] = buy_hold(load(a, tf))[1]
|
||||
print(" (BEATS B&H = OOS return exceeds buy&hold over same OOS window; otherwise it's just beta)")
|
||||
for r in survivors[:40]:
|
||||
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
|
||||
bho = bh[(r["asset"], r["tf"])]
|
||||
beat = "BEATS B&H" if r["oos"].net_return > bho else "<= B&H (beta)"
|
||||
print(f" {r['name'][:18]:<18s} {r['asset']} {r['tf']:>3s} {tag:<28s} "
|
||||
f"OOS: ret={r['oos'].net_return*100:>+7.0f}% Sh={r['oos'].sharpe:>4.2f} "
|
||||
f"DD={r['oos'].max_dd*100:>4.0f}% €/d={r['oos'].daily_profit(2000):>+5.2f} | "
|
||||
f"B&H={bho*100:>+5.0f}% {beat}")
|
||||
return survivors
|
||||
|
||||
|
||||
def robustness_report(survivors):
|
||||
"""For top survivors, check fee sweep + leverage stress + cross-asset consistency."""
|
||||
if not survivors:
|
||||
return
|
||||
print("\n" + "#" * 110)
|
||||
print("# ROBUSTNESS: fee sweep (0.0005/0.001/0.0015/0.002) + leverage (1x/2x/3x) on top survivors")
|
||||
print("#" * 110)
|
||||
seen = set()
|
||||
for r in survivors[:8]:
|
||||
key = (r["name"], r["asset"], r["tf"], tuple(r["params"].items()))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
df = load(r["asset"], r["tf"])
|
||||
# rebuild entries
|
||||
builder = BUILDERS[r["name"]]
|
||||
ent = builder(df, **KW_FN[r["name"]](r["params"]))
|
||||
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
|
||||
print(f"\n {r['name']} {r['asset']} {r['tf']} {tag}")
|
||||
print(" fee sweep (OOS net return):")
|
||||
for fee in (0.0005, 0.001, 0.0015, 0.002):
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], fee=fee)
|
||||
flag = "" if m_oos.net_return > 0 else " <-- DIES"
|
||||
print(f" fee={fee:.4f}: OOS ret={m_oos.net_return*100:>+8.0f}% Sh={m_oos.sharpe:>4.2f}{flag}")
|
||||
print(" leverage stress (OOS, fee=0.001):")
|
||||
for lev in (1.0, 2.0, 3.0):
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], lev=lev)
|
||||
print(f" {lev:.0f}x: OOS ret={m_oos.net_return*100:>+8.0f}% "
|
||||
f"Sh={m_oos.sharpe:>4.2f} DD={m_oos.max_dd*100:>4.0f}% €/d={m_oos.daily_profit(2000):>+5.2f}")
|
||||
# yearly OOS
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"])
|
||||
print(" OOS yearly:")
|
||||
for y in sorted(m_oos.yearly):
|
||||
print(f" {y}: {m_oos.yearly[y]*100:>+7.1f}%")
|
||||
|
||||
|
||||
# registry so robustness_report can rebuild entries
|
||||
BUILDERS = {
|
||||
"TSMOM": sig_tsmom,
|
||||
"TSMOM_LONG": sig_tsmom,
|
||||
"EMA_CROSS": sig_ema_cross,
|
||||
"DONCHIAN": sig_donchian,
|
||||
"VOLSCALED_TSMOM": sig_vol_scaled_tsmom,
|
||||
}
|
||||
KW_FN = {
|
||||
"TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"]),
|
||||
"TSMOM_LONG": lambda p: dict(lookback=p["N"], hold=p["H"], long_only=True),
|
||||
"EMA_CROSS": lambda p: dict(fast=p["f"], slow=p["s"], hold=p["H"]),
|
||||
"DONCHIAN": lambda p: dict(lookback=p["N"], hold=p["H"]),
|
||||
"VOLSCALED_TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"], vol_win=p["vw"], z_gate=p["z"]),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
pd.set_option("display.width", 200)
|
||||
print_benchmarks()
|
||||
all_results = []
|
||||
|
||||
# ---- 1. TSMOM (long/short) ----
|
||||
tsmom_grid = [dict(N=n, H=h) for n in (10, 20, 50, 100, 200) for h in (6, 12, 24, 48)]
|
||||
all_results += run_grid("TSMOM", sig_tsmom, tsmom_grid,
|
||||
KW_FN["TSMOM"])
|
||||
|
||||
# ---- 2. TSMOM long-only (crypto has strong upward drift; honest to test) ----
|
||||
all_results += run_grid("TSMOM_LONG", lambda df, **k: sig_tsmom(df, long_only=True, **k),
|
||||
[dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)],
|
||||
KW_FN["TSMOM"])
|
||||
|
||||
# ---- 3. EMA crossover ----
|
||||
ema_grid = [dict(f=f, s=s, H=h)
|
||||
for (f, s) in ((10, 30), (20, 50), (20, 100), (50, 200))
|
||||
for h in (12, 24, 48)]
|
||||
all_results += run_grid("EMA_CROSS", sig_ema_cross, ema_grid, KW_FN["EMA_CROSS"])
|
||||
|
||||
# ---- 4. Donchian breakout ----
|
||||
don_grid = [dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)]
|
||||
all_results += run_grid("DONCHIAN", sig_donchian, don_grid, KW_FN["DONCHIAN"])
|
||||
|
||||
# ---- 5. Vol-scaled / regime-gated TSMOM ----
|
||||
vs_grid = [dict(N=n, H=h, vw=vw, z=z)
|
||||
for n in (20, 50, 100) for h in (24, 48)
|
||||
for vw in (50, 100) for z in (0.5, 1.0)]
|
||||
all_results += run_grid("VOLSCALED_TSMOM", sig_vol_scaled_tsmom, vs_grid,
|
||||
KW_FN["VOLSCALED_TSMOM"])
|
||||
|
||||
# ---- survivor screen + robustness ----
|
||||
survivors = summarize_survivors(all_results)
|
||||
robustness_report(survivors)
|
||||
|
||||
# ---- cross-asset robustness note ----
|
||||
print("\n" + "#" * 110)
|
||||
print("# CROSS-ASSET / CROSS-TF CONSISTENCY of survivors (a real edge holds on BOTH BTC & ETH)")
|
||||
print("#" * 110)
|
||||
from collections import defaultdict
|
||||
by_strat = defaultdict(list)
|
||||
for r in survivors:
|
||||
by_strat[(r["name"], r["tf"], tuple(r["params"].items()))].append(r["asset"])
|
||||
both = [(k, v) for k, v in by_strat.items() if set(v) >= {"BTC", "ETH"}]
|
||||
if not both:
|
||||
print(" No single (strategy, tf, params) cell is an OOS survivor on BOTH BTC and ETH.")
|
||||
print(" => any apparent edge is asset/regime-specific, not a robust trend edge.")
|
||||
else:
|
||||
for (name, tf, params), assets in both:
|
||||
print(f" {name} {tf} {dict(params)} survives on: {assets}")
|
||||
|
||||
print("\nDONE. Read the survivor screen + robustness above for the honest verdict.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user