Tier-1 alpha research (local only, no production deploy): Sector residual momentum: two-factor SPY+sector residual and sector demean signals, IC harness + A/B. Sector resid clears pre-registered bars narrowly (PROMOTE for human wire design only). Sector demean fails t vs market resid. Earnings: earnings_events backfill (FMP bulk paid; FMP/AV per-symbol), 2a gap diagnostic report-only, 2b SUE IC (PARK; incomplete 48/506 coverage). History-depth: pre-registered doc + runner for MacBook deep rebuild/harness. Do not ship production residual or filters from this branch.
9.6 KiB
Sector-residual momentum (Tier-1 alpha research)
Status: PROMOTE (to human design decision only) — IC + A/B bars cleared; do not ship.
Branch: research/sector-residual-momentum
Production impact: none. Local research only. No scheduler / gate / prod-config changes.
Artifacts: reports/sector-residual-20260719-083356.json (+ companion .md)
Pre-registration (locked before first research run)
Hypothesis
Residualizing 12–1 momentum against the sector, not only the market, reduces factor volatility at similar return (Blitz / Huij / Martens-style) → higher Sharpe on the production book when the residual replaces market-only residual as the momentum leg.
Signals (candidates)
| signal | construction |
|---|---|
mom_12_1_sector_resid |
Two-factor residual vs SPY + ticker’s sector ETF. Same window as mom_12_1_resid: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas without intercept; cumulate residual returns over the formation window. |
mom_12_1_sector_demeaned |
Plain mom_12_1 minus the cross-sectional mean of mom_12_1 within the same GICS sector that week (≥2 names in sector). No regression. |
Baselines (same run, same cross-sections — iron rule)
Always report side-by-side with:
mom_12_1mom_12_1_resid
Computed on the identical weekly non-overlapping cross-sections in this run. Never compare against IC numbers from another report.
Iron rule (IC harness)
Source of truth: _signal_evaluation in app/services/backtest_service.py.
- Mean weekly Spearman IC on non-overlapping weekly windows
- Bar: |mean IC| ≥ ~0.03, consistent positive sign,
reliable: true(≥ 12 windows)
Promotion to portfolio A/B (candidate → book)
A candidate promotes to A/B only if:
- It clears the iron-rule bar and
- Its IC t-stat ≥ that of
mom_12_1_residon the same cross-sections.
Portfolio A/B grading (if and only if IC promotion fires)
- Swap candidate in as the momentum leg of the production 80/20 momentum/vol rank and as the gate-percentile signal.
fill_mode=close,COST_PER_SIDE = 0.001, full config otherwise unchanged.- Validation window = entries ≥ 2024-07-01 (call it validation, not holdout — contaminated by prior experiments).
- Pre-registered promotion bar:
- validation Sharpe ≥ control − 0.5·SE
- full-period Sharpe and max-DD not worse than control
- Report Lo / Mertens-adjusted SEs.
Optional sector-cap sub-experiment
Only if labels are in and A/B ran: max 3 positions per sector in the 10-slot book. Same A/B grading. Tail-trim presumption of guilt (rule 4): report entry counts and both tails of the R distribution. Rising win rate with falling Sharpe/CAGR = red flag → do not promote.
This run: sector-cap arm not executed (optional; A/B unconstrained book only). Can be a human-approved follow-up.
Verdict labels
| label | meaning |
|---|---|
| PROMOTE | Clears pre-registered bar; human decides next (wire design separate) |
| PARK | Inconclusive / weak; keep machinery, no book change |
| DEAD | Failed iron rule or worse than residual baseline with clear sign |
Explicit non-goals
- No production deploy from this doc
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking, inverse-vol sizing, gap-caps, unconditional FIP filter
Data provenance
Snapshot race guard
| check | result |
|---|---|
| Snapshot path | backtest_snapshots/prod.sqlite |
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
| Tickers / OHLCV | 506 / 629,263 |
| Bars min / avg / max | 14 / 1246.1 / 1261 |
| OHLCV range | 2021-06-24 → 2026-07-02 |
| Partial-build red flags | none (avg bars healthy) |
Integrity fingerprint on same run: fip_id mean IC −0.045 / t −2.91
(35 weeks, N≈498) — matches the established prod fingerprint.
Sector labels
| source | count |
|---|---|
| Public S&P 500 GICS CSV | 496 newly filled |
| FMP profile requests | 10 (all missing after CSV) |
| Mapped / universe | 505 / 506 (99.8%) |
| With mappable ETF | 505 |
| Still missing | RHM only |
Persist path: data/research/ticker_sector_map.json.
FMP aliases (Technology, Consumer Defensive, Financial Services) map to
SPDRs via the alias table in app/services/sector_map.py.
Sector ETFs in benchmark_prices (auxiliary only — not tradable)
| symbol | bars | min date | max date |
|---|---|---|---|
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
Fetched via Alpaca Adjustment.SPLIT into benchmark_prices (same table as
SPY) so they never enter the ticker universe or candidate replay.
Results
Generated: 2026-07-19T08:33:56
IC harness (identical cross-sections, production 506-name universe)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|---|---|---|---|---|---|---|---|
| mom_12_1_sector_resid | 0.0578 | 2.34 | 35 | 497.7 | true | 65.7 | 0.0245 |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
IC promotion grades
| candidate | iron rule | t ≥ resid | promote_to_ab |
|---|---|---|---|
mom_12_1_sector_resid |
pass (IC 0.058, +sign, reliable) | yes (2.34 ≥ 1.98) | yes |
mom_12_1_sector_demeaned |
pass (IC 0.034, +sign, reliable) | no (1.32 < 1.98) | no |
Portfolio A/B — mom_12_1_sector_resid as residual leg
Config: production 80/20 residual/high-vol rank + gate percentile, fill_mode=close,
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
2024-07-01.
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|---|---|---|---|---|---|---|---|
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
| train | treatment (sector resid) | 1.57 | 0.677 | 35.5 | 19.8 | 176 | 530 |
| validation | control | 2.92 | 0.709 | 76.3 | 11.7 | 150 | 501 |
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
| full | treatment | 2.09 | 0.491 | 51.0 | 19.8 | 337 | 1005 |
Pre-registered A/B checks
| check | result |
|---|---|
| val Sharpe ≥ control − 0.5·SE | pass (2.57 ≥ 2.92 − 0.5×0.701 = 2.5695) — knife-edge |
| full Sharpe not worse | pass (2.09 = 2.09) |
| full max DD not worse | pass (19.8 < 21.4) |
Qualified long candidates: control 1086 vs treatment 1210 (sector residual gates a slightly larger set).
Verdict
| signal | verdict | note |
|---|---|---|
mom_12_1_sector_resid |
PROMOTE → human wire-in decision | IC modestly beats market residual; A/B clears pre-reg bar narrowly. Do not ship from this branch. |
mom_12_1_sector_demeaned |
DEAD (for promotion) | Iron-rule IC magnitude ok, but t-stat loses to mom_12_1_resid. Cheap variant not competitive. |
Read carefully (for the human)
- IC edge is real but small. Sector residual IC 0.0578 / t 2.34 vs market residual 0.0552 / t 1.98 on the same 35 windows — better consistency (ic+ 65.7% vs 60%) and slightly higher mean, not a different factor class.
- A/B is not a clear Sharpe win. Full-period Sharpe is flat (2.09). Validation Sharpe is lower than control (2.57 vs 2.92) and only clears the pre-registered “within 0.5 SE” cushion by ~0.001. Train improves; validation worsens — classic regime-split noise on ~2 years.
- Risk side is friendly. Full max DD improves (19.8% vs 21.4%); train DD also better. Matches the “lower factor vol” half of the hypothesis more than the “higher Sharpe” half on this window.
- Survivorship / short history. Same caveats as all current research: today’s constituents, ~35 independent weekly windows, one post-2021 regime dominant. Task 3 (history depth) should re-check IC stability before any wire-in.
- Not shipped. Machinery lives on the research branch; production residual path is untouched.
What a human must decide next
- Accept or reject replacing
mom_12_1_residwithmom_12_1_sector_residas the production residual (gate + 80/20 mom leg), or keep market residual and treat sector residual as research-only. - If leaning accept: require Task 3 history-depth confirmation (IC era split pre/post-2021) before any production PR.
- Optional: run sector-cap ≤3 A/B with full tail diagnostics (not run here).
- Do not merge this verdict into main strategy docs without review.
- Wire-in design (live sector map refresh, ETF series ops, fallback when sector missing) is a separate approved engineering step.
Implementation notes (research machinery)
| piece | role |
|---|---|
app/services/sector_map.py |
GICS→ETF map, symbol normalise, JSON load/save |
app/services/backtest_service.py |
multi-factor residual; mom_12_1_sector_resid in _signal_values; demean inject |
scripts/build_ticker_sector_map.py |
SP500 CSV + FMP gap fill |
scripts/fetch_sector_etfs_to_snapshot.py |
Alpaca → snapshot benchmark_prices |
scripts/run_sector_residual_research.py |
race guard, IC, optional A/B, reports |
data/research/ticker_sector_map.json |
persisted labels (research only) |
Artifacts
- JSON:
reports/sector-residual-20260719-083356.json - MD copy:
reports/sector-residual-20260719-083356.md