d152941360
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>
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""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()
|