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
134 lines
5.8 KiB
Python
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
|