Files
PythagorasGoal/scripts/research/eq_spy_trend.py
T
Adriano Dal Pastro c9b89739c1 research(equities): EQ-TREND01 trend difensivo su SPY = edge difensivo REALE (analogo TP01)
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>
2026-06-22 21:37:37 +00:00

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