Date: 2026-09-04 Status: Research proposal (pre-registered before any implementation). Predecessor: edge_hunt15 PEAD x Momentum double-sort (REJECT, reports/edge_hunt15.md). This proposal is a different mechanism — sizing overlay, not universe filter.
Background and motivation
The validated equity-momentum edge (reports/momentum_spectrum.md, momentum_liquid.md): long-only top-decile 6-month winners, OOS Sharpe 1.25-1.69 on the liquid subset, bootstrap p<0.001. PEAD (post-earnings drift, edge_hunt7.md, edge_hunt10.md): long top-quintile of recent earnings surprises, OOS Sharpe 1.04, p=0.001.
Both edges are live in production (eqmom and pead bots). They run
independently: each sleeve has its own deploy fraction, and the
combined-book allocator (production/book_allocator.py) sets relative
weights at the book level. There is no per-name meta-signal: a name can
be in the EqMom top decile AND have an active PEAD signal, but the
two sleeves don't know about each other.
The rejected edge_hunt15 PEAD x Momentum double-sort filtered the universe to names that were in BOTH top deciles. It was REJECTED because the mom filter diluted PEAD's earnings signal and the filtered universe had only 12-19 names, too sparse to be significant. This proposal is structurally different: it KEEPS the EqMom top decile (101 names) and modulates position size based on whether each name has an active PEAD signal.
Mechanism (pre-registered, no re-grids)
Inputs (per rebalance date D):
eqmom_target[D]∈ {0, 1}: the EqMom top-decile set (existing long-only momentum, validated 1.06-1.25 Sharpe)pead_active[D, sym]∈ {0, 1}: 1 if the symbolsymhad a positive earnings surprise in the trailing 60 calendar days (matches the production PEAD sleeve's hold window)pead_surprise[D, sym]∈ ℝ: the magnitude of the most recent positive surprise (in percent; from data/earnings_dates.parquet)
Sizing multiplier s[D, sym] ∈ {0.75, 1.0, 1.25}:
- If
pead_active[D, sym] == 1andpead_surprise[D, sym] >= 5.0: both signals agree strongly →s = 1.25(boost 25%) - If
pead_active[D, sym] == 1andpead_surprise[D, sym] < 5.0: both signals agree weakly →s = 1.0(passthrough, since PEAD-with-positive-surprise is already in EqMom and shouldn't change the conviction) - If
pead_active[D, sym] == 0AND the symbol had a NEGATIVE earnings surprise in the trailing 60 days → conflict between momentum (EqMom says long) and earnings (recent miss) →s = 0.75(reduce 25%) - If
pead_active[D, sym] == 0AND no earnings event in trailing 60 days → no information →s = 1.0(passthrough)
The bucket logic is conditional on EqMom's signal, not on PEAD's: PEAD only modulates the size of names that EqMom already wants to hold. This avoids contaminating the EqMom signal with PEAD's universe (which is a smaller, more earnings-active subset).
effective_position[D, sym] = eqmom_base_size * s[D, sym]
Hypotheses
The expected mechanism is information-theoretic: PEAD and EqMom have correlation 0.39 (per edge_hunt15.md), which is moderate (not zero, not high). When the two signals agree, the joint information is stronger than either alone; when they disagree, the signal is mixed and the position should be smaller. The asymmetry (1.25 boost on strong agreement, 0.75 on conflict) is heuristic, not optimized.
Validation protocol (pre-registered)
- Universe: top-100 by listing persistence (the liquid subset from
reports/momentum_liquid.md) - Window: 2016-01-01..2026-07-31 (the existing momentum_liquid OOS window, plus the 2.5 years since the start of the earnings cache)
- IS/OOS split: pre-registered at 2021-01-01 (midpoint of the earnings cache; pre-registered BEFORE seeing results)
- Hold: 21 calendar days (matches EqMom rebalance)
- Lag: 1 day (signal at D-1 close decides for D+1 open; matches the existing EqMom convention)
- Costs: 10bps round trip (matches
validation/framework.pydefault) - Pre-registered primary metric: OOS Sharpe lift (overlay vs baseline) >= 0.05 AND bootstrap p < 0.10 AND maxDD not increase by > 5pp
- Pre-registered buckets: {0.75x, 1.0x, 1.25x} (NOT to be re-gridded after seeing the IS verdict; the look-ahead bug class from R10-13)
- Robustness grid (REPORTED, not selected): surprise threshold ∈ {2.5, 5.0, 7.5, 10.0}, hold window ∈ {30, 60, 90} days, sizing multipliers ∈ {0.5/1.0/1.5, 0.75/1.0/1.25, 1.0/1.0/1.0}
- Pre-registered pass: at least 18 of 27 (3x3x3) robustness cells show positive Sharpe lift (>= 2/3 majority) AND the pre-registered configuration passes the primary metric
Why this might fail (and what to do)
- No PEAD signal in the EqMom hold period: most EqMom names don't have an earnings event in the trailing 60 days. The bucket distribution will be ~80% passthrough, ~15% boost, ~5% reduce. If the boost/reduce days don't have a clear pattern, the test is INERT (no information). Acceptable: this is a realistic outcome and the overlay can still be useful as a "tilt" that pays off on the few days it fires.
- PEAD and EqMom are too correlated: if correlation is actually > 0.7, the signals are mostly redundant and the meta-signal doesn't add information. The validation will show no Sharpe lift.
- The 0.75/1.25 asymmetry is wrong: if the optimal is symmetric (e.g. 0.5/1.5) or fully one-sided (e.g. only boost, no reduce), the pre-registered configuration will look weak. The robustness grid covers the alternative configurations as REPORTED outcomes.
- Survivorship bias in the earnings cache: yfinance only serves recent quarters (~5 per name), so the PEAD signal for pre-2020 names is incomplete. The validation window starts at 2016-01-01, but most pre-2020 names will have no PEAD signal. This biases toward "no signal" rather than toward false positives. Acceptable.
Code locations to touch (when implementing)
validation/eqmom_pead_overlay.py(new) — the validation study following the same protocol asmomentum_liquid.pyandedge_hunt7.py(PEAD).production/equity_momentum_runner.py— add a sizing hook that consultsdata/earnings_dates.parquetand returns a multiplier in {0.75, 1.0, 1.25}, mirroring the structure ofproduction/carry_basis_overlay.pyandproduction/depth_filter.py. The existingEQMOM_NEWS_WEIGHThook shows the wire-up pattern.start_bots.py— addEQMOM_PEAD_OVERLAY=0(default off, operator opts in).tests/test_eqmom_pead_overlay.py(new) — unit tests for the sizing function and the per-name bucket logic.
What this proposal does NOT do
- Does not change the EqMom signal (still top-decile 6-month winners, no shorting, monthly rebalance)
- Does not change the PEAD signal (still top-quintile positive surprises, 60-day hold)
- Does not introduce leverage (the multiplier is bounded to [0.75, 1.25], not [0.0, 2.0])
- Does not require new data (uses the existing
data/earnings_dates.parquetcache) - Does not introduce a new sleeve (the overlay runs inside
equity_momentum_runner.py, not as a separate bot)