c9b89739c1
Il cross-section e' morto (EQ-MOM01), ma il trend DIFENSIVO time-series su SPY regge — stesso tipo di TP01 nel crypto. TSMOM multi-orizzonte / SMA-200 long-flat, causale, netto fee, OOS 2015+. RISULTATO: Sharpe 0.54->0.62/0.65, maxDD DIMEZZATO (55%->~27%; nei bear lenti piu': GFC 19% vs 55%, dot-com 26% vs 49%, COVID 17% vs 34%). Plateau robusto (0.56-0.65), fee-robusto (0.48 a 0.10%/lato), basso turnover, eseguibile a $0.5-2k (switch mensile SPY/cash). SMA-200 = piu' semplice E migliore. CAVEAT: e' risk-management non alpha (CAGR -2/3pp); i tagli grossi sono in-sample (OOS 2015-26 quasi tutto toro -> ha seguito SPY a beta minore, ma COVID dimezzato). long-bonds TLT non convince. Lezione cross-mercato confermata: il valore robusto e' ridurre il rischio (trend long-flat), non battere il buy&hold. Prossimo: trend multi-asset/GTAA + diversifica la sleeve crypto? Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
7.3 KiB
Python
147 lines
7.3 KiB
Python
"""EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo equity di TP01).
|
|
|
|
Il momentum cross-sectional settoriale e' morto (EQ-MOM01). Ma nel crypto l'unica cosa che ha retto
|
|
NON era un alpha relative-value: era TP01, un trend DIFENSIVO che taglia il drawdown restando vicino
|
|
al ritorno. L'equity ha lo stesso buco: SPY buy&hold fa Sharpe ~0.51 ma con maxDD 55% (due bear -50%:
|
|
2000-02 e 2008-09). Domanda: un trend long-flat su SPY ALZA il Sharpe e DIMEZZA il DD restando
|
|
investito nei tori? (NON cerchiamo di battere il CAGR — cerchiamo il taglio del rischio, come TP01.)
|
|
|
|
DATI: cache su disco eq_spy/eq_tlt (ADJUSTED), via eqlib (nessun IB).
|
|
COSTRUZIONE (causale, stile TP01): TSMOM multi-orizzonte [21,63,126,252]g (1/3/6/12 mesi); target =
|
|
frazione di orizzonti in trend-up (0..1, allocazione graduale). Vol-target opz. Posizione decisa a
|
|
<=i-1, tenuta da i. Netto fee sul turnover. Varianti: long-flat (cash in risk-off), long-bonds
|
|
(TLT in risk-off, solo dal 2016), e SMA-200 binario (Faber) come riferimento classico.
|
|
|
|
GIUDIZIO: vs SPY buy&hold (CAGR/Sharpe full-pre15-OOS15+/maxDD/time-in-market), marginale vs SPY,
|
|
DD nei due bear storici, plateau (orizzonti/vol-target/cap leva), sweep fee.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
import numpy as np, pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
|
import eqlib
|
|
from eqlib import load_eq
|
|
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT, spy_bh
|
|
|
|
ANN = np.sqrt(252.0)
|
|
|
|
|
|
def _series(sym):
|
|
d = load_eq(sym)["close"].astype(float)
|
|
return pd.Series(d.values, index=d.index)
|
|
|
|
|
|
def tsmom_exposure(close: pd.Series, horizons=(21, 63, 126, 252), target_vol=None,
|
|
lev_cap=1.0) -> pd.Series:
|
|
"""Esposizione SPY in [0, lev_cap]: frazione di orizzonti in trend-up, opz. vol-targeted (causale)."""
|
|
px = close.values; n = len(px); tgt = np.zeros(n)
|
|
mh = max(horizons)
|
|
for i in range(mh, n):
|
|
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in horizons])
|
|
s = pd.Series(tgt, index=close.index)
|
|
if target_vol:
|
|
ret = close.pct_change()
|
|
rv = ret.rolling(63, min_periods=20).std().shift(1) * ANN
|
|
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, lev_cap / 0.0 if False else 10.0)
|
|
s = (s * scale).clip(0, lev_cap)
|
|
else:
|
|
s = s.clip(0, lev_cap)
|
|
return s
|
|
|
|
|
|
def sma_timing(close: pd.Series, win=200) -> pd.Series:
|
|
"""Faber: long se close > SMA(win), altrimenti flat. Binario {0,1}."""
|
|
sma = close.rolling(win, min_periods=win // 2).mean()
|
|
return (close > sma).astype(float)
|
|
|
|
|
|
def backtest(close: pd.Series, exposure: pd.Series, risk_off: pd.Series | None = None,
|
|
fee_side=0.0002) -> pd.Series:
|
|
"""Rendimenti netti: held = exposure ritardata di 1 (causale). La quota non in SPY (1-held, se
|
|
exposure<=1) va in risk_off (es. TLT) o cash (0). Fee sul turnover di SPY."""
|
|
ret = close.pct_change().fillna(0.0).values
|
|
exp = exposure.reindex(close.index).fillna(0.0).values
|
|
held = np.zeros(len(exp)); held[1:] = exp[:-1]
|
|
net = held * ret
|
|
if risk_off is not None:
|
|
ro = risk_off.reindex(close.index).pct_change().fillna(0.0).values
|
|
cash_w = np.clip(1.0 - held, 0.0, 1.0) # quota fuori da SPY -> bonds
|
|
net = net + cash_w * ro
|
|
net = net - fee_side * np.abs(np.diff(held, prepend=0.0))
|
|
return pd.Series(net, index=close.index)
|
|
|
|
|
|
def _row(name, r, common, bench=None):
|
|
r = r.reindex(common).fillna(0.0)
|
|
h = r[r.index >= EQ_HOLDOUT]; ii = r[r.index < EQ_HOLDOUT]
|
|
tim = float((r != 0).mean()) * 100
|
|
extra = ""
|
|
if bench is not None:
|
|
J = pd.concat({"r": r, "b": bench.reindex(common).fillna(0.0)}, axis=1).dropna()
|
|
extra = f" corr {J['r'].corr(J['b']):+.2f}"
|
|
print(f" {name:24} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
|
f"(pre15 {_sh(ii):>5.2f}|OOS {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}% inMkt {tim:>3.0f}%{extra}")
|
|
|
|
|
|
def _bear_dd(r, common, lo, hi, label):
|
|
seg = r.reindex(common).fillna(0.0)
|
|
seg = seg[(seg.index >= pd.Timestamp(lo, tz="UTC")) & (seg.index <= pd.Timestamp(hi, tz="UTC"))]
|
|
return f"{label}: {_dd(seg.values)*100:.0f}%"
|
|
|
|
|
|
def main():
|
|
print("=" * 100)
|
|
print(" EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo di TP01)")
|
|
print("=" * 100)
|
|
spy_px = _series("SPY"); spy = spy_bh()
|
|
common = spy_px.index[spy_px.index >= spy_px.index[252]] # warmup 1y
|
|
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) OOS = {EQ_HOLDOUT.date()}+\n")
|
|
|
|
print(" --- BASELINE ---")
|
|
_row("SPY buy&hold", spy, common)
|
|
print("\n --- TREND long-flat (cash in risk-off) ---")
|
|
_row("SMA-200 (Faber)", backtest(spy_px, sma_timing(spy_px)), common, bench=spy)
|
|
_row("TSMOM lf cap1.0", backtest(spy_px, tsmom_exposure(spy_px)), common, bench=spy)
|
|
_row("TSMOM lf vt15 cap1.0", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.0)), common, bench=spy)
|
|
_row("TSMOM lf vt15 cap1.5", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.5)), common, bench=spy)
|
|
|
|
print("\n --- TREND long-BONDS (TLT in risk-off, solo dove TLT esiste: 2016+) ---")
|
|
tlt = _series("TLT")
|
|
cb = spy_px.index[(spy_px.index >= tlt.index[0])]
|
|
_row("SPY b&h (2016+)", spy.reindex(cb), cb)
|
|
_row("TSMOM lf+TLT (2016+)", backtest(spy_px, tsmom_exposure(spy_px), risk_off=tlt), cb, bench=spy)
|
|
|
|
# MARGINALE vs SPY + DD nei bear
|
|
base = backtest(spy_px, tsmom_exposure(spy_px))
|
|
print("\n --- MARGINALE vs SPY (TSMOM lf cap1.0) ---")
|
|
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna(); JH = J[J.index >= EQ_HOLDOUT]
|
|
print(f" corr full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
|
for wt in (0.5, 1.0):
|
|
bf = _sh((1-wt)*J['spy']+wt*J['c']) - _sh(J['spy']); bh = _sh((1-wt)*JH['spy']+wt*JH['c']) - _sh(JH['spy'])
|
|
lbl = "100% TREND" if wt == 1.0 else f"{int((1-wt)*100)}/{int(wt*100)} SPY/TREND"
|
|
print(f" {lbl:16}: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
|
print(" DD nei bear storici (TSMOM vs SPY):")
|
|
for lo, hi, lbl in [("2000-03-01","2002-12-31","dot-com"), ("2007-10-01","2009-06-30","GFC"),
|
|
("2020-02-01","2020-04-30","COVID"), ("2022-01-01","2022-12-31","2022")]:
|
|
print(f" {lbl:8} TSMOM {_bear_dd(base,common,lo,hi,'')} | SPY {_bear_dd(spy,common,lo,hi,'')}")
|
|
|
|
# PLATEAU
|
|
print("\n --- PLATEAU (Sharpe FULL/pre15/OOS, maxDD, CAGR) long-flat cap1.0 ---")
|
|
print(f" {'horizons':22} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'DD%':>5} {'CAGR%':>6}")
|
|
for hz in [(63,126,252),(21,63,126,252),(126,252),(50,200),(200,)]:
|
|
ex = sma_timing(spy_px, 200) if hz == (200,) else tsmom_exposure(spy_px, horizons=hz)
|
|
r = backtest(spy_px, ex); h=r[r.index>=EQ_HOLDOUT]; ii=r[r.index<EQ_HOLDOUT]
|
|
tag = "SMA-200" if hz==(200,) else "x".join(map(str,hz))
|
|
print(f" {tag:22} {_sh(r):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_dd(r.values)*100:>5.0f} {_cagr(r.values,r.index)*100:>6.1f}")
|
|
|
|
print("\n --- SWEEP FEE (TSMOM lf cap1.0) ---")
|
|
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
|
r = backtest(spy_px, tsmom_exposure(spy_px), fee_side=fee)
|
|
print(f" fee {fee*100:.2f}%/lato: Sh {_sh(r):.2f} maxDD {_dd(r.values)*100:.0f}%")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|