From c9b89739c135d8242f4393e9c06ecd4195ddebb6 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 22 Jun 2026 21:37:37 +0000 Subject: [PATCH] research(equities): EQ-TREND01 trend difensivo su SPY = edge difensivo REALE (analogo TP01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/diary/2026-06-22-equities-spy-trend.md | 48 +++++++ scripts/research/eq_spy_trend.py | 146 ++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 docs/diary/2026-06-22-equities-spy-trend.md create mode 100644 scripts/research/eq_spy_trend.py diff --git a/docs/diary/2026-06-22-equities-spy-trend.md b/docs/diary/2026-06-22-equities-spy-trend.md new file mode 100644 index 0000000..594e3a6 --- /dev/null +++ b/docs/diary/2026-06-22-equities-spy-trend.md @@ -0,0 +1,48 @@ +# 2026-06-22 — EQ-TREND01: trend DIFENSIVO su SPY = edge difensivo REALE (analogo di TP01) + +## Contesto +Il momentum cross-sectional settoriale è morto (EQ-MOM01: long-short Sharpe −0.08, long-only ≈ SPY). +Ma nel crypto l'unica cosa che ha retto NON era relative-value: era **TP01**, un trend DIFENSIVO che +taglia il drawdown. L'equity ha lo stesso buco: SPY buy&hold Sharpe ~0.54 ma maxDD **55%**. + +## Costruzione (causale, stile TP01) +`eq_spy_trend.py`. TSMOM multi-orizzonte [21,63,126,252]g, target = frazione di orizzonti in +trend-up (allocazione graduale 0..1), opz. vol-target. Posizione decisa a ≤i-1, tenuta da i. Netto +fee. Varianti: long-flat (cash in risk-off), long-bonds (TLT, solo 2016+), SMA-200 (Faber). Dati da +cache eqlib (ADJUSTED, nessun IB). Periodo 1997-2026, OOS 2015+. + +## Risultati +| strategia | CAGR | Sharpe (pre15/OOS) | maxDD | in-mkt | +|---|---|---|---|---| +| SPY buy&hold | 9.0% | 0.54 (0.38/0.82) | 55% | 99% | +| **SMA-200 (Faber)** | 7.0% | **0.65** (0.52/0.88) | **29%** | 76% | +| TSMOM lf cap1.0 | 5.7% | 0.57 (0.44/0.78) | 30% | 92% | +| TSMOM lf vt15% | 5.7% | 0.62 (0.51/0.78) | **25%** | 92% | + +**Drawdown nei bear (TSMOM vs SPY):** dot-com 26%/49% · GFC **19%/55%** · COVID 17%/34% · 2022 16%/24%. + +**Plateau** (long-flat): ogni config Sharpe 0.56-0.65 (> SPY 0.54), maxDD 25-31% (~metà di SPY). +SMA-200 il più semplice E il migliore (Sh 0.65, OOS 0.88, DD 29%). **Fee-robusto** (Sh 0.48 a +0.10%/lato), basso turnover. + +**Marginale vs SPY:** corr 0.73. blend 50/50 uplift +0.035 FULL / +0.031 OOS (modesto positivo); +100% trend uplift −0.012 / −0.041 (nel toro recente la difesa costa). + +## Verdetto: edge DIFENSIVO reale (non alpha) — analogo di TP01 +- ✅ Sharpe 0.54→0.62/0.65, **maxDD dimezzato** (55%→~27%, nei bear lenti più che dimezzato), + plateau robusto, fee-robusto, **eseguibile a $0.5-2k** (switch mensile SPY/cash). +- ⚠️ NON genera ritorno (CAGR −2/3pp): è risk-management, come TP01. +- ⚠️ I tagli grossi (dot-com/GFC) sono IN-SAMPLE; l'OOS 2015-26 è quasi tutto toro → lì ha seguito + SPY a beta minore (ma COVID, OOS, dimezzato). La difesa "serve" nei bear, rari nell'OOS. +- ⚠️ long-bonds (TLT) non convince (TLT distrutto 2022). + +## Lettura strategica +Primo positivo del fronte equity, e dello stesso TIPO che ha retto nel crypto: trend difensivo, non +relative-value. Conferma la lezione cross-mercato: **il valore robusto è nel ridurre il rischio +(trend long-flat), non nel battere il buy&hold**. Da solo non risolve €50/g (problema di capitale). + +## Prossimo angolo plausibile +**Trend multi-asset / GTAA** sull'universo ETF in cache (SPY/QQQ/IWM + TLT/GLD/HYG): un portafoglio +di trend long-flat su classi d'attivo diverse di solito batte il trend mono-SPY sul rischio-aggiustato +(diversificazione dei trend). + domanda cross-mercato: la sleeve equity-trend DIVERSIFICA il +portafoglio crypto (TP01+XS01+VRP01)? (esecuzione split Deribit+IB). diff --git a/scripts/research/eq_spy_trend.py b/scripts/research/eq_spy_trend.py new file mode 100644 index 0000000..9b0523f --- /dev/null +++ b/scripts/research/eq_spy_trend.py @@ -0,0 +1,146 @@ +"""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()