Skip to content

View translation

UsLargeCapEarningsGapContinuationLS

Hypotheses

Earnings-Gap Continuation (PEAD Proxy), Long-Short Event-Driven Basket on 20 Liquid US Large Caps (USEQ 1-DAY — enter at the CLOSE of a >=3-sigma high-volume overnight gap, ride the post-announcement drift for ~15 sessions, 3-parameter)

Hypotheses

A LONG-SHORT, MULTI-INSTRUMENT, EVENT-DRIVEN continuation strategy on US equities (venue USEQ, 1-DAY bars) that harvests post-earnings-announcement drift (PEAD) WITHOUT needing an earnings calendar: an earnings announcement reveals itself in the price tape as an abnormally large overnight gap on abnormally large volume. The strategy scans a fixed 20-name liquid large-cap universe every daily bar, flags names whose open-vs-prior-close gap exceeds ~3x their trailing 60-session daily volatility on >=2x average volume, enters AT THE CLOSE OF THE EVENT DAY in the DIRECTION of the gap (long the gap-ups, short the gap-downs — Reg-T margin allows the short leg), and holds a fixed ~15 sessions. It deliberately does NOT enter at the open (the jump itself is not tradeable) and does NOT fade the gap — the documented anomaly is UNDER-reaction, i.e. continuation. This fills three of the furthest-from-target portfolio buckets at once: USEQ is 1.6% of 3074 experiments, long_short is 14.8%, multi-instrument is 18.5%; it also avoids BINANCE (70.0%, double its 40% cap). It is explicitly NOT the daily ETF regime-rotation / cross-asset dual-momentum family that lesson L99 kills, and NOT the cross-sectional weekly reversal that died before.

Hypotheses

Iteration 5 fixes the single semantic-audit finding: the executed universe was 13 names while the hypothesis pre-registers 20. The full 20-name basket is restored. The reason iteration 3 had cut it is addressed without shrinking the cross-section: the base template's cross-leg alignment barrier makes the YOUNGEST listing (META, 2012-05-18) the first tradeable session, while the walk-forward/holdout windows are carved from the PRIMARY bar type's catalog span — with AAPL (1980) primary, two of three WF windows validated on zero tradeable bars. Making META the PRIMARY leg (it is one of the hypothesis's declared instruments, so Layer 1.5 still passes) and the other 19 names the extra legs makes the data span and the tradeable span coincide at 2012-05-18, so every WF window and the holdout intersect real trading. I verified in the catalog that 3598 of META's 3599 sessions have a contemporaneous daily bar on all 19 other legs, so the barrier costs one session in 14 years. Nothing else changed: the event definition, close-of-event-day entry in the gap's direction, single 15-session holding clock, ranking/capacity logic, whole-share sizing, imports and class structure are byte-identical to the code that passed Layers 1-3 — only the module docstring and the config's instrument list differ. One caveat for the analyst: the sample is now ~14 years (2012-2026) rather than ~34, which is the honest cost of testing the pre-registered universe.

Implementation

Event-driven PEAD proxy on the full pre-registered 20-name US large-cap basket (USEQ, 1-DAY). Every session each name's overnight gap (open vs prior close) is scaled by its trailing 60-session daily return volatility; a gap of >= +gap_sigma_threshold_long sigma on >= 2x average volume is bought AT THE CLOSE of the event day, a gap of <= -gap_sigma_threshold_short sigma on >= 2x volume is sold short, and both legs are held the same fixed hold_days (~15) sessions to ride post-announcement drift. Candidates are ranked by |gap_z|, capped at 10 concurrent whole-share positions of 7.5% equity notional each with a gross cap of 1.5x equity. Holding time is measured in sessions on the observed session-close grid, so restarts cannot churn the book.

Verification Results

No code change required for this hypothesis. If the analyst wants to bound the effect, re-run with _volume_mult raised slightly (e.g. 2.2) and confirm the trade count and per-trade return are stable — the gate should not be sitting on a cliff.

Verification Results

Execution-timing realism of the volume gate. The event test requires volume_ratio = volume_t / mean(prior 60 volumes) >= 2.0, where volume_t is the FULL session's volume, and the entry is submitted on that same bar so it fills at that same session's close. This is exactly what the hypothesis pre-registers ('enter at the CLOSE of the event day'), and it is NOT look-ahead in the engine's terms (the bar is close-stamped, gap_z uses only the session open and the strictly-prior 60 closes, and sigma/avg_vol are computed BEFORE today's return and volume are appended to the deques, so no current-bar leakage into the z-score). The realism caveat is that a live implementation must commit a market-on-close order several minutes before the bell, at which point the day's volume is ~95-99% final, not 100%. For a 2x-average-volume gate on an earnings day this will almost never flip the decision, but a handful of marginal events near the threshold would differ live.

