Files
momentum-system/strategy/evaluation/premarket_range.py
T
momentum devandClaude Opus 5 45b0386347 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
2026-09-13 19:16:17 -05:00

134 lines
5.8 KiB
Python

"""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