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>
This commit is contained in:
@@ -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).
|
||||
@@ -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.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()
|
||||
Reference in New Issue
Block a user