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:
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
|
||||
Reference in new issue
Block a user