From 45b03863474d18511dba1f84ac2ff7e612f6c99c Mon Sep 17 00:00:00 2001 From: momentum dev Date: Sun, 13 Sep 2026 19:16:17 -0500 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM --- deploy/docker/overrides.live.seed.env | 10 ++ run_live_paper.py | 15 ++- strategy/evaluation/premarket_range.py | 133 ++++++++++++++++++++ tests/unit/strategy/test_premarket_range.py | 97 ++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 strategy/evaluation/premarket_range.py create mode 100644 tests/unit/strategy/test_premarket_range.py diff --git a/deploy/docker/overrides.live.seed.env b/deploy/docker/overrides.live.seed.env index dcb8bfc..1f76856 100644 --- a/deploy/docker/overrides.live.seed.env +++ b/deploy/docker/overrides.live.seed.env @@ -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 diff --git a/run_live_paper.py b/run_live_paper.py index ae96aad..14d488a 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -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 diff --git a/strategy/evaluation/premarket_range.py b/strategy/evaluation/premarket_range.py new file mode 100644 index 0000000..4718f0f --- /dev/null +++ b/strategy/evaluation/premarket_range.py @@ -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 diff --git a/tests/unit/strategy/test_premarket_range.py b/tests/unit/strategy/test_premarket_range.py new file mode 100644 index 0000000..7f85f5d --- /dev/null +++ b/tests/unit/strategy/test_premarket_range.py @@ -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