diff --git a/docs/diary/2026-06-22-equities-sector-momentum.md b/docs/diary/2026-06-22-equities-sector-momentum.md new file mode 100644 index 0000000..0953bb0 --- /dev/null +++ b/docs/diary/2026-06-22-equities-sector-momentum.md @@ -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. diff --git a/scripts/research/eq_sector_momentum.py b/scripts/research/eq_sector_momentum.py new file mode 100644 index 0000000..508e8dd --- /dev/null +++ b/scripts/research/eq_sector_momentum.py @@ -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.year6} {'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.index6.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()