"""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.index6.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()