Verification Results

Either store the session TIMESTAMP instead of the index in _entry_sess (self._entry_sess[iid] = self._session_ts[-1], then bisect it like the ts_opened path), or subtract the trim offset from every stored index when _session_ts is truncated.

Verification Results

Latent index-staleness in the _sessions_held fallback path. self._entry_sess stores an ABSOLUTE index into self._session_ts, but calculate_signal trims that list (when len > 600 it is replaced by the last 400 entries), which shifts every index by 201 without rewriting _entry_sess. A stored index from before a trim would make (n - 1) - start negative, and max(0, ...) then resets the holding clock to zero, so a position could be held indefinitely. This path is currently unreachable: it is only taken when pos.ts_opened is 0/absent or older than _session_ts[0], and NautilusTrader always populates Position.ts_opened in backtest, paper and live, while a 400-session retained grid is ~26x the maximum hold_days of 30. Flagging it as latent rather than active.

Verification Results

Three things for the analyst to weigh at full-history backtest time, none of which block here. (1) Regime attribution is lopsided: the calm tercile (+15.52%, Sharpe 1.48) and the stressed tercile (+16.78%, Sharpe 1.21) carry the entire sandbox result while the NORMAL tercile is negative (-3.30%, Sharpe -0.36). PEAD should be a broadly-present under-reaction, not a barbell, so check whether the middle-vol loss is noise or a real hole. (2) The long-short premise is only half-evidenced: 116 long trades win 64.7% but 110 short trades win exactly 50.0%, so the short leg looks close to a coin flip in the sandbox draw. Because gap_sigma_threshold_short is independently searchable [2.0, 8.0], the optimizer can effectively switch the short leg off by pushing the threshold to the top of its range — if it does, treat the result as a long-only gap-continuation strategy that no longer tests the pre-registered long-short claim, and check the short leg's standalone PnL in the optimized detail file. (3) Sample cost of the iteration-5 fix: making META primary is the right call for window alignment, but it truncates the testable span to 2012-05-18 onward (~14y, ~3600 sessions) rather than the ~34y AAPL span. Sandbox trade rate (226 trades / 1650 days) extrapolates to roughly 500-700 trades over the full span, which is comfortably measurable, but the walk-forward and holdout windows are proportionally shorter than the developer's earlier iterations implied. Note also that borrow_cost_usd came back null despite 110 short trades even though USEQ declares borrow_rate_annual=0.005 and runner.py charges it on short legs — at 0.5%/yr over a ~15-session hold that is ~0.03%/trade against a 1.69% per-trade return, so it is immaterial to viability, but the analyst should confirm the field populates on the full run rather than assuming shorts were charged.

Backtest Review

The iteration-2 ANALYZING blocker is fixed the right way this time. META is primary (span 2012-05-18 -> 2026-09-11, 3599 bars) and the full pre-registered 20-name universe is restored, so the catalog span and the tradeable span now coincide: entry_diag shows 3537 bars seen, only 61 below min_bars, i.e. ~3476 evaluated sessions out of 3598. All three walk-forward windows and the holdout will intersect real trading instead of an empty book.

Backtest Review

Trades implement the hypothesis, verified on the ledger rather than assumed: 285 LONG (gap-ups) / 247 SHORT (gap-downs), entries dated on event days, median holding 23 calendar days = ~15-16 sessions against the pre-registered ~15, max 27 days (no runaway holds), and position sizes ~7-8% of equity per the _position_pct policy. No leg-specific exit clock remains, so the semantic auditor's iteration-4 order is respected.

Backtest Review

Numerically viable by a wide margin on this venue: avg_trade_return_pct 1.38% against the USEQ floor of 0.05% (prefer >0.10%), PF 1.578, 532 trades over 14.3 years, commissions 0.0 (correct for USEQ) and modeled impact only 1.68% of gross with capacity $356M. This is not a fee-fragile result.

