17 · Sector momentum, clean — external indices, point-in-time
Date: 2026-05-15 Decision: REJECT (do not apply to engine).
Why this re-test
Method review #15 tested a sectorMom feature and reported a striking
in-sample bucket spread (~21pp) that evaporated to ~0.7pp in OOS. Audit
identified the cause: the construction used the same cohort's realized 90-day
returns to build the sector signal. That is lookahead — the sector signal is
not observable at decision time, so any backtest measuring forward returns
mechanically benefits from cross-fitting noise into both the feature and the
target.
This re-test replaces the same-cohort proxy with external sector indices (iShares STOXX 600 sector ETFs traded on Xetra), making the signal strictly observable at end-of-day t and usable on t+1.
Sector → index mapping
| FR sector tag (Company.sectorTag) | Yahoo symbol | iShares ETF longName |
|---|---|---|
| Technologie | EXV3.DE | STOXX Europe 600 Technology |
| Santé & Pharma | EXV4.DE | STOXX Europe 600 Health Care |
| Industrie | EXH4.DE | STOXX Europe 600 Industrial Goods & Services |
| Finance & Banque | EXV1.DE | STOXX Europe 600 Banks |
| Assurance | EXH5.DE | STOXX Europe 600 Insurance |
| Services aux entreprises | EXH2.DE | STOXX Europe 600 Financial Services |
| Distribution & Commerce | EXH8.DE | STOXX Europe 600 Retail |
| Énergie | EXH1.DE | STOXX Europe 600 Oil & Gas |
| Eau & Environnement | EXH9.DE | STOXX Europe 600 Utilities |
| Immobilier | EXI5.DE | STOXX Europe 600 Real Estate |
| Médias & Communication | EXH6.DE | STOXX Europe 600 Media |
| Télécoms | EXV2.DE | STOXX Europe 600 Telecommunications |
| Chimie & Matériaux | EXV7.DE | STOXX Europe 600 Chemicals |
| Agroalimentaire | EXH3.DE | STOXX Europe 600 Food & Beverage |
| Tourisme & Hôtellerie | EXV9.DE | STOXX Europe 600 Travel & Leisure |
| Construction & BTP | EXV8.DE | STOXX Europe 600 Construction & Materials |
| Luxe & Mode | EXH7.DE | STOXX Europe 600 Personal & Household Goods |
| Défense & Aérospatial | EXH4.DE | Industrial Goods & Services (best fit) |
| Transport & Logistique | EXH4.DE | Industrial Goods & Services (best fit) |
| Agriculture | EXH3.DE | Food & Beverage (best fit) |
| Autres / unknown | EXSA.DE | STOXX Europe 600 (broad fallback) |
The original sector RP indices (SX8R.PA, SXDR.PA, …) requested in the task
brief were validated against Yahoo and all returned "symbol may be
delisted". The iShares sector ETF family is the live, traded equivalent and
was validated symbol-by-symbol against Yahoo's chart.meta.longName before
being committed to the map. All 18 symbols return ~2,539 daily bars over the
last 10 years (full continuous history).
Point-in-time methodology
For a declaration with publication date pubDate:
sym = SECTOR_INDEX_MAP[company.sectorTag] (fallback EXSA.DE)
end_close = last close STRICTLY BEFORE pubDate (yesterday's close)
start_close = last close at or before pubDate - 90 days
sectorMom = 100 · (end_close / start_close - 1) in percent
Strict less-than on end_close is what removes the lookahead: at decision
time t+1 (the morning a retail user reacts to the AMF publication), only
end-of-day closes up to t are observable.
Buckets: strong-down ≤ -10% · mild-down (-10, 0)% · mild-up [0, 10)% ·
strong-up ≥ +10%.
Coverage
- 15,171 BUY declarations with realized
returnFromPub90d - 15,171 / 15,171 (100%) attached with a non-null
sectorMomthanks to the STOXX 600 broad fallback on unmapped tags - ETF history covers 2016-05 → 2026-05, so every declaration since 2017 has a valid 90-day lookback window
In-sample bucket diagnostic
| Bucket | n | Mean r90d | Win-rate | Wilson 95% CI |
|---|---|---|---|---|
| strong-down (≤ -10%) | 1,195 | -0.07% | 42.1% | [39.3, ~] |
| mild-down (-10, 0)% | 4,936 | 0.83% | 46.0% | [44.6, ~] |
| mild-up [0, 10)% | 7,250 | -1.59% | 42.6% | [41.5, ~] |
| strong-up (≥ +10%) | 1,790 | -1.92% | 47.8% | [45.5, ~] |
Spread strong-down minus strong-up = 1.85pp (vs the 21pp same-cohort artifact in run #15). The clean signal is essentially flat across buckets — within Wilson CI overlap. This alone is a strong indication that sector-index momentum does NOT carry meaningful cross-sectional information on top of the existing 35-pt signal score + role×size win-rate prior.
Walk-forward OOS (24m train / 12m test, 2 folds, top-10/week, 90d hold)
| Strategy | n picks | Mean | Win-rate | SR_ann | ΔWinRate | ΔSR_ann | p-value | Decision |
|---|---|---|---|---|---|---|---|---|
| baseline (A1 proxy) | 1,060 | 4.14% | 50.9% | 0.858 | — | — | — | — |
| contrarian (+5 strong-down / +2 mild-down / 0 mild-up / -2 strong-up) | 1,060 | 4.17% | 50.9% | 0.537 | 0.0 | -0.321 | 0.976 | REJECT |
| momentum (+5 strong-up / +2 mild-up / -2 mild-down / -5 strong-down) | 1,060 | 1.72% | 50.0% | -0.845 | -0.9 | -1.703 | 0.011 | REJECT |
Validation gate (ΔWinRate ≥ +2pp OR ΔSR_ann ≥ +0.15, AND p < 0.10):
- contrarian: directional gate fails (ΔWR=0, ΔSR negative); permutation p=0.976 → indistinguishable from baseline.
- momentum: directional gate fails badly (ΔSR -1.7); permutation p=0.011 (significant in the wrong direction).
Decision
Do not modify src/lib/recommendation-engine.ts. Neither hypothesis
passes the OOS validation gate. The contrarian hypothesis is directionally
neutral and the momentum hypothesis is significantly worse than baseline.
Honest takeaway: once the same-cohort lookahead is removed, sector momentum adds no signal on the BUY side of this dataset. The information it might have provided is already captured by the role×size return prior and the recency decay.
What ships
Even though no engine change is applied, the infrastructure (table, mapping, PIT query, cron refresh, backfill script) is committed so future re-tests against a different hypothesis or horizon don't have to rebuild sector data plumbing from scratch.
prisma/schema.prisma— newSectorIndexHistorymodelsrc/app/api/migrate/route.ts— IF NOT EXISTS create blocksrc/lib/sector-index.ts— mapping,fetchSectorIndex,refreshSectorIndices,sectorMomentumAt(strict PIT)src/app/api/cron/route.ts— step 12: nightly sector ETF refreshscripts/backfill-sector-indices.mjs— one-shot 5y history fillscripts/backtest-feature-sector-clean.mjs— the validator abovedocs/method-review/17-sector-momentum-clean.md— this file
Raw results JSON: /tmp/sector-clean-backtest.json.