feat: rank gappers by premarket range, the one measured predictor

Over 1,040 screened symbol-days (2026-08-01 on), asking which premarket
property predicts the session running >=50% open-to-high:

    premarket range   n     avg run   ran >=50%
    >= 30%           201     34.7%      15.9%
    15-30%           136     28.3%      14.7%
     5-15%           303     15.2%       5.9%
      < 5%           400     16.8%       4.3%

A name that already travelled >=15% premarket is 3.1x more likely to run
>=50% (15.4% vs 5.0%) -- the largest single lift in this dataset, and free:
it comes off minute bars the arm pass has already fetched.

Two findings constrain the shape:

  - It is a STEP at 15%, not a gradient (15-30% and >=30% are 14.7 vs 15.9;
    5-15% and <5% are 5.9 vs 4.3). So the multiplier is a threshold. A linear
    scale would invent precision the data does not support.

  - Premarket VOLUME carries NO signal (10.0/4.2/7.9/9.0% across participation
    buckets, non-monotonic) and raw premarket volume runs inversely to the
    outcome. scanner_snapshots.premarket_volume being NULL costs nothing;
    range is the field that mattered all along.

Do not expect 3x. Replaying armed-set selection over the same 29 sessions,
top-5 by mean/median run and >=50% runners captured out of 145 slots:

    gap x rvol (baseline)        39.77% / 10.97%   27
    x3.0 if PM range >= 15%      42.00% / 13.16%   32
    HARD FILTER >= 15%           37.27% / 12.67%   24   <- worse
    PM range alone               32.86% / 12.65%   20   <- much worse

gap x rvol already encodes much of the same information, so the marginal gain
is ~+2 points of mean run and 4-5 extra big runners per 145 slots. It is
BOOST-only for a reason: gating on this feature scored below baseline, and it
must never replace gap/rvol. Every threshold in 10-20% and gain in 1.5-5.0 beat
baseline -- a flat plateau, so not fitted. The shipped 15%/3.0x are the measured
step and the measured lift, not the sweep's peak cell.

Fail-open: an unknown range yields a neutral 1.0, never a penalty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
This commit is contained in:
momentum devandClaude Opus 5 committed 2026-09-13 19:16:17 -05:00
1 parent ff63b19e91
commit 45b0386347
4 files changed
+254 -1

No files matched your search

@@ -0,0 +1,97 @@
"""Premarket range as a rank feature.
Measured 2026-09-13 over 1,040 screened symbol-days: a name that travelled >=15%
premarket ran >=50% in the session 15.4% of the time vs 5.0% for the rest -- a
3.1x lift, the largest single predictor in the dataset. The relationship is a
STEP at 15%, not a gradient, and premarket VOLUME carries no signal at all.
"""
import pandas as pd
import pytest
from strategy.evaluation.premarket_range import (
PremarketRangeConfig,
premarket_range_pct,
premarket_rank_multiplier,
)
CFG = PremarketRangeConfig(enabled=True, min_range_pct=15.0, gain=3.0)
def _bars(rows):
"""rows = [(is_premarket, high, low), ...]"""
return pd.DataFrame(
[{"is_premarket": pm, "high": h, "low": lo, "close": lo} for pm, h, lo in rows]
)
def test_range_is_measured_over_premarket_bars_only():
"""A wild regular-hours session must not inflate the premarket reading."""
bars = _bars([(True, 11.0, 10.0), # premarket: 10.0 -> 11.0 = +10%
(False, 40.0, 9.0)]) # RTH: ignored
assert premarket_range_pct(bars) == pytest.approx(10.0)
def test_range_spans_the_premarket_high_and_low():
bars = _bars([(True, 10.5, 10.0), (True, 12.0, 9.5), (True, 11.0, 10.2)])
# high 12.0, low 9.5 -> (12.0-9.5)/9.5
assert premarket_range_pct(bars) == pytest.approx(26.3157, rel=1e-3)
def test_boost_applies_at_and_above_the_threshold():
assert premarket_rank_multiplier(15.0, CFG) == 3.0
assert premarket_rank_multiplier(30.0, CFG) == 3.0
assert premarket_rank_multiplier(200.0, CFG) == 3.0
def test_no_boost_below_the_threshold():
assert premarket_rank_multiplier(14.9, CFG) == 1.0
assert premarket_rank_multiplier(0.0, CFG) == 1.0
def test_it_is_a_step_not_a_gradient():
"""15-30% and >=30% were indistinguishable in the study (14.7% vs 15.9%), so
the multiplier must not scale with magnitude."""
assert premarket_rank_multiplier(16.0, CFG) == premarket_rank_multiplier(500.0, CFG)
@pytest.mark.parametrize("bars", [
None,
pd.DataFrame(),
pd.DataFrame([{"high": 11.0, "low": 10.0}]), # no is_premarket column
_bars([(False, 11.0, 10.0)]), # no premarket bars
_bars([(True, 11.0, 0.0)]), # zero low
_bars([(True, 0.0, 0.0)]), # dead frame
])
def test_unknowable_range_is_none(bars):
assert premarket_range_pct(bars) is None
def test_unknown_range_is_neutral_never_a_penalty():
"""A data gap may cost a name its boost; it must never rank it below peers."""
assert premarket_rank_multiplier(None, CFG) == 1.0
def test_disabled_config_is_a_no_op():
off = PremarketRangeConfig(enabled=False, min_range_pct=15.0, gain=3.0)
assert premarket_rank_multiplier(99.0, off) == 1.0
def test_from_env_defaults_and_overrides():
d = PremarketRangeConfig.from_env({})
assert d.enabled and d.min_range_pct == 15.0 and d.gain == 3.0
off = PremarketRangeConfig.from_env({"TRADING_PM_RANGE_BOOST": "0"})
assert not off.enabled
tuned = PremarketRangeConfig.from_env(
{"TRADING_PM_RANGE_MIN_PCT": "20", "TRADING_PM_RANGE_GAIN": "1.5"})
assert tuned.min_range_pct == 20.0 and tuned.gain == 1.5
# a junk value must fall back, not raise
assert PremarketRangeConfig.from_env({"TRADING_PM_RANGE_GAIN": "abc"}).gain == 3.0
def test_ranking_reorders_a_mover_above_a_bigger_gapper():
"""The point of the feature: a 20% gapper already in motion outranks a 30%
gapper that has not moved."""
quiet = 30.0 * 2.0 * premarket_rank_multiplier(3.0, CFG) # big gap, flat PM
mover = 20.0 * 2.0 * premarket_rank_multiplier(25.0, CFG) # smaller gap, moving
assert mover > quiet