Backtest Review

Breadth is genuine: all 20 names trade (10-39 trades each, no single-name dependence), 11 of 15 calendar years positive, max drawdown 5.94% with recovery factor 11.2. Regime spread is mild (calm 0.89 / normal 0.63 / stressed 0.45 Sharpe, all positive) — QA's sandbox worry about a negative NORMAL tercile does NOT reproduce on full history.

Backtest Review

Probabilistic Sharpe 0.977 on 3092 effective daily observations.

Backtest Review

The pre-registered long-short claim is half-falsified at the default draw. Ledger attribution: LONG 285 trades +$70,153, PF 2.461, +2.57%/trade, win 0.625 — SHORT 247 trades -$760, PF 0.989, -0.045%/trade, win 0.514. The short leg is a coin flip that contributes nothing; the entire edge is gap-up continuation. QA's concern (2) is confirmed on full history, not refuted.

Backtest Review

Because gap_sigma_threshold_short is independently searchable to 8.0, the likely optimizer answer is to push it to the top of the range and hand back an effectively long-only strategy. That is a legitimate search outcome, but it stops testing the registered long-short premise — the ANALYZING pass must read the short leg's standalone PnL in the optimized detail file and say plainly whether the surviving strategy is long-only.

Backtest Review

Sharpe 0.566 is thin and sharpe_ci_low is 0.028 — the bootstrap CI barely clears zero, so deflation over ~225 trials will be the binding test. Return kurtosis 66.97 and tail_ratio 1.07 say the result is a fat-tail event book: top 25 of 532 trades carry 84.6% of PnL (partly an artifact of equity-proportional sizing making the 2025-2026 trades largest in $ — pnl_pct concentration is milder). 2026 alone is ~22% of total PnL, which will pressure the one-year-concentration soft floor.

Backtest Review

max_drawdown_duration_days 700 and CAGR 3.84% / Calmar 0.62 — a slow, shallow grinder; the 2014, 2021 and 2022 years are all net negative.

Backtest Review

borrow_cost_usd is null despite 247 short trades even though USEQ declares borrow_rate_annual=0.005 (QA's concern 3). At ~0.03%/trade over a 15-session hold it is immaterial to viability, but it means the already-flat short leg is if anything slightly overstated. Confirm the field populates on the optimized run.

Analysis

Code↔hypothesis misalignment found by the semantic auditor — the code does NOT implement the hypothesis. Re-code the strategy to implement the hypothesis EXACTLY (instrument, timeframe, direction, the named edge/mechanic, sizing). Concrete issues: Pre-registered universe size is violated: the hypothesis title ("on 20 Liquid US Large Caps") and body ("scans a fixed 20-name liquid large-cap universe every daily bar") pre-register a 20-name cross-section, but the config trades only 13 instruments (AAPL.USEQ primary + 12 extras: MSFT, ADBE, ORCL, CSCO, QCOM, AMD, MU, INTC, JPM, WMT, DIS, NKE), and the strategy's _universe is exactly that list (primary + _extra_instrument_ids). The code's own module docstring confirms the deviation ("the basket was cut from 20 names to the 13 whose daily history reaches back into the walk-forward windows... The seven late-listing names (META, TSLA, GOOGL, CRM, NFLX, AMZN, NVDA) are removed"), and iteration 4's rationale explicitly keeps the 13-name basket, yet the hypothesis text/title were never revised. The drop is 35% of the pre-registered cross-section and is non-random — the excluded names are precisely the high-growth large caps where earnings-gap/PEAD events are most frequent and largest — so the executed strategy tests a materially different, history-availability-selected universe than the one pre-registered (a cross-sectional breadth claim, not an incidental detail). Layer 1.5 (hypothesis_config_check in src/verification/hypothesis_consistency.py:240-314) cannot catch this: it only verifies the primary instrument/venue is covered by the declared set and never compares universe cardinality, so the mismatch passes verification silently. Fix requires either restoring the 20-name basket or having the Research Lead revise the hypothesis to a 13-name universe with the selection rule stated.

Analysis

Benjamini-Hochberg at q=0.10 over 287 programme candidates keeps 4. A candidate that does not survive here is not distinguishable from the programme's own noise, however good its individual statistics look.
Strategy report

Backtest and paper results are hypothetical. Trading involves risk of loss.