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