research(equities): EQ-MOM01 momentum settoriale -> NON batte SPY
Primo backtest del fronte equity. Momentum cross-sectional settoriale (9 SPDR, 1998-2026), causale, netto fee, OOS 2015+, giudicato marginale vs SPY buy&hold (il baseline equity). VERDETTO: nessun edge. Long-short Sharpe -0.08 (alpha cross-sectional MORTO su 27y, decadimento post-2000 noto). Long-only ~= SPY (corr 0.85, uplift marginale ~0.00) = SPY a beta piu' basso. Plateau stabile ~0.50 vs SPY 0.51; sugli 11 settori (2018+) peggio (0.69 vs 0.82). L'unico beneficio (maxDD 55->39%) e' del vol-target, non del momentum. Coerente col progetto: il relative-value momentum e' morto anche in equity (come ortho wave nel crypto). Prossimo angolo: TS-trend difensivo su SPY (analogo equity di TP01) per tagliare il drawdown, non per battere il CAGR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# 2026-06-22 — Fronte EQUITY aperto + EQ-MOM01 (momentum settoriale): NON batte SPY
|
||||
|
||||
## Apertura fronte (branch research/equities-ib)
|
||||
|
||||
Le 4 ondate crypto hanno esaurito gli angoli su BTC/ETH (soffitto ~1.3). L'unico modo di superarlo è
|
||||
un **mercato diverso**. Aperto il fronte azioni/ETF via IB (paper, `gnzsnz/ib-gateway`, read-only).
|
||||
|
||||
**Dati certificati + cache su disco** (`fetch_ib_equities.py` → `data/raw/eq_*.parquet`, ADJUSTED_LAST
|
||||
div+split, gitignored = cache locale; loader `eqlib.py` con lru_cache → ricerca legge da disco, MAI
|
||||
da IB). Universo: 9 SPDR settoriali classici dal **1998 (27.5y)** + XLRE(2015)/XLC(2018) + SPY(1996,
|
||||
30y)/QQQ/IWM/GLD/HYG/TLT. Tutti integri (monotoni, no dup, no spike>50%, 0 gap lunghi).
|
||||
NB bug timestamp risolto: `pd.Timestamp` a risoluzione µs → salvati in secondi, corretti a ms.
|
||||
|
||||
## EQ-MOM01 — momentum cross-sectional settoriale
|
||||
|
||||
Costruzione causale (`eq_sector_momentum.py`): ogni 21g, momentum = blend lookback [63,126,252]g con
|
||||
skip-21 (12-1 classico), z-score cross-sectional. long-only top-k (full-invested, confronto
|
||||
like-for-like con SPY) e long-short (dollar-neutral, test alpha puro). Netto fee, hold-out OOS 2015+.
|
||||
|
||||
### Risultati (9 settori, 1998-2026)
|
||||
| strategia | CAGR | Sharpe (pre15/OOS15+) | maxDD | corr SPY |
|
||||
|---|---|---|---|---|
|
||||
| **SPY buy&hold** | 8.2% | **0.51** (0.31/0.82) | 55% | — |
|
||||
| EW 9 settori | 8.9% | 0.56 (0.44/0.76) | 53% | 0.96 |
|
||||
| MOM long top-3 | 7.7% | 0.50 (0.32/0.76) | 47% | 0.85 |
|
||||
| MOM long vol-target 15% | 7.3% | 0.52 | 39% | 0.75 |
|
||||
| **MOM long-short top-3** | −0.9% | **−0.08** (−0.19/0.08) | 32% | −0.20 |
|
||||
|
||||
### Verdetto: NESSUN edge vs SPY
|
||||
- **Long-short Sharpe −0.08** → l'alpha cross-sectional di momentum settoriale è **morto** su 27 anni
|
||||
(decadimento post-2000 noto in letteratura). Niente alpha market-neutral.
|
||||
- **Long-only ≈ SPY**: corr 0.85, **uplift marginale ~0.00** (blend 75/25 +0.012 FULL / +0.001 OOS;
|
||||
50/50 +0.015 / −0.010). È un SPY a beta più basso, non un edge. Plateau stabile ma sempre ~0.50
|
||||
(vs SPY 0.51); sugli 11 settori (2018+) fa peggio (0.69 vs 0.82). Fee-robusto (ma niente da salvare).
|
||||
- L'unico beneficio (maxDD 55%→39%) è del **vol-target**, non del momentum (lo daresti a SPY stesso).
|
||||
|
||||
## Lezione (coerente col progetto)
|
||||
Il momentum **relative-value** è morto anche in equity, come nel crypto (ortho wave). Il baseline
|
||||
equity da battere è SPY buy&hold (Sharpe ~0.51 full / 0.82 OOS), ostico come il toro crypto.
|
||||
|
||||
## Prossimo angolo plausibile (NON ancora testato)
|
||||
L'analogo equity di TP01 (l'unica cosa che ha retto nel crypto = trend DIFENSIVO): **time-series
|
||||
trend su SPY long-flat/long-bonds** — non per battere il CAGR ma per **tagliare il 55% di drawdown**
|
||||
restando vicino al ritorno. È il punto dove vive il valore robusto in equity (e dove il cross-section
|
||||
NON guarda). Da provare con lo stesso gauntlet: marginale vs SPY, OOS lungo, plateau.
|
||||
@@ -0,0 +1,159 @@
|
||||
"""EQ-MOM01 — Momentum cross-sectional settoriale (SPDR), backtest onesto.
|
||||
|
||||
Prima ricerca del fronte equity (branch research/equities-ib). L'edge "noioso e robusto" piu'
|
||||
plausibile in un mercato efficiente: ruotare nei settori a momentum forte. Domanda chiave (come nel
|
||||
crypto col soffitto TP01): NON "fa soldi?" (un long-only equity cavalca il toro) ma **batte/ADDS a
|
||||
SPY buy&hold?** — il baseline vero in equity. Anche vs equal-weight 9 settori (isola il timing del
|
||||
momentum dal tilt equal-weight).
|
||||
|
||||
DATI: cache su disco eq_*.parquet (ADJUSTED div+split), via eqlib (nessun IB). 9 settori CLASSICI
|
||||
dal 1998 (27.5y) per il backtest lungo; 11 settori (2018+) come robustezza.
|
||||
|
||||
COSTRUZIONE (causale): ogni REB giorni, momentum = blend di lookback [63,126,252]g con SKIP recente
|
||||
(12-1 classico), z-score cross-sectional mediato. long-only: full-invested nei top-k (confronto
|
||||
like-for-like con SPY). long-short: dollar-neutral top-k vs bottom-k. Posizione decisa a <= i-1,
|
||||
tenuta da i (W[:-1]*dret[1:]). Netto fee sul turnover. Opz. vol-target.
|
||||
|
||||
GIUDIZIO: standalone (FULL / pre-2015 / hold-out 2015+ / per-anno, CAGR, Sharpe, maxDD) vs SPY e
|
||||
EW-settori; marginale vs SPY (corr, uplift blend full+hold, edge in-sample, persistenza multi-cut);
|
||||
plateau su lookback/k/reb/skip; 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 panel, load_eq, SECTORS_CLASSIC, SECTORS
|
||||
|
||||
ANN = np.sqrt(252.0)
|
||||
EQ_HOLDOUT = pd.Timestamp("2015-01-01", tz="UTC") # OOS lungo: ultimi ~11 anni (post-GFC, dove il momentum e' decaduto)
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(pd.Series(r).dropna(), float)
|
||||
return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def _cagr(r, idx):
|
||||
r = np.asarray(r, float); yrs = (idx[-1] - idx[0]).days / 365.25
|
||||
return float(np.prod(1 + r) ** (1 / yrs) - 1) if yrs > 0 else 0.0
|
||||
|
||||
|
||||
def _dd(r):
|
||||
eq = np.cumprod(1 + np.asarray(r, float)); pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
|
||||
|
||||
def momentum(universe=tuple(SECTORS_CLASSIC), lookbacks=(63, 126, 252), k=3, reb=21,
|
||||
skip=21, mode="long", target_vol=None, fee_side=0.0002):
|
||||
"""Serie netta daily del book momentum settoriale. mode='long' (top-k full-invested) o
|
||||
'ls' (dollar-neutral top-k vs bottom-k)."""
|
||||
P = panel(universe, how="inner")
|
||||
idx = P.index; px = P.values; n, A = px.shape
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
mlb = max(lookbacks) + skip
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= mlb and i % reb == 0:
|
||||
score = np.zeros(A); cnt = 0
|
||||
for Lb in lookbacks:
|
||||
a, b = i - skip - Lb, i - skip
|
||||
rL = px[b] / px[a] - 1.0
|
||||
sd = rL.std()
|
||||
if sd > 0:
|
||||
score += (rL - rL.mean()) / sd; cnt += 1
|
||||
if cnt:
|
||||
score /= cnt
|
||||
order = np.argsort(score)
|
||||
w = np.zeros(A)
|
||||
if mode == "long":
|
||||
w[order[-k:]] = 1.0 / k # full-invested nei top-k
|
||||
else:
|
||||
w[order[-k:]] = 0.5 / k; w[order[:k]] = -0.5 / k
|
||||
W[i] = w
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||
net = gross - turn * fee_side
|
||||
s = pd.Series(net, index=idx)
|
||||
if target_vol:
|
||||
rv = s.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, 3.0)
|
||||
s = pd.Series(s.values * scale, index=idx)
|
||||
return s
|
||||
|
||||
|
||||
def spy_bh():
|
||||
d = load_eq("SPY")["close"].astype(float)
|
||||
return pd.Series(d.values[1:] / d.values[:-1] - 1.0, index=d.index[1:])
|
||||
|
||||
|
||||
def ew_sectors_bh(universe=tuple(SECTORS_CLASSIC)):
|
||||
P = panel(universe, how="inner"); dret = P.pct_change().dropna()
|
||||
return dret.mean(axis=1)
|
||||
|
||||
|
||||
def _line(name, r, idx=None, bench=None):
|
||||
idx = idx if idx is not None else r.index
|
||||
r = r.reindex(idx).fillna(0.0) if hasattr(r, "reindex") else pd.Series(r, index=idx)
|
||||
h = r[r.index >= EQ_HOLDOUT]; isamp = r[r.index < EQ_HOLDOUT]
|
||||
extra = ""
|
||||
if bench is not None:
|
||||
J = pd.concat({"r": r, "b": bench}, axis=1, join="inner").dropna()
|
||||
extra = f" corr_SPY {J['r'].corr(J['b']):+.2f}"
|
||||
print(f" {name:26} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||
f"(pre15 {_sh(isamp):>5.2f} | OOS15+ {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}%{extra}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-MOM01 — Momentum cross-sectional settoriale (9 SPDR classici, 1998+)")
|
||||
print("=" * 100)
|
||||
spy = spy_bh(); ew = ew_sectors_bh()
|
||||
base = momentum() # long, lb[63,126,252], k=3, reb21, skip21
|
||||
common = base.index
|
||||
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) hold-out OOS = {EQ_HOLDOUT.date()}+\n")
|
||||
|
||||
print(" --- BASELINE da battere ---")
|
||||
_line("SPY buy&hold", spy, common)
|
||||
_line("EW 9 settori buy&hold", ew, common, bench=spy)
|
||||
print("\n --- EQ-MOM01 ---")
|
||||
_line("MOM long top-3", base, common, bench=spy)
|
||||
_line("MOM long top-3 vt15%", momentum(target_vol=0.15), common, bench=spy)
|
||||
_line("MOM long-short top-3", momentum(mode="ls"), common, bench=spy)
|
||||
|
||||
# MARGINALE vs SPY (il test che conta in equity)
|
||||
print("\n --- MARGINALE vs SPY buy&hold (aggiunge al baseline?) ---")
|
||||
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= EQ_HOLDOUT]
|
||||
print(f" corr(MOM,SPY) full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||
for wt in (0.25, 0.5):
|
||||
bf = _sh((1-wt)*J["spy"]+wt*J["c"]) - _sh(J["spy"])
|
||||
bh = _sh((1-wt)*JH["spy"]+wt*JH["c"]) - _sh(JH["spy"])
|
||||
print(f" blend {int((1-wt)*100)}/{int(wt*100)} SPY/MOM: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||
# per-decade (multi-cut onesto)
|
||||
print(" Sharpe MOM per blocco: ", {f"{y}s": round(_sh(base[(base.index.year>=y)&(base.index.year<y+5)]),2)
|
||||
for y in (1999,2004,2009,2014,2019,2024)})
|
||||
|
||||
# PLATEAU
|
||||
print("\n --- PLATEAU (Sharpe FULL / pre15 / OOS15+) long-only ---")
|
||||
print(f" {'cfg':24} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'CAGR%':>6} {'DD%':>5}")
|
||||
for lbs in [(126,), (63,126,252), (252,)]:
|
||||
for k in (2, 3, 4):
|
||||
for reb in (21,):
|
||||
s = momentum(lookbacks=lbs, k=k, reb=reb)
|
||||
tag = f"lb{'-'.join(map(str,lbs))} k{k}"
|
||||
h=s[s.index>=EQ_HOLDOUT]; ii=s[s.index<EQ_HOLDOUT]
|
||||
print(f" {tag:24} {_sh(s):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_cagr(s.values,s.index)*100:>6.1f} {_dd(s.values)*100:>5.0f}")
|
||||
|
||||
# sweep fee + robustezza 11 settori (2018+)
|
||||
print("\n --- ROBUSTEZZA ---")
|
||||
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
||||
s = momentum(fee_side=fee); print(f" fee {fee*100:.2f}%/lato: Sh FULL {_sh(s):.2f} OOS {_sh(s[s.index>=EQ_HOLDOUT]):.2f}")
|
||||
s11 = momentum(universe=tuple(SECTORS)); spy11 = spy.reindex(s11.index)
|
||||
print(f" 11 settori (2018+): MOM Sh {_sh(s11):.2f} vs SPY {_sh(spy11):.2f} (periodo {s11.index[0].date()}+)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user