55c28e51b2
TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH) passa dove il mio trend 1h era caduto: hold-out 2025-26 +2.8%/DD8% vs buy&hold -39%/DD60%, positivo su ENTRAMBI gli asset, plateau 1h/4h/1d. La chiave e' il vol-targeting (esposizione ~1/vol -> cash nei crash) che non avevo combinato col trend. Edge DIFENSIVO reale (Sharpe full 1.36 vs B&H 0.92, ma CAGR 16.6% vs 48%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
6.5 KiB
Python
150 lines
6.5 KiB
Python
"""VERIFICA AVVERSARIALE di TP01 (branch strategy-research-2026-06) col MIO gauntlet onesto.
|
|
|
|
TP01 = TSMOM multi-orizzonte (30/90/180g) long-flat, vol-target 20%, leva cap 2x, portafoglio
|
|
50/50 BTC+ETH. Codice riprodotto VERBATIM dal branch (src/strategies/trend_portfolio.py).
|
|
La loro tesi: 'positiva ogni anno 2019-2026, Sharpe ~1.32'. Il mio test decisivo: il HOLD-OUT
|
|
2025-26 (che ha bocciato il mio trend 1h in Fase 3) + cross-asset + multi-TF (cherry-picking 4h?).
|
|
|
|
uv run python scripts/analysis/verify_tp01.py
|
|
"""
|
|
from __future__ import annotations
|
|
import sys
|
|
from pathlib import Path
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
import numpy as np
|
|
import pandas as pd
|
|
from src.data.downloader import load_data
|
|
|
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
|
CANONICAL = dict(target_vol=0.20, leverage=2.0, long_only=True,
|
|
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005)
|
|
|
|
|
|
# ---- TP01 riprodotto VERBATIM dal branch ----
|
|
def simple_returns(c):
|
|
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0; return r
|
|
|
|
def realized_vol(r, win, bpy):
|
|
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bpy)
|
|
|
|
def tsmom_blend(c, horizons):
|
|
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)
|
|
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]; return out
|
|
|
|
def target_series(df, p, bpd):
|
|
c = df["close"].values.astype(float); bpy = bpd * 365.25
|
|
r = simple_returns(c)
|
|
vol = realized_vol(r, p["vol_win_days"] * bpd, bpy)
|
|
direction = tsmom_blend(c, tuple(d * bpd for d in p["horizons_days"]))
|
|
if p["long_only"]:
|
|
direction = np.clip(direction, 0, None)
|
|
scal = np.where((vol > 0) & np.isfinite(vol), p["target_vol"] / vol, 0.0)
|
|
tgt = np.clip(direction * scal, -p["leverage"], p["leverage"]); tgt[~np.isfinite(tgt)] = 0.0
|
|
return tgt
|
|
|
|
def net_returns(df, p, bpd):
|
|
c = df["close"].values.astype(float); r = simple_returns(c)
|
|
tgt = target_series(df, p, bpd)
|
|
pos_held = np.zeros(len(tgt)); pos_held[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t -> causale
|
|
gross = pos_held * r
|
|
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
|
net = gross - p["fee_side"] * turn; net[0] = 0.0
|
|
return np.clip(net, -0.99, None), pos_held
|
|
|
|
|
|
def resample(df_1h, rule):
|
|
g = df_1h.copy(); idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True); g.index = idx
|
|
out = g.resample(rule, label="left", closed="left").agg(
|
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
|
|
out["timestamp"] = out.index
|
|
return out.reset_index(drop=True)
|
|
|
|
|
|
def metrics(combo, idx):
|
|
rr = combo[np.isfinite(combo)]
|
|
if len(rr) < 2 or np.std(rr) == 0:
|
|
return dict(sharpe=0, cagr=0, dd=0, ret=0, n=len(rr))
|
|
dt = pd.Series(idx).diff().dt.total_seconds().median()
|
|
bpy = 86400 * 365.25 / dt
|
|
eq = np.cumprod(1 + rr); peak = np.maximum.accumulate(eq)
|
|
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
|
|
return dict(sharpe=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)),
|
|
cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 else 0,
|
|
dd=float(np.max((peak - eq) / peak)), ret=float(eq[-1] - 1), n=len(rr))
|
|
|
|
|
|
def portfolio_combo(tf_rule, bpd):
|
|
series = {}
|
|
for a in ("BTC", "ETH"):
|
|
df = load_data(a, "1h")
|
|
if tf_rule:
|
|
df = resample(df, tf_rule)
|
|
net, _ = net_returns(df, CANONICAL, bpd)
|
|
series[a] = pd.Series(net, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True) if not tf_rule
|
|
else pd.DatetimeIndex(df["timestamp"]))
|
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
|
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
|
return combo, J.index, J
|
|
|
|
|
|
def line(label, combo, idx):
|
|
m = metrics(combo, idx)
|
|
return f" {label:<22s} Sharpe {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | DD {m['dd']*100:>5.1f}% | n {m['n']}"
|
|
|
|
|
|
def main():
|
|
print("=" * 92)
|
|
print(" VERIFICA TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)")
|
|
print(" col gauntlet onesto: FULL vs buy&hold | HOLD-OUT 2025-26 bloccato | per-anno | multi-TF")
|
|
print("=" * 92)
|
|
|
|
TFS = [("15m", "15min", 96), ("1h", None, 24), ("4h", "4h", 6), ("1d", "1D", 1)]
|
|
print("\n (A) MULTI-TF: il 4h e' cherry-picked? FULL + HOLD-OUT per ogni timeframe")
|
|
for tf, rule, bpd in TFS:
|
|
combo, idx, J = portfolio_combo(rule, bpd)
|
|
ho = idx >= HOLDOUT
|
|
full = metrics(combo, idx)
|
|
hold = metrics(combo[ho], idx[ho])
|
|
tag = " <- canonica" if tf == "4h" else ""
|
|
print(f" {tf:<3s} FULL Sh {full['sharpe']:>5.2f} CAGR {full['cagr']*100:>+6.1f}% DD {full['dd']*100:>4.1f}% "
|
|
f"| HOLD-OUT Sh {hold['sharpe']:>5.2f} ret {hold['ret']*100:>+6.1f}% DD {hold['dd']*100:>4.1f}%{tag}")
|
|
|
|
# focus 4h canonica
|
|
combo, idx, J = portfolio_combo("4h", 6)
|
|
print("\n (B) 4h CANONICA — per anno (la tesi: positiva OGNI anno 2019-2026)")
|
|
s = pd.Series(combo, index=idx)
|
|
for y, g in s.groupby(s.index.year):
|
|
eq = np.cumprod(1 + g.values); pk = np.maximum.accumulate(eq)
|
|
ho_flag = " <- HOLD-OUT (mai usato per scegliere config?)" if y >= 2025 else ""
|
|
print(f" {y}: ret {(eq[-1]-1)*100:>+7.1f}% DD {np.max((pk-eq)/pk)*100:>5.1f}%{ho_flag}")
|
|
|
|
print("\n (C) HOLD-OUT 2025-26 — TP01 vs buy&hold 50/50 (4h)")
|
|
ho = idx >= HOLDOUT
|
|
print(line("TP01 portfolio HO", combo[ho], idx[ho]))
|
|
# buy&hold 50/50 sullo stesso indice/finestra
|
|
bh = {}
|
|
for a in ("BTC", "ETH"):
|
|
df = resample(load_data(a, "1h"), "4h")
|
|
r = simple_returns(df["close"].values.astype(float))
|
|
bh[a] = pd.Series(r, index=pd.DatetimeIndex(df["timestamp"]))
|
|
Jb = pd.concat(bh, axis=1, join="inner").reindex(idx).fillna(0.0)
|
|
bh_combo = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values
|
|
print(line("buy&hold 50/50 HO", bh_combo[ho], idx[ho]))
|
|
print(line("TP01 portfolio FULL", combo, idx))
|
|
print(line("buy&hold 50/50 FULL", bh_combo, idx))
|
|
|
|
print("\n (D) CROSS-ASSET nel HOLD-OUT (lo stesso edge regge su ENTRAMBI?)")
|
|
for a in ("BTC", "ETH"):
|
|
df = resample(load_data(a, "1h"), "4h")
|
|
net, _ = net_returns(df, CANONICAL, 6)
|
|
ix = pd.DatetimeIndex(df["timestamp"]); m = ix >= HOLDOUT
|
|
print(line(f"TP01 {a} sleeve HO", net[m], ix[m]))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|