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

+10
View File
@@ -75,6 +75,16 @@ TRADING_MAX_RISK_DOLLARS=50000
TRADING_MIN_DAY_VOLUME=300000
TRADING_MIN_ENTRY_GAP_PCT=0.10
TRADING_MIN_QUALITY_SCORE=0.60
# PREMARKET-RANGE RANK BOOST (2026-09-13). A name that already travelled >=15%
# premarket ran >=50% on the day 15.4% of the time vs 5.0% for the rest -- a 3.1x
# lift, the largest single predictor measured over 1,040 screened symbol-days.
# Replaying the armed-set selection, the gain is real but modest (mean run of the
# top-5 armed 39.8% -> 42.0%, 27 -> 32 big runners per 145 slots) because gap x
# rvol already encodes much of it. BOOST only: a hard filter on the same feature
# scored WORSE than baseline. Set TRADING_PM_RANGE_BOOST=0 to disable.
TRADING_PM_RANGE_BOOST=1
TRADING_PM_RANGE_MIN_PCT=15.0
TRADING_PM_RANGE_GAIN=3.0
TRADING_PRICE_MAX=20.0
TRADING_PRICE_MIN=2.0
TRADING_REENTRY_MIN_LOSS_PCT=0.01
+14 -1
View File
@@ -65,6 +65,8 @@ from runtime.market_calendar import (
)
from runtime.triggers import ArmedTriggerBook
from runtime.watcher import Watcher, WatcherConfig
from strategy.evaluation.premarket_range import (
PremarketRangeConfig, premarket_range_pct, premarket_rank_multiplier)
from strategy.population import PopulationConfig
from strategy.exits import ExitConfig
from storage.event_schema import EventMode, ModuleTickEvent
@@ -911,6 +913,9 @@ def main(argv: list[str] | None = None) -> int:
cat_map = {}
news_rank_gain = float(os.environ.get("NEWS_RANK_BOOST_GAIN", "2.0"))
dilutive_penalty = float(os.environ.get("NEWS_DILUTIVE_PENALTY", "0.1"))
# premarket travel: the one measured predictor of a >=50% session (3.1x
# lift at >=15%). See strategy/evaluation/premarket_range.py for the study.
pm_cfg = PremarketRangeConfig.from_env()
_PEGGED = ("acqui", "merger", "buyout", "to be acquired", "take-private")
@@ -967,6 +972,8 @@ def main(argv: list[str] | None = None) -> int:
bars = rq.query_minute_bars(rt["research_con"], g.symbol, session_date)
hi, lo, complete = opening_range(bars, orb_bars=orb_bars_v)
stop = lo * (1.0 - stop_cushion_v) if lo else None
# premarket travel off the SAME frame — no extra query
pm_range = premarket_range_pct(bars)
catalyst = news_map.get(g.symbol, "")
# recent $-volume (last 5 regular-hours bars) — the liquidity gate.
# rvol is relative; this is the absolute "can it absorb an order" check.
@@ -1016,8 +1023,10 @@ def main(argv: list[str] | None = None) -> int:
"day_open": day_open,
# a fresh catalyst is WHY a small-cap runs — boost it up the rank;
# times an optional manual per-symbol weight (default 1.0)
"pm_range_pct": pm_range,
"_score": (g.gap_pct * max(g.relative_volume, 0.1)
* _cat_mult(g.symbol, bool(catalyst))
* premarket_rank_multiplier(pm_range, pm_cfg)
* weights.get(g.symbol, 1.0)),
"complete": complete,
})
@@ -1044,7 +1053,11 @@ def main(argv: list[str] | None = None) -> int:
pass
armed = sum(1 for t in book.triggers.values() if t.state == "armed")
n_news = sum(1 for c in candidates if c.get("catalyst"))
return f"tracking={len(book.triggers)} armed={armed}" + (f" news={n_news}" if n_news else "")
n_pm = sum(1 for c in candidates
if (c.get("pm_range_pct") or 0.0) >= pm_cfg.min_range_pct)
return (f"tracking={len(book.triggers)} armed={armed}"
+ (f" news={n_news}" if n_news else "")
+ (f" pm-range={n_pm}" if n_pm else ""))
def step_quotes():
"""Record NBBO bid/ask SIZES for tracked + held names (order-book-imbalance
+133
View File
@@ -0,0 +1,133 @@
"""Premarket range as a rank feature — the one measured predictor of a big day.
Measured 2026-09-13 over 1,040 screened symbol-days (2026-08-01 onward), asking
which premarket property predicts the regular 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%
in the session (15.4% vs 5.0%). That is the largest single lift found anywhere in
this dataset -- larger than gap %, relative volume, or the news flag the ranking
previously leaned on -- and it costs nothing: it is computed from minute bars the
loop already stores.
Two results shaped this module and are worth keeping in mind before "improving" it:
* It is a STEP, not a gradient. 15-30% and >= 30% are indistinguishable (14.7 vs
15.9), as are 5-15% and < 5% (5.9 vs 4.3). So the multiplier is a threshold,
not a curve. A linear scale would be inventing precision the data does not have.
* Premarket VOLUME is not predictive and must not be used here. Bucketed by
premarket volume as a fraction of 20-day average, the >= 50% rate went
10.0 / 4.2 / 7.9 / 9.0% -- non-monotonic, no signal. Raw premarket volume is
worse: it runs INVERSELY to the outcome, because illiquid names have low
premarket volume and larger percentage moves. ``premarket_volume`` being NULL
in scanner_snapshots costs nothing; premarket range is the field that matters.
A/B ON THE ACTUAL RANKING, and the reason to keep expectations low: replaying the
armed-set selection over the same 29 sessions, the univariate 3.1x does NOT carry
through to a 3x improvement, because gap% x rvol already encodes much of the same
information -- a name that gapped hard on heavy relative volume has usually already
travelled premarket. Top-5 armed set, mean/median run of the names armed and how
many >= 50% runners were captured out of 145 slots:
gap x rvol (baseline) 39.77% / 10.97% 27 runners
x1.5 if PM range >= 10% 41.75% / 12.14% 30
x3.0 if PM range >= 15% 42.00% / 13.16% 32
x3.0 if PM range >= 20% 42.20% / 13.21% 31
HARD FILTER PM range >= 15% 37.27% / 12.67% 24 <- worse than baseline
PM range alone 32.86% / 12.65% 20 <- much worse
So: BOOST, never gate, and never let it replace gap/rvol. The gain is real but
modest -- roughly +2 points of mean run and 4-5 extra big runners per 145 armed
slots. Every threshold in 10-20% and every gain in 1.5-5.0 beat the baseline,
which is the flat plateau that says this is not fitted. The shipped values (15%,
3.0x) are taken from the univariate study -- the measured step and the measured
lift -- not from the peak cell of that sweep.
Fail-open throughout: an unknown range returns a neutral 1.0 multiplier, never a
penalty, so a data gap can only cost a name its boost -- never bench it.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass
class PremarketRangeConfig:
"""Threshold boost applied to a candidate's rank score."""
enabled: bool = True
# premarket travel (high-to-low as a % of the low) at or above which a name is
# treated as "already in motion". 15% is the measured step, not a fitted value.
min_range_pct: float = 15.0
# rank multiplier applied at/above the threshold. Set to the measured 3.1x
# lift rather than a sweep peak -- see the A/B note below. This reorders the
# armed set, it does not gate entries; population and entry gates still decide.
gain: float = 3.0
@classmethod
def from_env(cls, env: dict | None = None) -> "PremarketRangeConfig":
v = dict(os.environ)
if env:
v.update(env)
def _f(key: str, default: str) -> float:
try:
return float(v.get(key, default))
except (TypeError, ValueError):
return float(default)
return cls(
enabled=str(v.get("TRADING_PM_RANGE_BOOST", "1")).strip().lower()
in {"1", "true", "yes", "on"},
min_range_pct=_f("TRADING_PM_RANGE_MIN_PCT", "15.0"),
gain=_f("TRADING_PM_RANGE_GAIN", "3.0"),
)
def describe(self) -> str:
if not self.enabled:
return "pm-range boost off"
return f"pm-range >={self.min_range_pct:g}% -> x{self.gain:g}"
def premarket_range_pct(bars) -> float | None:
"""Premarket high-to-low travel as a percentage of the low. None if unknowable.
``bars`` is a minute-bar frame carrying ``is_premarket``, ``high`` and ``low``
(what ``research.query.query_minute_bars`` returns). Anything missing, empty or
non-positive yields None rather than a number the caller might trust.
"""
if bars is None or len(bars) == 0:
return None
try:
if "is_premarket" not in bars:
return None
pm = bars[bars["is_premarket"] == True] # noqa: E712 — pandas mask
if len(pm) == 0:
return None
hi = float(pm["high"].max())
lo = float(pm["low"].min())
except Exception: # noqa: BLE001 — a malformed frame must not break ranking
return None
if not (lo > 0) or not (hi > 0) or hi < lo:
return None
return (hi - lo) / lo * 100.0
def premarket_rank_multiplier(range_pct: float | None,
cfg: PremarketRangeConfig) -> float:
"""Rank multiplier for a candidate whose premarket range is ``range_pct``.
A threshold, not a curve -- see the module docstring. Returns 1.0 (neutral)
when disabled or when the range is unknown.
"""
if not cfg.enabled or range_pct is None:
return 1.0
return cfg.gain if range_pct >= cfg.min_range_pct else 1.0
@@ -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