Files
PythagorasGoal/scripts/research/trackA_trend.py
Adriano Dal Pastro d152941360 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>
2026-06-19 18:55:04 +00:00

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()