Files
signal-platform/docs/research/sector-residual-momentum.md
T
dennisthiessen fa25b6ee68 research: sector residual, earnings gap/SUE, history-depth scaffolding
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.
2026-07-19 09:33:34 +02:00

9.6 KiB
Raw Blame History

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 121 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 + tickers 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_1
  • mom_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:

  1. It clears the iron-rule bar and
  2. Its IC t-stat ≥ that of mom_12_1_resid on 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)

  1. 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.
  2. 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.
  3. 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.
  4. Survivorship / short history. Same caveats as all current research: todays constituents, ~35 independent weekly windows, one post-2021 regime dominant. Task 3 (history depth) should re-check IC stability before any wire-in.
  5. Not shipped. Machinery lives on the research branch; production residual path is untouched.

What a human must decide next

  1. Accept or reject replacing mom_12_1_resid with mom_12_1_sector_resid as the production residual (gate + 80/20 mom leg), or keep market residual and treat sector residual as research-only.
  2. If leaning accept: require Task 3 history-depth confirmation (IC era split pre/post-2021) before any production PR.
  3. Optional: run sector-cap ≤3 A/B with full tail diagnostics (not run here).
  4. Do not merge this verdict into main strategy docs without review.
  5. 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