integra(TP01): merge ricerca branch strategy-research-2026-06 (squash) — strategia vincente + harness + track A-E
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py (le 5 track della ricerca)
- scripts/live/paper_trend.py paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)
Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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()
|
||||
@@ -0,0 +1,398 @@
|
||||
"""TRACK B — Machine-learning / feature-prediction on BTC & ETH (Deribit-certified).
|
||||
|
||||
Honest, strict walk-forward ML research. The whole point is to NOT repeat the death of
|
||||
the old library (look-ahead). Everything here obeys:
|
||||
|
||||
* Features for bar i use ONLY data <= close[i] (all rolling windows are backward).
|
||||
* Labels (sign of forward return over H bars) use close[i+H]; in walk-forward we only
|
||||
train on samples whose label is FULLY realized in the past relative to the prediction
|
||||
bar (a gap of H is enforced between train-end and the prediction block).
|
||||
* Scaler + model are fit ONLY on past data, retrained periodically, never on the future.
|
||||
* Net of fees (fee_rt sweep 0.0005 .. 0.002, baseline 0.001). Turnover reported.
|
||||
* Grid over W (lookback for training), H (horizon), threshold, asset, tf.
|
||||
* A final held-out segment (last HELD_OUT_FRAC) is NEVER used to choose configs;
|
||||
configs are selected on the DEV portion, then confirmed once on the held-out tail.
|
||||
|
||||
Run: uv run python scripts/research/trackB_ml.py
|
||||
uv run python scripts/research/trackB_ml.py --quick (smaller grid, faster)
|
||||
uv run python scripts/research/trackB_ml.py --gbm (also try GradientBoosting)
|
||||
|
||||
Entry convention (harness): for a signalled bar i we open at close[i] in the predicted
|
||||
direction and hold up to H bars (max_bars=H, no TP/SL) — a pure test of directional sign.
|
||||
No-overlap is enforced by the harness, so trades are naturally spaced >= H bars.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from src.backtest.harness import backtest_signals, load
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
HELD_OUT_FRAC = 0.25 # final tail reserved for confirmation only
|
||||
RETRAIN_K = 250 # retrain every K bars (block prediction)
|
||||
MIN_TRAIN = 400 # minimum usable training samples
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature engineering — ALL backward-looking (safe at close[i])
|
||||
# ---------------------------------------------------------------------------
|
||||
def _rsi(close: pd.Series, n: int = 14) -> pd.Series:
|
||||
d = close.diff()
|
||||
up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean()
|
||||
dn = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).fillna(50.0)
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, n: int = 14) -> pd.Series:
|
||||
h, l, c = df["high"], df["low"], df["close"]
|
||||
pc = c.shift(1)
|
||||
tr = pd.concat([(h - l), (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1 / n, adjust=False).mean()
|
||||
|
||||
|
||||
def build_features(df: pd.DataFrame) -> tuple[np.ndarray, list[str], np.ndarray]:
|
||||
"""Return (X, names, warmup_valid_mask). Every column known at close[i]."""
|
||||
c = df["close"].astype(float)
|
||||
h = df["high"].astype(float)
|
||||
l = df["low"].astype(float)
|
||||
o = df["open"].astype(float)
|
||||
v = df["volume"].astype(float)
|
||||
logc = np.log(c)
|
||||
|
||||
feats: dict[str, pd.Series] = {}
|
||||
|
||||
# multi-lag simple returns (ret[i] uses close[i],close[i-k] -> known at i)
|
||||
for k in (1, 2, 3, 6, 12, 24):
|
||||
feats[f"ret{k}"] = c.pct_change(k)
|
||||
|
||||
# candle geometry (current bar fully known at its close)
|
||||
rng = (h - l).replace(0, np.nan)
|
||||
feats["body"] = (c - o) / rng
|
||||
feats["upsh"] = (h - np.maximum(c, o)) / rng
|
||||
feats["dnsh"] = (np.minimum(c, o) - l) / rng
|
||||
feats["range_n"] = (h - l) / c
|
||||
# one-lag candle geometry
|
||||
feats["body1"] = ((c - o) / rng).shift(1)
|
||||
|
||||
# momentum/acceleration
|
||||
feats["mom48"] = c.pct_change(48)
|
||||
feats["accel"] = c.pct_change(6) - c.pct_change(12)
|
||||
|
||||
# RSI
|
||||
feats["rsi14"] = _rsi(c, 14) / 100.0
|
||||
|
||||
# ATR-normalized extension from a trend baseline
|
||||
ema = c.ewm(span=24, adjust=False).mean()
|
||||
atr = _atr(df, 14)
|
||||
feats["ext_atr"] = (c - ema) / atr.replace(0, np.nan)
|
||||
|
||||
# realized vol (std of 1-bar returns)
|
||||
r1 = c.pct_change()
|
||||
feats["rvol24"] = r1.rolling(24).std()
|
||||
feats["rvol72"] = r1.rolling(72).std()
|
||||
feats["vol_ratio"] = feats["rvol24"] / feats["rvol72"].replace(0, np.nan)
|
||||
|
||||
# position of close within recent window (0=low,1=high)
|
||||
for w in (24, 72):
|
||||
lo = l.rolling(w).min()
|
||||
hi = h.rolling(w).max()
|
||||
feats[f"pos{w}"] = (c - lo) / (hi - lo).replace(0, np.nan)
|
||||
|
||||
# volume z-score
|
||||
vlog = np.log1p(v)
|
||||
feats["volz"] = (vlog - vlog.rolling(72).mean()) / vlog.rolling(72).std().replace(0, np.nan)
|
||||
|
||||
names = list(feats.keys())
|
||||
X = np.column_stack([feats[k].to_numpy(dtype=float) for k in names])
|
||||
valid = np.isfinite(X).all(axis=1)
|
||||
return X, names, valid
|
||||
|
||||
|
||||
def forward_labels(df: pd.DataFrame, H: int):
|
||||
"""label[i] = 1 if close[i+H] > close[i] else 0 ; fwd[i] = forward return."""
|
||||
c = df["close"].to_numpy(float)
|
||||
n = len(c)
|
||||
fwd = np.full(n, np.nan)
|
||||
fwd[: n - H] = c[H:] / c[: n - H] - 1.0
|
||||
y = (fwd > 0).astype(float)
|
||||
lab_valid = np.isfinite(fwd)
|
||||
return y, fwd, lab_valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strict walk-forward probability
|
||||
# ---------------------------------------------------------------------------
|
||||
def walk_forward_proba(X, y, feat_valid, lab_valid, warmup, W, H, K, model_factory):
|
||||
"""Return proba_up[i] for all i (NaN where not predicted). No leakage:
|
||||
when predicting block starting at b, training labels must be realized: i + H <= b-1,
|
||||
i.e. train indices < b - H. Training window is the last W such indices."""
|
||||
n = len(y)
|
||||
proba = np.full(n, np.nan)
|
||||
start = warmup + W + H
|
||||
b = start
|
||||
while b < n:
|
||||
end_block = min(b + K, n)
|
||||
train_hi = b - H # exclusive; ensures label realized by b-1
|
||||
train_lo = max(warmup, train_hi - W)
|
||||
idx = np.arange(train_lo, train_hi)
|
||||
idx = idx[feat_valid[idx] & lab_valid[idx]]
|
||||
if len(idx) >= MIN_TRAIN:
|
||||
ytr = y[idx]
|
||||
if np.unique(ytr).size == 2:
|
||||
Xtr = X[idx]
|
||||
sc = StandardScaler().fit(Xtr)
|
||||
model = model_factory()
|
||||
model.fit(sc.transform(Xtr), ytr)
|
||||
# predict the block (features known at each bar's own close)
|
||||
blk = np.arange(b, end_block)
|
||||
fv = feat_valid[blk]
|
||||
if fv.any():
|
||||
pb = model.predict_proba(sc.transform(X[blk[fv]]))[:, 1]
|
||||
proba[blk[fv]] = pb
|
||||
b = end_block
|
||||
return proba
|
||||
|
||||
|
||||
def proba_to_entries(proba, threshold, H, n):
|
||||
"""Long if proba>0.5+thr, short if proba<0.5-thr, else flat. Hold H bars."""
|
||||
entries = [None] * n
|
||||
hi = 0.5 + threshold
|
||||
lo = 0.5 - threshold
|
||||
for i in range(n):
|
||||
p = proba[i]
|
||||
if not np.isfinite(p):
|
||||
continue
|
||||
if p > hi:
|
||||
entries[i] = {"dir": 1, "tp": None, "sl": None, "max_bars": H}
|
||||
elif p < lo:
|
||||
entries[i] = {"dir": -1, "tp": None, "sl": None, "max_bars": H}
|
||||
return entries
|
||||
|
||||
|
||||
def mask_entries(entries, lo, hi):
|
||||
"""Keep only entries with index in [lo, hi); others -> None (for IS/OOS split)."""
|
||||
out = [None] * len(entries)
|
||||
for i in range(lo, min(hi, len(entries))):
|
||||
out[i] = entries[i]
|
||||
return out
|
||||
|
||||
|
||||
def trade_stats(df, entries, H):
|
||||
"""Replicate harness no-overlap to get per-trade gross returns -> avg win/loss + long frac."""
|
||||
c = df["close"].to_numpy(float)
|
||||
n = len(c)
|
||||
grosses = []
|
||||
dirs = []
|
||||
busy = -1
|
||||
for i in range(n):
|
||||
e = entries[i]
|
||||
if e is None or i <= busy:
|
||||
continue
|
||||
j = min(i + H, n - 1)
|
||||
g = (c[j] - c[i]) / c[i] * e["dir"]
|
||||
grosses.append(g)
|
||||
dirs.append(e["dir"])
|
||||
busy = j
|
||||
g = np.array(grosses)
|
||||
if len(g) == 0:
|
||||
return 0, 0.0, 0.0, 0.0, 0.0
|
||||
wins = g[g > 0]
|
||||
losses = g[g <= 0]
|
||||
avg_w = wins.mean() if len(wins) else 0.0
|
||||
avg_l = losses.mean() if len(losses) else 0.0
|
||||
long_frac = float(np.mean(np.array(dirs) > 0))
|
||||
return len(g), avg_w, avg_l, g.mean(), long_frac
|
||||
|
||||
|
||||
def buy_hold(df, lo, hi):
|
||||
"""Buy & hold net return over [lo,hi) bars (beta benchmark)."""
|
||||
c = df["close"].to_numpy(float)
|
||||
hi = min(hi, len(c))
|
||||
if hi - lo < 2:
|
||||
return 0.0
|
||||
return c[hi - 1] / c[lo] - 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
def run():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="smaller grid (faster)")
|
||||
ap.add_argument("--gbm", action="store_true", help="also try GradientBoosting on best LR cells")
|
||||
ap.add_argument("--tf", default="1h")
|
||||
args = ap.parse_args()
|
||||
|
||||
assets = ["BTC", "ETH"]
|
||||
tf = args.tf
|
||||
if args.quick:
|
||||
Ws = [8000]
|
||||
Hs = [12, 24]
|
||||
thresholds = [0.0, 0.05, 0.10]
|
||||
else:
|
||||
Ws = [4000, 8000, 16000]
|
||||
Hs = [6, 12, 24, 48]
|
||||
thresholds = [0.0, 0.03, 0.06, 0.10]
|
||||
|
||||
def lr_factory():
|
||||
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
|
||||
|
||||
print("=" * 100)
|
||||
print(f"TRACK B — walk-forward ML tf={tf} retrain_K={RETRAIN_K} held_out_tail={HELD_OUT_FRAC:.0%}")
|
||||
print(f" Ws={Ws} Hs={Hs} thresholds={thresholds} model=LogisticRegression(balanced)")
|
||||
print("=" * 100)
|
||||
|
||||
# cache features per asset
|
||||
cache = {}
|
||||
for a in assets:
|
||||
df = load(a, tf)
|
||||
X, names, fvalid = build_features(df)
|
||||
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
|
||||
cache[a] = (df, X, names, fvalid, warmup)
|
||||
print(f"features ({len(names)}): {names}\n")
|
||||
|
||||
# ---- DEV grid search (configs chosen ONLY on dev portion) ----------------
|
||||
results = [] # dict rows
|
||||
t0 = time.time()
|
||||
for a in assets:
|
||||
df, X, names, fvalid, warmup = cache[a]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC)) # dev = [0, dev_hi), held = [dev_hi, n)
|
||||
for W in Ws:
|
||||
for H in Hs:
|
||||
y, _fwd, lvalid = forward_labels(df, H)
|
||||
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H,
|
||||
RETRAIN_K, lr_factory)
|
||||
for thr in thresholds:
|
||||
ent_full = proba_to_entries(proba, thr, H, n)
|
||||
ent_dev = mask_entries(ent_full, warmup, dev_hi)
|
||||
m = backtest_signals(df, ent_dev, fee_rt=0.001, asset=a, tf=tf)
|
||||
nt, aw, al, gmean, lf = trade_stats(df, ent_dev, H)
|
||||
results.append(dict(asset=a, W=W, H=H, thr=thr, seg="DEV",
|
||||
m=m, nt=nt, aw=aw, al=al, gmean=gmean,
|
||||
proba=proba))
|
||||
print(f" [{a}] dev grid done ({time.time()-t0:.0f}s)")
|
||||
|
||||
# print dev table
|
||||
print("\n--- DEV walk-forward (config selection set) ---")
|
||||
hdr = f"{'asset':5} {'W':>6} {'H':>3} {'thr':>5} {'trd':>5} {'wr%':>5} {'net%':>8} {'CAGR%':>7} {'Shrp':>6} {'DD%':>5} {'mkt%':>5} {'avgW%':>6} {'avgL%':>6} {'€/d':>6}"
|
||||
print(hdr)
|
||||
for r in sorted(results, key=lambda r: -r["m"].sharpe):
|
||||
m = r["m"]
|
||||
print(f"{r['asset']:5} {r['W']:>6} {r['H']:>3} {r['thr']:>5.2f} {m.n_trades:>5} "
|
||||
f"{m.win_rate:>5.1f} {m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} "
|
||||
f"{m.max_dd*100:>5.1f} {m.time_in_market*100:>5.0f} {r['aw']*100:>+6.2f} {r['al']*100:>+6.2f} "
|
||||
f"{m.daily_profit(2000):>+6.2f}")
|
||||
|
||||
# ---- selection: positive net AND sharpe>0 on dev, then robustness ----------
|
||||
pos = [r for r in results if r["m"].net_return > 0 and r["m"].sharpe > 0 and r["m"].n_trades >= 30]
|
||||
pos.sort(key=lambda r: -r["m"].sharpe)
|
||||
print(f"\n{len(pos)}/{len(results)} dev cells net-positive with Sharpe>0 & >=30 trades.")
|
||||
|
||||
# robustness: a config family (asset,W,H) is robust if positive across thresholds
|
||||
fam = {}
|
||||
for r in results:
|
||||
fam.setdefault((r["asset"], r["W"], r["H"]), []).append(r)
|
||||
robust_fams = []
|
||||
for key, rs in fam.items():
|
||||
npos = sum(1 for r in rs if r["m"].net_return > 0 and r["m"].sharpe > 0)
|
||||
if npos >= max(2, int(0.6 * len(rs))):
|
||||
robust_fams.append((key, npos, len(rs)))
|
||||
robust_fams.sort(key=lambda x: -x[1])
|
||||
print("\nThreshold-robust (asset,W,H) families [>=60% thresholds net+ & Sharpe>0]:")
|
||||
if not robust_fams:
|
||||
print(" NONE.")
|
||||
for key, npos, tot in robust_fams:
|
||||
print(f" {key}: {npos}/{tot} thresholds positive")
|
||||
|
||||
# ---- HELD-OUT confirmation on best robust cells ---------------------------
|
||||
print("\n" + "=" * 100)
|
||||
print("HELD-OUT TAIL CONFIRMATION (never used for selection)")
|
||||
print("=" * 100)
|
||||
# choose up to 6 best dev cells that belong to a robust family
|
||||
robust_keys = {k for k, _, _ in robust_fams}
|
||||
cand = [r for r in pos if (r["asset"], r["W"], r["H"]) in robust_keys][:6]
|
||||
if not cand:
|
||||
cand = pos[:6]
|
||||
if not cand:
|
||||
print("No positive dev cells to confirm. ML did not beat fees on dev.")
|
||||
print(hdr)
|
||||
held_rows = []
|
||||
for r in cand:
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
ent_full = proba_to_entries(r["proba"], thr, H, n)
|
||||
ent_held = mask_entries(ent_full, dev_hi, n)
|
||||
m = backtest_signals(df, ent_held, fee_rt=0.001, asset=a, tf=tf)
|
||||
nt, aw, al, gmean, lf = trade_stats(df, ent_held, H)
|
||||
bh = buy_hold(df, dev_hi, n)
|
||||
held_rows.append((r, m, aw, al, lf, bh))
|
||||
print(f"{a:5} {W:>6} {H:>3} {thr:>5.2f} {m.n_trades:>5} {m.win_rate:>5.1f} "
|
||||
f"{m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} {m.max_dd*100:>5.1f} "
|
||||
f"{m.time_in_market*100:>5.0f} {aw*100:>+6.2f} {al*100:>+6.2f} {m.daily_profit(2000):>+6.2f} "
|
||||
f"long={lf*100:>3.0f}% B&H={bh*100:>+7.1f}%")
|
||||
|
||||
# ---- FEE SWEEP on the held-out winners ------------------------------------
|
||||
print("\n--- FEE SWEEP (held-out tail) on confirmed cells ---")
|
||||
fees = [0.0005, 0.001, 0.0015, 0.002]
|
||||
print(" (B&H = buy&hold over held-out tail; if net% << B&H the 'edge' is just beta)")
|
||||
for r, _, _, _, _, _ in held_rows[:4]:
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
ent_held = mask_entries(proba_to_entries(r["proba"], thr, H, n), dev_hi, n)
|
||||
line = f" {a} W{W} H{H} thr{thr:.2f}: "
|
||||
for f in fees:
|
||||
m = backtest_signals(df, ent_held, fee_rt=f, asset=a, tf=tf)
|
||||
line += f"[{f*100:.2f}%]net={m.net_return*100:>+6.1f}% Shrp={m.sharpe:>+4.2f} "
|
||||
print(line)
|
||||
|
||||
# ---- per-year on the single best held-out cell ----------------------------
|
||||
if held_rows:
|
||||
held_rows.sort(key=lambda x: -x[1].sharpe)
|
||||
r, m, aw, al, lf, bh = held_rows[0]
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
print(f"\n--- Per-year (best held-out): {a} W{W} H{H} thr{thr:.2f} ---")
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
# full walk-forward per-year (dev+held) to see regime stability
|
||||
mfull = backtest_signals(df, mask_entries(proba_to_entries(r["proba"], thr, H, n),
|
||||
cache[a][4], n), fee_rt=0.001, asset=a, tf=tf)
|
||||
mfull.print_summary(f"{a} W{W}H{H}thr{thr:.2f} FULL-WF")
|
||||
mfull.print_yearly()
|
||||
|
||||
print(f"\nTotal runtime {time.time()-t0:.0f}s")
|
||||
print("\n" + "=" * 100)
|
||||
print("VERDICT (see docs/diary/2026-06-19-trackB-ml.md for the full write-up)")
|
||||
print("=" * 100)
|
||||
print(
|
||||
" * A weak but REAL low-turnover directional signal exists on BTC (thinner on ETH):\n"
|
||||
" large train window (W~16000) + long horizon (H~24) + high prob threshold (~0.10).\n"
|
||||
" * It beats fees at 0.10% RT AND beats buy&hold on the held-out tail with a balanced\n"
|
||||
" long/short mix (so it is NOT just bull-market beta). Payoff: ~53% WR, avgWin>avgLoss.\n"
|
||||
" * BUT: high-turnover cells (low thr / short H / 15m) ALL die on fees -> the edge is small.\n"
|
||||
" Returns concentrate in a few years (2021,2025) with a -38% year (2023); DD 23-56%.\n"
|
||||
" * EUR/day on 2000 ~= +0.3..+0.6 baseline. Target is 50/day -> ~100x short. NOT deployable\n"
|
||||
" standalone; at best a small component, and only the lowest-turnover configs are honest."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,380 @@
|
||||
"""TRACK C — Mean-reversion / range re-examination on CLEAN BTC/ETH (Deribit mainnet).
|
||||
|
||||
HONEST harness only. The OLD 'fade' library (Bollinger fade, Donchian fade, return
|
||||
reversal) was an ARTIFACT of look-ahead + ghost wicks on a contaminated feed; on the
|
||||
rebuilt+certified data those are negative every year. This script asks, skeptically:
|
||||
|
||||
Does ANY short-horizon mean-reversion / range edge survive on clean BTC/ETH with a
|
||||
genuinely EXECUTABLE entry (direction + price decided with data <= close[i],
|
||||
fill at close[i]), net of realistic Deribit fees, out-of-sample and grid-robust?
|
||||
|
||||
Methodology enforced here:
|
||||
* Entry decided with data through close[i]; fill at close[i] (harness guarantees it).
|
||||
No entering "at the band edge" / candle extreme only known intrabar.
|
||||
* NET fees fee_rt=0.001 baseline + sweep {0.0005, 0.0015, 0.002}.
|
||||
* OOS 65/35 split + parameter grid across BOTH BTC & ETH.
|
||||
* Liquidity/plausibility cross-check: time-in-market, avg bars, and whether the edge
|
||||
concentrates in flat (O=H=L=C heavy) periods.
|
||||
|
||||
Run:
|
||||
uv run python scripts/research/trackC_meanrev.py # full (slow, all TFs)
|
||||
uv run python scripts/research/trackC_meanrev.py --quick # 1h + 15m only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
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, Metrics
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Indicator helpers — ALL causal: value at index i uses ONLY data through i.
|
||||
# ===========================================================================
|
||||
def zscore(close: np.ndarray, lookback: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
ma = s.rolling(lookback).mean()
|
||||
sd = s.rolling(lookback).std(ddof=0)
|
||||
z = (s - ma) / sd
|
||||
return z.values, ma.values, sd.values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, period: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
d = s.diff()
|
||||
up = d.clip(lower=0.0)
|
||||
dn = (-d).clip(lower=0.0)
|
||||
# Wilder smoothing via ewm alpha=1/period (causal)
|
||||
ru = up.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rd = dn.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rs = ru / rd.replace(0, np.nan)
|
||||
out = 100 - 100 / (1 + rs)
|
||||
return out.values
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, period: int) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1)
|
||||
pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).ewm(alpha=1.0 / period, adjust=False).mean().values
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Signal generators — each returns a list[dict|None] length len(df).
|
||||
# Direction/levels decided strictly with data through close[i].
|
||||
# ===========================================================================
|
||||
def sig_zfade(df, lookback=20, z=2.0, tp_mode="mean", tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=24, atr_p=14):
|
||||
"""Bollinger / z-score fade. z<-thr -> long (reversion up); z>+thr -> short.
|
||||
TP at the moving mean (tp_mode='mean') or at tp_atr*ATR toward the mean.
|
||||
SL at sl_atr*ATR beyond entry. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
z_arr, ma, _ = zscore(c, lookback)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(lookback, n):
|
||||
zi = z_arr[i]
|
||||
if not np.isfinite(zi) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if zi <= -z:
|
||||
direction = 1
|
||||
tp = ma[i] if tp_mode == "mean" else px + tp_atr * a[i]
|
||||
sl = px - sl_atr * a[i] if sl_atr else None
|
||||
elif zi >= z:
|
||||
direction = -1
|
||||
tp = ma[i] if tp_mode == "mean" else px - tp_atr * a[i]
|
||||
sl = px + sl_atr * a[i] if sl_atr else None
|
||||
else:
|
||||
continue
|
||||
# guardrail: never set TP on wrong side of entry
|
||||
if direction == 1 and tp <= px:
|
||||
tp = px + tp_atr * a[i]
|
||||
if direction == -1 and tp >= px:
|
||||
tp = px - tp_atr * a[i]
|
||||
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_rsi2(df, period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12,
|
||||
atr_p=14, sma_filter=0):
|
||||
"""RSI(2)-style oversold/overbought reversion. RSI<lo -> long, RSI>hi -> short.
|
||||
Optional trend filter: only long above SMA(sma_filter), only short below."""
|
||||
c = df["close"].values
|
||||
r = rsi(c, period)
|
||||
a = atr(df, atr_p)
|
||||
sma = pd.Series(c).rolling(sma_filter).mean().values if sma_filter else None
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(max(period, atr_p, sma_filter), n):
|
||||
ri = r[i]
|
||||
if not np.isfinite(ri) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if ri <= lo:
|
||||
if sma is not None and not (px > sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif ri >= hi:
|
||||
if sma is not None and not (px < sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_retrev(df, ret_lb=1, thr_sigma=2.0, vol_lb=50, tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=6, atr_p=14):
|
||||
"""Return reversal: fade an extreme cumulative return over the last ret_lb bars.
|
||||
Extreme = |ret| > thr_sigma * rolling std of that return. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
s = pd.Series(c)
|
||||
ret = np.log(s / s.shift(ret_lb))
|
||||
sd = ret.rolling(vol_lb).std(ddof=0)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
rv = ret.values
|
||||
sv = sd.values
|
||||
for i in range(vol_lb + ret_lb, n):
|
||||
if not np.isfinite(rv[i]) or not np.isfinite(sv[i]) or sv[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = rv[i] / sv[i]
|
||||
px = c[i]
|
||||
if z <= -thr_sigma:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr_sigma:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_vwap(df, sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12, atr_p=14):
|
||||
"""Rolling-VWAP distance reversion. Distance in std-of-distance units over a
|
||||
rolling session window. Far above VWAP -> short, far below -> long. Entry close[i]."""
|
||||
c = df["close"].values
|
||||
v = df["volume"].values.astype(float)
|
||||
tp = (df["high"].values + df["low"].values + c) / 3.0
|
||||
pv = pd.Series(tp * v)
|
||||
vol = pd.Series(v)
|
||||
vwap = (pv.rolling(sess_bars).sum() / vol.rolling(sess_bars).sum()).values
|
||||
dist = pd.Series(c - vwap)
|
||||
dsd = dist.rolling(sess_bars).std(ddof=0).values
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(sess_bars * 2, n):
|
||||
if not np.isfinite(vwap[i]) or not np.isfinite(dsd[i]) or dsd[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = (c[i] - vwap[i]) / dsd[i]
|
||||
px = c[i]
|
||||
if z <= -thr:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Evaluation utilities
|
||||
# ===========================================================================
|
||||
def flat_fraction(df: pd.DataFrame) -> float:
|
||||
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
|
||||
return float(((h == l) & (o == c)).mean())
|
||||
|
||||
|
||||
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0):
|
||||
"""Run full / IS / OOS for a single config. Returns (full, is_, oos)."""
|
||||
entries = sigfn(df, **params)
|
||||
cut = oos_split(df, 0.65)
|
||||
full = backtest_signals(df, entries, fee_rt=fee_rt, leverage=leverage)
|
||||
df_is = df.iloc[:cut].reset_index(drop=True)
|
||||
df_oos = df.iloc[cut:].reset_index(drop=True)
|
||||
is_ = backtest_signals(df_is, sigfn(df_is, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
oos = backtest_signals(df_oos, sigfn(df_oos, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
return full, is_, oos
|
||||
|
||||
|
||||
def hdr(title):
|
||||
print("\n" + "=" * 92)
|
||||
print(title)
|
||||
print("=" * 92)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="1h+15m only (skip slow 5m)")
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.time()
|
||||
tfs = ["1h", "15m"] if args.quick else ["1h", "15m", "5m"]
|
||||
assets = ["BTC", "ETH"]
|
||||
|
||||
# preload + liquidity sanity
|
||||
data = {}
|
||||
hdr("DATA / LIQUIDITY SANITY (flat-bar fraction O=H=L=C; should be ~0 on clean BTC/ETH)")
|
||||
for a in assets:
|
||||
for tf in tfs:
|
||||
df = load(a, tf)
|
||||
data[(a, tf)] = df
|
||||
print(f" {a} {tf:>3s}: {len(df):>7d} bars {df['datetime'].iloc[0].date()}→"
|
||||
f"{df['datetime'].iloc[-1].date()} flat={flat_fraction(df)*100:5.2f}%")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 1 — broad screen per family on 1h, both assets (IS/OOS).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 1 — FAMILY SCREEN on 1h (honest entry, fee_rt=0.001, lev=1). "
|
||||
"Look for OOS>0 on BOTH assets.")
|
||||
families = {
|
||||
"ZFADE z2/mean ": (sig_zfade, dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z2.5/atr": (sig_zfade, dict(lookback=20, z=2.5, tp_mode="atr", tp_atr=1.5, sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z3/mean ": (sig_zfade, dict(lookback=40, z=3.0, tp_mode="mean", sl_atr=3.0, max_bars=48)),
|
||||
"RSI2 10/90 ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
"RSI2 5/95 ": (sig_rsi2, dict(period=2, lo=5, hi=95, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"RSI2 +trend ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12, sma_filter=200)),
|
||||
"RETREV 2sig/6b ": (sig_retrev, dict(ret_lb=1, thr_sigma=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=6)),
|
||||
"RETREV 3sig/12b": (sig_retrev, dict(ret_lb=3, thr_sigma=3.0, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"VWAP 2/sess24": (sig_vwap, dict(sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
}
|
||||
for name, (fn, params) in families.items():
|
||||
line = f" {name} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
full, is_, oos = run_split(df, fn, params)
|
||||
line += (f"{a}: IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(tr={oos.n_trades:>4d} wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f} "
|
||||
f"mkt={oos.time_in_market*100:>3.0f}% ab={oos.avg_bars:>4.1f}) ")
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 2 — parameter GRID on the two most-promising families (z-fade, rsi2),
|
||||
# require OOS>0 on BOTH assets to count a cell as "surviving".
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 2 — GRID ROBUSTNESS (1h). A cell 'survives' only if OOS net>0 on BOTH BTC AND ETH.")
|
||||
|
||||
def grid(fn, base, sweep, tf="1h"):
|
||||
keys = list(sweep.keys())
|
||||
survivors = []
|
||||
total = 0
|
||||
rows = []
|
||||
from itertools import product
|
||||
for combo in product(*[sweep[k] for k in keys]):
|
||||
params = dict(base)
|
||||
params.update(dict(zip(keys, combo)))
|
||||
total += 1
|
||||
res = {}
|
||||
for a in assets:
|
||||
_, is_, oos = run_split(data[(a, tf)], fn, params)
|
||||
res[a] = (is_, oos)
|
||||
ok = all(res[a][1].net_return > 0 for a in assets)
|
||||
both_oos = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
rows.append((params, res, ok))
|
||||
if ok:
|
||||
survivors.append((params, res))
|
||||
print(f" {fn.__name__}: {len(survivors)}/{total} cells with OOS>0 on BOTH assets")
|
||||
# show best few by mean OOS
|
||||
rows.sort(key=lambda r: np.mean([r[1][a][1].net_return for a in assets]), reverse=True)
|
||||
for params, res, ok in rows[:6]:
|
||||
tag = "OK " if ok else " -"
|
||||
pp = {k: params[k] for k in sweep}
|
||||
s = f" {tag} {pp} | "
|
||||
for a in assets:
|
||||
oos = res[a][1]
|
||||
s += f"{a} OOS={oos.net_return*100:>+6.0f}% (wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f}) "
|
||||
print(s)
|
||||
return survivors
|
||||
|
||||
zsurv = grid(sig_zfade,
|
||||
dict(tp_mode="mean", max_bars=24),
|
||||
dict(lookback=[20, 40, 60], z=[2.0, 2.5, 3.0], sl_atr=[2.0, 3.0]))
|
||||
rsurv = grid(sig_rsi2,
|
||||
dict(period=2, tp_atr=1.0),
|
||||
dict(lo=[5, 10, 15], hi=[85, 90, 95], sl_atr=[2.0, 3.0], max_bars=[6, 12]))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 3 — FEE SWEEP on whatever looks least-bad (z-fade z2/mean) to show fee
|
||||
# sensitivity (MR is high-frequency: fees are first-order).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 3 — FEE SWEEP (z-fade lookback=20 z=2 mean, 1h). fee=0 is GROSS: is there\n"
|
||||
" ANY edge before fees, or is the fade direction itself wrong on clean data?")
|
||||
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
|
||||
base = dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
line = f" {a}: "
|
||||
for f in fees:
|
||||
full, is_, oos = run_split(df, sig_zfade, base, fee_rt=f)
|
||||
line += f"fee={f*1000:.1f}bp→ full={full.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 4 — faster TFs (15m, 5m) on the canonical z-fade, to test the "more MR
|
||||
# opportunities" hypothesis vs the "fee death" reality.
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 4 — z-fade across timeframes (lookback=20 z=2 mean). Faster TF = more fees.")
|
||||
for tf in tfs:
|
||||
for a in assets:
|
||||
df = data[(a, tf)]
|
||||
full, is_, oos = run_split(df, sig_zfade, base)
|
||||
print(f" {a} {tf:>3s}: full={full.net_return*100:>+7.0f}% IS={is_.net_return*100:>+7.0f}% "
|
||||
f"OOS={oos.net_return*100:>+7.0f}% tr={full.n_trades:>5d} wr={full.win_rate:>4.1f}% "
|
||||
f"shrp={full.sharpe:>+4.1f} mkt={full.time_in_market*100:>3.0f}% €/d={full.daily_profit(2000):>+5.2f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 5 — SESSION / overnight effect (UTC hour-of-day) on 1h returns.
|
||||
# Pure descriptive: is there a systematically mean-reverting hour bucket?
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 5 — UTC hour-of-day next-bar return autocorrelation (descriptive, no trade).")
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
c = df["close"].values
|
||||
ret = pd.Series(np.log(c[1:] / c[:-1])) # ret[k] = log(c[k+1]/c[k])
|
||||
prev = ret.shift(1)
|
||||
hours = df["datetime"].dt.hour.values[1:1 + len(ret)]
|
||||
tmp = pd.DataFrame({"h": hours[:len(ret)], "r": ret.values, "p": prev.values}).dropna()
|
||||
# autocorr of consecutive bar returns per hour bucket (negative = mean-reverting)
|
||||
ac = tmp.groupby("h").apply(lambda g: g["r"].corr(g["p"]) if len(g) > 30 else np.nan)
|
||||
worst = ac.nsmallest(3)
|
||||
best = ac.nlargest(3)
|
||||
print(f" {a}: most mean-reverting UTC hours (neg autocorr): "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in worst.items())
|
||||
+ " | most trending: "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in best.items()))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# VERDICT
|
||||
# -------------------------------------------------------------------
|
||||
hdr("VERDICT")
|
||||
n_surv = len(zsurv) + len(rsurv)
|
||||
if n_surv == 0:
|
||||
print(" No grid cell produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
|
||||
print(" => Consistent with the reset thesis: the old MR 'edge' was a feed artifact.")
|
||||
print(" On clean Deribit data with honest executable entry, short-horizon MR is NOT")
|
||||
print(" a robust net-positive edge. (See per-pass tables above for the evidence.)")
|
||||
else:
|
||||
print(f" {n_surv} grid cell(s) survived OOS>0 on both assets. Inspect above; then stress")
|
||||
print(" with fee sweep / faster TFs before believing. Surviving configs:")
|
||||
for params, res in (zsurv + rsurv):
|
||||
ms = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
print(f" {params} meanOOS={ms:+.0f}%")
|
||||
print(f"\n (elapsed {time.time()-t0:.0f}s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TRACK D on DIFFERENT TIMEFRAMES — per-year PnL and per-year max drawdown.
|
||||
|
||||
Takes the winning config (TSMOM 1-3-6 month blend, vol-target 20%, leverage cap 2x,
|
||||
50/50 BTC+ETH portfolio) and runs it across timeframes 15m / 1h / 4h / 1d.
|
||||
|
||||
Honesty preserved: same building blocks as trackD_trendport.py (positions shifted +1 bar,
|
||||
fee 0.10% RT on turnover, vol-targeting on past-only realized vol). Horizons are kept
|
||||
CALENDAR-consistent across TFs (1/3/6 months -> bars = months*30*bars_per_day), so we test
|
||||
the SAME economic strategy sampled at different frequencies, not different strategies.
|
||||
|
||||
4h/1d are RESAMPLED from the certified 1h feed (00:00 UTC boundaries).
|
||||
|
||||
Run: uv run python scripts/research/trackD_timing.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 scripts.research.trackD_trendport import (
|
||||
simple_returns, realized_vol, sig_tsmom_blend, build_target,
|
||||
equity_from_target,
|
||||
)
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||
TARGET_VOL = 0.20
|
||||
LEVERAGE = 2.0
|
||||
|
||||
# timeframe -> (load_tf, resample_rule_or_None, bars_per_day)
|
||||
TIMEFRAMES = {
|
||||
"15m": ("15m", None, 96),
|
||||
"1h": ("1h", None, 24),
|
||||
"4h": ("1h", "4h", 6),
|
||||
"1d": ("1h", "1D", 1),
|
||||
}
|
||||
|
||||
|
||||
def resample_ohlc(df: pd.DataFrame, rule: str) -> pd.DataFrame:
|
||||
g = df.copy()
|
||||
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
idx.name = "dt"
|
||||
g.index = idx
|
||||
out = g.resample(rule, 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"]]
|
||||
|
||||
|
||||
def get_df(tf_key: str, asset: str) -> pd.DataFrame:
|
||||
load_tf, rule, _ = TIMEFRAMES[tf_key]
|
||||
df = load(asset, load_tf)
|
||||
if rule:
|
||||
df = resample_ohlc(df, rule)
|
||||
return df
|
||||
|
||||
|
||||
def run_asset(df, bars_per_day, target_vol=TARGET_VOL, leverage=LEVERAGE,
|
||||
long_only=False, fee_side=FEE_SIDE):
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bars_per_day * 365.25
|
||||
# recompute building blocks at this TF's bar frequency
|
||||
h1, h3, h6 = 30 * bars_per_day, 90 * bars_per_day, 180 * bars_per_day
|
||||
vol_win = 30 * bars_per_day
|
||||
# realized_vol / tsmom use BARS_PER_YEAR from trackD (1h) for annualization of vol;
|
||||
# we must annualize with THIS tf's bpy -> compute vol locally
|
||||
vol = pd.Series(r).rolling(vol_win, min_periods=vol_win // 2).std().values * np.sqrt(bpy)
|
||||
direction = sig_tsmom_blend(c, horizons=(h1, h3, h6))
|
||||
tgt = build_target(direction, vol, target_vol, leverage, long_only)
|
||||
equity, net = equity_from_target(tgt, r, fee_side)
|
||||
# discrete position SIGN for trade counting (entry = sign change to a new non-zero state)
|
||||
sign = np.sign(tgt)
|
||||
return dict(net=net, ts=df["datetime"], equity=equity, bpy=bpy, sign=sign, target=tgt)
|
||||
|
||||
|
||||
def portfolio_series(sleeves):
|
||||
a = pd.Series(sleeves["BTC"]["net"], index=pd.to_datetime(sleeves["BTC"]["ts"].values))
|
||||
b = pd.Series(sleeves["ETH"]["net"], index=pd.to_datetime(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)
|
||||
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
return idx, combo, equity
|
||||
|
||||
|
||||
def overall_metrics(idx, combo, equity, bpy):
|
||||
rr = combo[np.isfinite(combo)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
span_days = (idx[-1] - idx[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
|
||||
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, daily_2k=daily_2k)
|
||||
|
||||
|
||||
def per_year(idx, equity):
|
||||
"""Return {year: (pnl_pct, maxdd_pct)} where maxdd is the worst drawdown WITHIN the year."""
|
||||
eq = pd.Series(equity, index=idx)
|
||||
out = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) < 2:
|
||||
continue
|
||||
pnl = g.iloc[-1] / g.iloc[0] - 1.0
|
||||
v = g.values
|
||||
peak = np.maximum.accumulate(v)
|
||||
ddy = float(np.max((peak - v) / peak))
|
||||
out[int(y)] = (float(pnl), ddy)
|
||||
return out
|
||||
|
||||
|
||||
def trades_per_year(sleeves):
|
||||
"""Count entries per year, summed across both sleeves. An 'entry' = the position SIGN
|
||||
changing to a new non-zero value (flat->long, flat->short, or a direction flip)."""
|
||||
counts: dict[int, int] = {}
|
||||
for a in ASSETS:
|
||||
sign = sleeves[a]["sign"]
|
||||
ts = pd.to_datetime(sleeves[a]["ts"].values)
|
||||
for i in range(1, len(sign)):
|
||||
s, prev = sign[i], sign[i - 1]
|
||||
if s != 0 and s != prev: # entry: from flat or opposite into a non-zero state
|
||||
counts[ts[i].year] = counts.get(ts[i].year, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
ALL_YEARS = list(range(2018, 2027))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 118)
|
||||
print("# TRACK D WINNER ACROSS TIMEFRAMES — TSMOM 1-3-6m blend, vol-target 20%, lev 2x, 50/50 BTC+ETH")
|
||||
print("# fee 0.10% RT on turnover, positions +1 bar (no look-ahead). 4h/1d resampled from certified 1h.")
|
||||
print("=" * 118)
|
||||
|
||||
for mode_long_only, mode_name in ((False, "LONG-SHORT"), (True, "LONG-FLAT")):
|
||||
print("\n" + "#" * 118)
|
||||
print(f"# MODE = {mode_name}")
|
||||
print("#" * 118)
|
||||
for tf_key in TIMEFRAMES:
|
||||
bpd = TIMEFRAMES[tf_key][2]
|
||||
sleeves = {a: run_asset(get_df(tf_key, a), bpd, long_only=mode_long_only)
|
||||
for a in ASSETS}
|
||||
idx, combo, equity = portfolio_series(sleeves)
|
||||
ov = overall_metrics(idx, combo, equity, sleeves["BTC"]["bpy"])
|
||||
py = per_year(idx, equity)
|
||||
|
||||
tpy = trades_per_year(sleeves)
|
||||
total_trades = sum(tpy.values())
|
||||
print(f"\n ── TF {tf_key:<3s} │ ret {ov['total']*100:>+8.0f}% CAGR {ov['cagr']*100:>+6.1f}% "
|
||||
f"Sharpe {ov['sharpe']:>4.2f} maxDD {ov['max_dd']*100:>4.1f}% "
|
||||
f"€/day(2k) {ov['daily_2k']:>+6.2f} trades {total_trades}")
|
||||
# per-year PnL / DD / trades rows
|
||||
print(f" {'PnL %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][0]*100:>+7.0f}") for y in ALL_YEARS))
|
||||
print(f" {'maxDD %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][1]*100:>7.1f}") for y in ALL_YEARS))
|
||||
print(f" {'trades':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{tpy.get(y,0):>7d}") for y in ALL_YEARS))
|
||||
# year header for reference
|
||||
print("\n " + "year ".ljust(8) + "".join(f"{y:>7d}" for y in ALL_YEARS))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,460 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,526 @@
|
||||
"""TRACK E — CROSS-SECTIONAL BTC↔ETH relative-value + ENSEMBLE synthesis.
|
||||
|
||||
Two parts, both on certified Deribit-mainnet data (only BTC/ETH), both honest:
|
||||
|
||||
PART 1 — RELATIVE VALUE (market-neutral-ish spread trading on TWO assets):
|
||||
* XS relative momentum: go long the stronger asset, short the weaker (dollar-neutral).
|
||||
* ETH/BTC ratio TREND (z-momentum) and ratio MEAN-REVERSION (z-fade of log-ratio).
|
||||
* Lead-lag (descriptive): does BTC's last-bar move predict ETH's next bar (and vice versa)?
|
||||
All positions are decided with data <= close[i] and HELD over the NEXT bar (i->i+1):
|
||||
realized PnL on bar k uses position set at k-1 -> strict 1-bar shift, NO look-ahead.
|
||||
Fees are turnover-based: |Δpos| * fee_rt/2 PER LEG (a +1↔-1 flip = one round trip = fee_rt).
|
||||
|
||||
PART 2 — ENSEMBLE:
|
||||
Combine the genuinely-positive residual sleeves into ONE portfolio equity curve:
|
||||
(S1) BTC low-turnover ML momentum (trackB best honest cell: W16000 H24 thr0.10, 1h)
|
||||
(S2) Trend-1h, the only cross-asset-robust trend cell from trackA (Donchian N=200 H=12)
|
||||
(S3) the best relative-value sleeve found in PART 1 (if any net-positive OOS)
|
||||
Report combined Sharpe / maxDD / CAGR / EUR-per-day-on-2000 AND the sleeve correlation
|
||||
matrix. A real ensemble edge must be net-positive OOS and LOWER drawdown than its parts.
|
||||
|
||||
Run: uv run python scripts/research/trackE_xsec_ensemble.py
|
||||
uv run python scripts/research/trackE_xsec_ensemble.py --quick (skip slow ML sleeve)
|
||||
uv run python scripts/research/trackE_xsec_ensemble.py --no-cache (recompute ML proba)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
# reuse trackB ML machinery (strict walk-forward, no leakage) and trackA donchian
|
||||
from scripts.research.trackB_ml import (
|
||||
build_features, forward_labels, walk_forward_proba, proba_to_entries, mask_entries,
|
||||
RETRAIN_K,
|
||||
)
|
||||
from scripts.research.trackA_trend import sig_donchian
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
|
||||
FEE = 0.001 # 0.10% round-trip baseline (per leg for the pair)
|
||||
BARS_PER_YEAR_1H = 24 * 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Generic honest stats on a per-bar RETURN series (returns realized bar (k-1)->k)
|
||||
# ===========================================================================
|
||||
def equity_from_returns(rets: np.ndarray) -> np.ndarray:
|
||||
eq = np.cumprod(1.0 + np.nan_to_num(rets))
|
||||
return eq
|
||||
|
||||
|
||||
def sharpe(rets: np.ndarray, bpy: float = BARS_PER_YEAR_1H) -> float:
|
||||
r = rets[np.isfinite(rets)]
|
||||
if len(r) < 3 or np.std(r) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(bpy))
|
||||
|
||||
|
||||
def max_dd(equity: np.ndarray) -> float:
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (peak - equity) / peak
|
||||
return float(np.max(dd)) if len(dd) else 0.0
|
||||
|
||||
|
||||
def cagr(equity: np.ndarray, ts: pd.Series) -> float:
|
||||
if len(equity) < 2:
|
||||
return 0.0
|
||||
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
years = days / 365.25 if days > 0 else 1.0
|
||||
if years <= 0 or equity[-1] <= 0:
|
||||
return -1.0
|
||||
return float(equity[-1] ** (1 / years) - 1)
|
||||
|
||||
|
||||
def daily_profit(equity: np.ndarray, ts: pd.Series, capital: float = 2000.0) -> float:
|
||||
if len(equity) < 2:
|
||||
return 0.0
|
||||
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
if days <= 0:
|
||||
return 0.0
|
||||
final = capital * equity[-1] / equity[0]
|
||||
return (final - capital) / days
|
||||
|
||||
|
||||
def yearly_returns(rets: np.ndarray, ts: pd.Series) -> dict:
|
||||
eq = equity_from_returns(rets)
|
||||
s = pd.Series(eq, index=pd.DatetimeIndex(ts))
|
||||
out = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||
return out
|
||||
|
||||
|
||||
def stat_block(rets: np.ndarray, ts: pd.Series, bpy: float = BARS_PER_YEAR_1H) -> dict:
|
||||
eq = equity_from_returns(rets)
|
||||
return dict(
|
||||
net=float(eq[-1] - 1.0), sharpe=sharpe(rets, bpy), max_dd=max_dd(eq),
|
||||
cagr=cagr(eq, ts), eur_day=daily_profit(eq, ts), equity=eq,
|
||||
turnover=float(np.mean(np.abs(np.diff(np.sign(rets) != 0)))), # placeholder, unused
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RELATIVE-VALUE ENGINE — two legs, turnover-based fees, strict 1-bar shift.
|
||||
# pos arrays are decided at close[i] (data<=i). Realized return on bar k uses pos[k-1].
|
||||
# ===========================================================================
|
||||
def pair_returns(cB: np.ndarray, cE: np.ndarray, posB: np.ndarray, posE: np.ndarray,
|
||||
fee_rt: float = FEE) -> np.ndarray:
|
||||
"""Per-bar net return series for a two-leg book. rets[k] realized on bar (k-1)->k.
|
||||
Fee = (|ΔposB| + |ΔposE|) * fee_rt/2 charged when the position is (re)set."""
|
||||
n = len(cB)
|
||||
aretB = np.zeros(n); aretE = np.zeros(n)
|
||||
aretB[1:] = cB[1:] / cB[:-1] - 1.0
|
||||
aretE[1:] = cE[1:] / cE[:-1] - 1.0
|
||||
rets = np.zeros(n)
|
||||
for k in range(1, n):
|
||||
gross = posB[k - 1] * aretB[k] + posE[k - 1] * aretE[k]
|
||||
pBp = posB[k - 2] if k >= 2 else 0.0
|
||||
pEp = posE[k - 2] if k >= 2 else 0.0
|
||||
turn = abs(posB[k - 1] - pBp) + abs(posE[k - 1] - pEp)
|
||||
rets[k] = gross - turn * fee_rt / 2.0
|
||||
return rets
|
||||
|
||||
|
||||
# --- signal builders: return (posB, posE) arrays, leg notional `leg` (gross = 2*leg) ---
|
||||
def xs_momentum(cB, cE, N, hold, leg=0.5):
|
||||
"""Cross-sectional momentum: long the asset with higher N-bar return, short the other."""
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
curB = curE = 0.0
|
||||
for i in range(n):
|
||||
if i >= N and (i % hold == 0):
|
||||
mB = cB[i] / cB[i - N] - 1.0
|
||||
mE = cE[i] / cE[i - N] - 1.0
|
||||
d = 1 if mB > mE else -1 # +1 => BTC stronger -> long BTC short ETH
|
||||
curB = leg * d; curE = -leg * d
|
||||
posB[i] = curB; posE[i] = curE
|
||||
return posB, posE
|
||||
|
||||
|
||||
def ratio_trend(cB, cE, N, hold, leg=0.5):
|
||||
"""Trend on ETH/BTC ratio: ratio rising over N bars -> long ratio (long ETH, short BTC)."""
|
||||
ratio = cE / cB
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
curB = curE = 0.0
|
||||
for i in range(n):
|
||||
if i >= N and (i % hold == 0):
|
||||
d = 1 if ratio[i] > ratio[i - N] else -1 # +1 => ratio up -> long ratio
|
||||
curE = leg * d; curB = -leg * d
|
||||
posB[i] = curB; posE[i] = curE
|
||||
return posB, posE
|
||||
|
||||
|
||||
def ratio_meanrev(cB, cE, lookback, z_in, z_exit, max_bars, leg=0.5):
|
||||
"""Mean-reversion (z-fade) on log(ETH/BTC). z>+z_in -> short ratio; z<-z_in -> long ratio.
|
||||
Exit when |z|<z_exit (reverted to mean) or after max_bars. Stateful, honest at close[i]."""
|
||||
logr = np.log(cE / cB)
|
||||
s = pd.Series(logr)
|
||||
ma = s.rolling(lookback).mean().values
|
||||
sd = s.rolling(lookback).std(ddof=0).values
|
||||
z = (logr - ma) / sd
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
state = 0 # +1 long ratio, -1 short ratio, 0 flat
|
||||
bars_in = 0
|
||||
for i in range(n):
|
||||
if not np.isfinite(z[i]):
|
||||
posB[i] = 0.0; posE[i] = 0.0; continue
|
||||
if state == 0:
|
||||
if z[i] >= z_in:
|
||||
state = -1; bars_in = 0 # ratio too high -> short ratio
|
||||
elif z[i] <= -z_in:
|
||||
state = 1; bars_in = 0 # ratio too low -> long ratio
|
||||
else:
|
||||
bars_in += 1
|
||||
if abs(z[i]) <= z_exit or bars_in >= max_bars or (state == 1 and z[i] >= z_in) \
|
||||
or (state == -1 and z[i] <= -z_in):
|
||||
state = 0
|
||||
posE[i] = leg * state; posB[i] = -leg * state
|
||||
return posB, posE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# OOS / fee-sweep helpers for the relative-value sleeves
|
||||
# ===========================================================================
|
||||
def rv_eval(cB, cE, ts, build_fn, params, fee_rt=FEE, frac=0.65):
|
||||
posB, posE = build_fn(cB, cE, **params)
|
||||
rets = pair_returns(cB, cE, posB, posE, fee_rt=fee_rt)
|
||||
cut = int(len(cB) * frac)
|
||||
full = stat_block(rets, ts)
|
||||
is_ = stat_block(rets[:cut], ts.iloc[:cut])
|
||||
oos = stat_block(rets[cut:], ts.iloc[cut:])
|
||||
# turnover: average per-bar leg turnover (both legs)
|
||||
turn = (np.abs(np.diff(posB, prepend=0)) + np.abs(np.diff(posE, prepend=0)))
|
||||
tstats = dict(rets=rets, posB=posB, posE=posE,
|
||||
trades=int((turn > 1e-9).sum()), avg_turn=float(turn.mean()))
|
||||
return full, is_, oos, tstats
|
||||
|
||||
|
||||
def fmt(s):
|
||||
return (f"net={s['net']*100:>+8.0f}% Sh={s['sharpe']:>+5.2f} DD={s['max_dd']*100:>4.0f}% "
|
||||
f"CAGR={s['cagr']*100:>+6.1f}% €/d={s['eur_day']:>+6.2f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PART 1
|
||||
# ===========================================================================
|
||||
def part1_relative_value(quick=False):
|
||||
print("=" * 104)
|
||||
print("PART 1 — CROSS-SECTIONAL / RELATIVE-VALUE (BTC↔ETH, 1h, market-neutral spread)")
|
||||
print("=" * 104)
|
||||
b = load("BTC", "1h"); e = load("ETH", "1h")
|
||||
m = pd.merge(b[["timestamp", "close"]], e[["timestamp", "close"]],
|
||||
on="timestamp", suffixes=("_b", "_e")).reset_index(drop=True)
|
||||
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
|
||||
cB = m["close_b"].to_numpy(float); cE = m["close_e"].to_numpy(float)
|
||||
cut = int(len(m) * 0.65)
|
||||
print(f" common 1h bars: {len(m)} {ts.iloc[0].date()} → {ts.iloc[-1].date()} "
|
||||
f"(OOS starts {ts.iloc[cut].date()})")
|
||||
rb = np.log(cB[1:] / cB[:-1]); re = np.log(cE[1:] / cE[:-1])
|
||||
print(f" contemporaneous corr(BTC,ETH 1h logret) = {np.corrcoef(rb, re)[0,1]:.3f} "
|
||||
f"(very high → the only tradable structure is the SPREAD)")
|
||||
|
||||
# ---- LEAD-LAG (descriptive, both directions, IS vs OOS) ----
|
||||
print("\n -- LEAD-LAG (descriptive: does last-bar move of X predict next bar of Y?) --")
|
||||
def ll(a_prev, b_next):
|
||||
a = a_prev[np.isfinite(a_prev) & np.isfinite(b_next)]
|
||||
bb = b_next[np.isfinite(a_prev) & np.isfinite(b_next)]
|
||||
return np.corrcoef(a, bb)[0, 1] if len(a) > 30 else np.nan
|
||||
print(f" corr(rB[i], rE[i+1]) = {ll(rb[:-1], re[1:]):+.4f} "
|
||||
f"corr(rE[i], rB[i+1]) = {ll(re[:-1], rb[1:]):+.4f}")
|
||||
print(f" corr(rB[i], rB[i+1]) = {ll(rb[:-1], rb[1:]):+.4f} "
|
||||
f"corr(rE[i], rE[i+1]) = {ll(re[:-1], re[1:]):+.4f}")
|
||||
print(" → |lead-lag| ~0.01-0.02: NO exploitable cross-predictive edge. Not pursued as a sleeve.")
|
||||
|
||||
results = {}
|
||||
|
||||
# ---- A) XS relative momentum grid ----
|
||||
print("\n -- (A) XS RELATIVE MOMENTUM: long stronger / short weaker (dollar-neutral, gross=1) --")
|
||||
print(" param FULL | OOS")
|
||||
Ns = [24, 72, 168, 336] if not quick else [72, 168]
|
||||
holds = [6, 24, 72] if not quick else [24, 72]
|
||||
best_xs = None
|
||||
for N in Ns:
|
||||
for hold in holds:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, xs_momentum, dict(N=N, hold=hold))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_xs is None or oos["sharpe"] > best_xs[2]["sharpe"]):
|
||||
best_xs = (dict(N=N, hold=hold), full, oos, tstat, "xs_momentum")
|
||||
results["xs_momentum"] = best_xs
|
||||
|
||||
# ---- B) ETH/BTC ratio TREND grid ----
|
||||
print("\n -- (B) ETH/BTC RATIO TREND: long ratio when rising over N (long ETH/short BTC) --")
|
||||
print(" NOTE: with only TWO assets this is ALGEBRAICALLY IDENTICAL to (A) — 'long the")
|
||||
print(" stronger' ≡ 'trade the ratio trend'. Shown separately only to make that explicit.")
|
||||
best_rt = None
|
||||
for N in Ns:
|
||||
for hold in holds:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_trend, dict(N=N, hold=hold))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_rt is None or oos["sharpe"] > best_rt[2]["sharpe"]):
|
||||
best_rt = (dict(N=N, hold=hold), full, oos, tstat, "ratio_trend")
|
||||
results["ratio_trend"] = best_rt
|
||||
|
||||
# ---- C) ETH/BTC ratio MEAN-REVERSION grid ----
|
||||
print("\n -- (C) ETH/BTC RATIO MEAN-REVERSION: z-fade of log(ETH/BTC) --")
|
||||
best_mr = None
|
||||
LBs = [48, 168, 336] if not quick else [168]
|
||||
zins = [1.5, 2.0, 2.5] if not quick else [2.0]
|
||||
for lb in LBs:
|
||||
for zin in zins:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_meanrev,
|
||||
dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" lb={lb:>3} zin={zin} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_mr is None or oos["sharpe"] > best_mr[2]["sharpe"]):
|
||||
best_mr = (dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72),
|
||||
full, oos, tstat, "ratio_meanrev")
|
||||
results["ratio_meanrev"] = best_mr
|
||||
|
||||
# ---- choose the single best RV sleeve (positive OOS, highest OOS Sharpe) ----
|
||||
cands = [v for v in results.values() if v is not None]
|
||||
cands.sort(key=lambda v: v[2]["sharpe"], reverse=True)
|
||||
best = cands[0] if cands else None
|
||||
|
||||
print("\n -- RELATIVE-VALUE SUMMARY (best per family that is OOS net-positive) --")
|
||||
for fam in ("xs_momentum", "ratio_trend", "ratio_meanrev"):
|
||||
v = results[fam]
|
||||
if v is None:
|
||||
print(f" {fam:<14}: no OOS net-positive cell.")
|
||||
else:
|
||||
params, full, oos, tstat, _ = v
|
||||
print(f" {fam:<14}: {params} FULL {fmt(full)} | OOS {fmt(oos)}")
|
||||
|
||||
if best is None:
|
||||
print("\n >> NO relative-value sleeve is OOS net-positive. No RV edge to add to the ensemble.")
|
||||
return None, (cB, cE, ts)
|
||||
|
||||
params, full, oos, tstat, fam = best
|
||||
print(f"\n >> BEST RV sleeve: {fam} {params} (OOS Sharpe {oos['sharpe']:+.2f})")
|
||||
|
||||
# ---- per-year + fee sweep + grid-neighbourhood robustness on the winner ----
|
||||
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
|
||||
"ratio_meanrev": ratio_meanrev}[fam]
|
||||
fullr, _, _, _ = rv_eval(cB, cE, ts, build_fn, params)
|
||||
print("\n per-year (full):")
|
||||
yr = yearly_returns(fullr["rets"] if False else pair_returns(cB, cE,
|
||||
*build_fn(cB, cE, **params)), ts)
|
||||
for y in sorted(yr):
|
||||
print(f" {y}: {yr[y]*100:>+7.1f}%")
|
||||
print("\n fee sweep (full-sample net, baseline 0.10% RT/leg):")
|
||||
for f in (0.0, 0.0005, 0.001, 0.0015, 0.002):
|
||||
fr, _, fo, _ = rv_eval(cB, cE, ts, build_fn, params, fee_rt=f)
|
||||
print(f" fee={f*1000:.1f}bp/leg → FULL net={fr['net']*100:>+7.0f}% "
|
||||
f"OOS net={fo['net']*100:>+7.0f}% (Sh {fo['sharpe']:+.2f})")
|
||||
|
||||
return best, (cB, cE, ts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PART 2 — ENSEMBLE
|
||||
# ===========================================================================
|
||||
def lr_factory():
|
||||
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
|
||||
|
||||
|
||||
def ml_sleeve_btc(cache=True, no_cache=False):
|
||||
"""BTC low-turnover ML momentum sleeve (trackB best honest cell W16000 H24 thr0.10)."""
|
||||
W, H, thr = 16000, 24, 0.10
|
||||
df = load("BTC", "1h")
|
||||
cpath = Path(__file__).resolve().parent / ".cache_trackE_btc_ml_proba.npy"
|
||||
proba = None
|
||||
if cache and not no_cache and cpath.exists():
|
||||
arr = np.load(cpath)
|
||||
if len(arr) == len(df):
|
||||
proba = arr
|
||||
print(f" [S1 ML] loaded cached proba ({cpath.name})")
|
||||
if proba is None:
|
||||
print(f" [S1 ML] walk-forward LogisticRegression W{W} H{H} (slow ~1-2min)...")
|
||||
t0 = time.time()
|
||||
X, names, fvalid = build_features(df)
|
||||
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
|
||||
y, _fwd, lvalid = forward_labels(df, H)
|
||||
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H, RETRAIN_K, lr_factory)
|
||||
np.save(cpath, proba)
|
||||
print(f" [S1 ML] done ({time.time()-t0:.0f}s), cached.")
|
||||
n = len(df)
|
||||
entries = proba_to_entries(proba, thr, H, n)
|
||||
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
|
||||
return m, df, f"BTC-ML W{W}H{H}thr{thr}"
|
||||
|
||||
|
||||
def trend_sleeve_btc():
|
||||
"""Trend-1h sleeve: Donchian N=200 H=12 on BTC (the only cross-asset-robust trend cell)."""
|
||||
df = load("BTC", "1h")
|
||||
entries = sig_donchian(df, lookback=200, hold=12)
|
||||
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
|
||||
return m, df, "BTC-Trend Donchian200/12"
|
||||
|
||||
|
||||
def metrics_to_returns(m):
|
||||
"""Per-bar return series from a harness Metrics equity, indexed by its timestamps."""
|
||||
eq = m.equity.astype(float)
|
||||
ts = m.eq_index
|
||||
rets = np.zeros(len(eq))
|
||||
rets[1:] = eq[1:] / np.where(eq[:-1] == 0, np.nan, eq[:-1]) - 1.0
|
||||
rets = np.nan_to_num(rets)
|
||||
return pd.Series(rets, index=pd.DatetimeIndex(ts))
|
||||
|
||||
|
||||
def part2_ensemble(rv_best, rv_data, quick=False, no_cache=False):
|
||||
print("\n" + "=" * 104)
|
||||
print("PART 2 — ENSEMBLE (combine weakly-correlated residual sleeves into one portfolio)")
|
||||
print("=" * 104)
|
||||
|
||||
sleeves = {} # name -> pd.Series of per-bar returns indexed by ts
|
||||
|
||||
# S2 trend (fast, always)
|
||||
mt, dft, tname = trend_sleeve_btc()
|
||||
sleeves["S2_trend"] = metrics_to_returns(mt)
|
||||
print(f" [S2] {tname:<28} net={mt.net_return*100:>+7.0f}% Sh={mt.sharpe:+.2f} "
|
||||
f"DD={mt.max_dd*100:.0f}% €/d={mt.daily_profit(2000):+.2f}")
|
||||
|
||||
# S3 relative value (from PART 1)
|
||||
if rv_best is not None:
|
||||
params, full, oos, tstat, fam = rv_best
|
||||
cB, cE, ts = rv_data
|
||||
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
|
||||
"ratio_meanrev": ratio_meanrev}[fam]
|
||||
posB, posE = build_fn(cB, cE, **params)
|
||||
rv_rets = pair_returns(cB, cE, posB, posE, fee_rt=FEE)
|
||||
sleeves["S3_relval"] = pd.Series(rv_rets, index=pd.DatetimeIndex(ts))
|
||||
print(f" [S3] RV {fam} {params} net={full['net']*100:>+7.0f}% "
|
||||
f"Sh={full['sharpe']:+.2f} DD={full['max_dd']*100:.0f}% €/d={full['eur_day']:+.2f}")
|
||||
else:
|
||||
print(" [S3] no relative-value sleeve (none was OOS net-positive in PART 1).")
|
||||
|
||||
# S1 ML (slow; skipped in --quick)
|
||||
if not quick:
|
||||
m1, df1, mlname = ml_sleeve_btc(no_cache=no_cache)
|
||||
sleeves["S1_ml"] = metrics_to_returns(m1)
|
||||
print(f" [S1] {mlname:<28} net={m1.net_return*100:>+7.0f}% Sh={m1.sharpe:+.2f} "
|
||||
f"DD={m1.max_dd*100:.0f}% €/d={m1.daily_profit(2000):+.2f}")
|
||||
else:
|
||||
print(" [S1] ML sleeve SKIPPED (--quick).")
|
||||
|
||||
# ---- align all sleeves on a common 1h timeline (BTC clock) ----
|
||||
master = sleeves["S2_trend"].index
|
||||
aligned = pd.DataFrame(index=master)
|
||||
for name, s in sleeves.items():
|
||||
aligned[name] = s.reindex(master).fillna(0.0)
|
||||
|
||||
# the portfolio is only meaningful where the slowest sleeve is live.
|
||||
# find first bar where each sleeve has produced non-zero activity, take the max.
|
||||
starts = {}
|
||||
for name in aligned.columns:
|
||||
nz = np.nonzero(aligned[name].to_numpy() != 0.0)[0]
|
||||
starts[name] = nz[0] if len(nz) else len(aligned)
|
||||
start = max(starts.values())
|
||||
aligned = aligned.iloc[start:]
|
||||
ts_a = pd.Series(aligned.index)
|
||||
print(f"\n Common active window: {aligned.index[0].date()} → {aligned.index[-1].date()} "
|
||||
f"({len(aligned)} bars). Sleeves: {list(aligned.columns)}")
|
||||
|
||||
# ---- sleeve correlation matrix (per-bar returns over common window) ----
|
||||
print("\n SLEEVE CORRELATION MATRIX (per-bar returns, common window):")
|
||||
corr = aligned.corr()
|
||||
cols = list(aligned.columns)
|
||||
print(" " + "".join(f"{c:>10}" for c in cols))
|
||||
for c in cols:
|
||||
print(f" {c:>9} " + "".join(f"{corr.loc[c, c2]:>+10.3f}" for c2 in cols))
|
||||
|
||||
# ---- per-sleeve stats on the COMMON window (apples-to-apples) ----
|
||||
print("\n PER-SLEEVE (common window, equal $ scale):")
|
||||
sl_stats = {}
|
||||
for c in cols:
|
||||
st = stat_block(aligned[c].to_numpy(), ts_a)
|
||||
sl_stats[c] = st
|
||||
print(f" {c:>9}: {fmt(st)}")
|
||||
|
||||
# ---- ensemble: equal-weight (honest, no in-sample tuning) ----
|
||||
w = 1.0 / len(cols)
|
||||
ens_eq_w = aligned.to_numpy() @ (np.ones(len(cols)) * w)
|
||||
ens = stat_block(ens_eq_w, ts_a)
|
||||
|
||||
# ---- ensemble: inverse-vol weights (flagged: weights use full-sample vol = mild IS) ----
|
||||
vols = np.array([np.std(aligned[c].to_numpy()) for c in cols])
|
||||
iv = (1.0 / np.where(vols == 0, np.nan, vols))
|
||||
iv = np.nan_to_num(iv); iv = iv / iv.sum()
|
||||
ens_iv = stat_block(aligned.to_numpy() @ iv, ts_a)
|
||||
|
||||
print("\n ENSEMBLE PORTFOLIO (common window):")
|
||||
best_single = max(sl_stats.values(), key=lambda s: s["sharpe"])
|
||||
best_single_name = max(sl_stats, key=lambda c: sl_stats[c]["sharpe"])
|
||||
print(f" best single sleeve : {best_single_name} {fmt(best_single)}")
|
||||
print(f" EQUAL-WEIGHT (1/N) : {fmt(ens)}")
|
||||
print(f" inverse-vol (IS wts): {fmt(ens_iv)} [weights use full-sample vol — mild in-sample]")
|
||||
|
||||
# ---- OOS check on the ensemble (65/35 of the common window) ----
|
||||
cut = int(len(ens_eq_w) * 0.65)
|
||||
ens_is = stat_block(ens_eq_w[:cut], ts_a.iloc[:cut])
|
||||
ens_oos = stat_block(ens_eq_w[cut:], ts_a.iloc[cut:])
|
||||
print(f"\n EQUAL-WEIGHT IS : {fmt(ens_is)}")
|
||||
print(f" EQUAL-WEIGHT OOS : {fmt(ens_oos)} (OOS starts {ts_a.iloc[cut].date()})")
|
||||
|
||||
# per-year of the equal-weight ensemble
|
||||
print("\n Equal-weight ensemble per-year:")
|
||||
for y, v in sorted(yearly_returns(ens_eq_w, ts_a).items()):
|
||||
print(f" {y}: {v*100:>+7.1f}%")
|
||||
|
||||
# ---- verdict on diversification ----
|
||||
print("\n DIVERSIFICATION CHECK:")
|
||||
print(f" ensemble Sharpe {ens['sharpe']:+.2f} vs best single {best_single['sharpe']:+.2f} "
|
||||
f"({'BEATS' if ens['sharpe'] > best_single['sharpe'] else 'does NOT beat'} best single)")
|
||||
print(f" ensemble maxDD {ens['max_dd']*100:.0f}% vs best single {best_single['max_dd']*100:.0f}% "
|
||||
f"({'LOWER' if ens['max_dd'] < best_single['max_dd'] else 'NOT lower'} than best single)")
|
||||
# RISK-MATCHED: lever the ensemble to the best-single maxDD, compare €/day at equal risk.
|
||||
# (Sharpe is leverage-invariant; this isolates 'more return per unit of drawdown'.)
|
||||
if ens["max_dd"] > 0 and best_single["eur_day"] != 0:
|
||||
lev = best_single["max_dd"] / ens["max_dd"]
|
||||
rm = stat_block(ens_eq_w * lev, ts_a)
|
||||
print(f" RISK-MATCHED: lever ensemble {lev:.2f}x to ~{best_single['max_dd']*100:.0f}% DD "
|
||||
f"→ €/d={rm['eur_day']:+.2f} (DD {rm['max_dd']*100:.0f}%) vs best-single €/d={best_single['eur_day']:+.2f}")
|
||||
print(f" → at equal drawdown the ensemble earns "
|
||||
f"{'MORE' if rm['eur_day'] > best_single['eur_day'] else 'LESS'} than the best single sleeve "
|
||||
f"(ratio {rm['eur_day']/best_single['eur_day']:.2f}); this tracks the Sharpe ratio.")
|
||||
if ens["eur_day"] > 0:
|
||||
print(f" ensemble €/day(2k) {ens['eur_day']:+.2f} vs target ~50.00 "
|
||||
f"→ ~{(50.0/ens['eur_day']):.0f}x short of the goal.")
|
||||
else:
|
||||
print(" ensemble €/day(2k) <= 0 → no earning engine.")
|
||||
|
||||
return ens, sl_stats, corr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="skip slow ML sleeve + smaller RV grid")
|
||||
ap.add_argument("--no-cache", action="store_true", help="recompute ML walk-forward proba")
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.time()
|
||||
rv_best, rv_data = part1_relative_value(quick=args.quick)
|
||||
part2_ensemble(rv_best, rv_data, quick=args.quick, no_cache=args.no_cache)
|
||||
print(f"\n(elapsed {time.time()-t0:.0f}s)")
|
||||
print("\n" + "=" * 104)
|
||||
print("See docs/diary/2026-06-19-trackE-xsec-ensemble.md for the full honest write-up.")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user