Runner-propensity selection lever: cheap-price + premarket-gap, shadow-measured
The float audit and the labeler both point at the SAME free, available-today selection signal the bot underuses: gap magnitude + cheap absolute price separate runners from faders, while the bot's gap%×RVOL rank leans on RVOL (labeler-proven FLAT) and CAPS gap. - strategy/evaluation/runner_propensity.py: pure, transparent, FROZEN-threshold primitive -> a coarse runner-propensity tier = gap_tier(0-3) + price_tier(0-2). Gap is weighted heavier BY CONSTRUCTION because the data says it dominates; premarket gap preferred over RTH gap; cheap price is a secondary tie-breaker (the two are correlated). 6 unit tests. - research/labeler.py runner_rank_report + `runner-rank` CLI: measures the lever on the v1 ORB labeled set. SHADOW/measurement only, not wired into live selection. MEASURED (v1 gappers, gap>=10%, n=187, 19 autocorrelated days -- small, caveated): the combined tier ladders monotonically: tier1 2% -> tier3 35% -> tier5 (cheap+big-gap) 48% runner (1.63x base). Gap dominates (>=50% -> 48%, 1.64x); cheap<$2 a real secondary (1.29x). OPERATIONAL KICKER: the live ~35% TRIGGER_GAP_MAX would EXCLUDE 73 gappers whose runner rate is 44% vs 20% for the kept <=35% -- the cap discards the best runners (this is how NXTS at +81% got excluded). Caveat: +1R/+2R separate far less than the +20% runner label, and gap/price are correlated; a hypothesis to shadow-confirm + adversarially validate before touching the live cap, not a validated rank. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae0651b769
commit
97e3bf91a6
3 files changed
+189
No files matched your search
@@ -33,6 +33,7 @@ from research.multi_schema import open_research_db
|
||||
from strategy.evaluation.data_quality import calculate_data_quality_score
|
||||
from strategy.evaluation.levels import calculate_vwap, compute_key_levels
|
||||
from strategy.evaluation.structure import opening_range
|
||||
from strategy.evaluation.runner_propensity import runner_propensity
|
||||
from strategy.evaluation.volume_metrics import calculate_time_of_day_rvol
|
||||
from strategy.evaluation.vwap_reclaim import detect_vwap_reclaim
|
||||
|
||||
@@ -478,6 +479,67 @@ def validate_vwap_reclaim(con, min_r_frac=0.015, min_r_abs=0.02):
|
||||
"The bar is locked; only OUT-OF-SAMPLE forward fires can clear it.")
|
||||
|
||||
|
||||
def runner_rank_report(con):
|
||||
"""Measure the cheap-price + premarket-gap SELECTION lever on the v1 ORB labeled set:
|
||||
does the runner_propensity tier separate runners (and +1R/+2R) from the base? Shadow
|
||||
measurement only -- NOT wired into live selection. Heavy small-n caveat (few gappers,
|
||||
autocorrelated days)."""
|
||||
rows = con.execute(
|
||||
"SELECT se.entry_reference_price, f.premarket_gap_pct, f.gap_pct, "
|
||||
"CASE WHEN l.reached_1r_before_minus_1r THEN 1 ELSE 0 END, "
|
||||
"CASE WHEN l.reached_2r_before_minus_1r THEN 1 ELSE 0 END, "
|
||||
"CASE WHEN l.trend_day_flag THEN 1 ELSE 0 END "
|
||||
"FROM setup_events se JOIN engineered_features f ON f.id = se.setup_id "
|
||||
"JOIN outcome_labels l ON l.setup_id = se.setup_id "
|
||||
"WHERE se.setup_version=? AND se.entry_reference_price > 0", [SETUP_VERSION]).fetchall()
|
||||
if not rows:
|
||||
print("No v1 labeled setups — run `build` first.")
|
||||
return
|
||||
scored = []
|
||||
for price, pmgap, gap, r1, r2, runner in rows:
|
||||
rp = runner_propensity(float(price),
|
||||
gap_pct=float(gap) if gap is not None else None,
|
||||
premarket_gap_pct=float(pmgap) if pmgap is not None else None)
|
||||
scored.append((rp, int(r1), int(r2), int(runner), float(rp.gap_used or 0.0)))
|
||||
gappers = [s for s in scored if s[0].gap_tier >= 1] # gap >= 10%
|
||||
ng = len(gappers)
|
||||
if not ng:
|
||||
print("No gappers (gap>=10%) in the v1 set."); return
|
||||
|
||||
def rate(rowset, idx):
|
||||
return sum(s[idx] for s in rowset) / len(rowset) if rowset else 0.0
|
||||
base_run, base_r1, base_r2 = rate(gappers, 3), rate(gappers, 1), rate(gappers, 2)
|
||||
print(f"=== runner-propensity SELECTION lever (v1 gappers, gap>=10%, n={ng}) ===")
|
||||
print(f" base among gappers: runner(+20%) {base_run*100:.0f}% | +1R {base_r1*100:.0f}% | +2R {base_r2*100:.0f}%")
|
||||
|
||||
def bucket(rowset, label):
|
||||
groups: dict = {}
|
||||
for s in rowset:
|
||||
groups.setdefault(label(s[0]), []).append(s)
|
||||
for key in sorted(groups):
|
||||
g = groups[key]
|
||||
lift = (rate(g, 3) / base_run) if base_run else 0
|
||||
print(f" {key:>16} runner {rate(g,3)*100:4.0f}% +1R {rate(g,1)*100:3.0f}% "
|
||||
f"+2R {rate(g,2)*100:3.0f}% n={len(g):3d} runner-lift {lift:.2f}x")
|
||||
|
||||
print(" by COMBINED tier (gap_tier + price_tier, higher = more runner-prone):")
|
||||
bucket(gappers, lambda rp: f"tier {rp.tier}")
|
||||
print(" by GAP tier alone (the dominant axis):")
|
||||
bucket(gappers, lambda rp: {1: "gap 10-25%", 2: "gap 25-50%", 3: "gap >=50%"}[rp.gap_tier])
|
||||
print(" by PRICE tier alone (secondary co-signal):")
|
||||
bucket(gappers, lambda rp: {0: "price >=$5", 1: "price $2-5", 2: "price <$2"}[rp.price_tier])
|
||||
|
||||
# operational kicker: the live TRIGGER_GAP_MAX (~35%) excludes the strongest gappers
|
||||
excl = [s for s in gappers if s[4] > 0.35]
|
||||
kept = [s for s in gappers if s[4] <= 0.35]
|
||||
if excl:
|
||||
print(f" GAP-CAP KICKER: the live ~35% gap cap would EXCLUDE {len(excl)} gappers whose runner "
|
||||
f"rate is {rate(excl,3)*100:.0f}% (vs {rate(kept,3)*100:.0f}% for the kept <=35%) -- "
|
||||
f"capping discards the best runners.")
|
||||
print(" CAVEAT: small n, 19 autocorrelated days; gap & price are correlated so the combined tier "
|
||||
"barely beats gap alone. A hypothesis to shadow-confirm forward, not a validated rank.")
|
||||
|
||||
|
||||
def report(con):
|
||||
n = con.execute("SELECT count(*) FROM setup_events WHERE setup_version=?", [SETUP_VERSION]).fetchone()[0]
|
||||
if not n:
|
||||
@@ -601,6 +663,7 @@ def main():
|
||||
bv.add_argument("--cooldown", type=int, default=10)
|
||||
sub.add_parser("report-vr", help="forward outcomes of the vwap_reclaim shadow track")
|
||||
sub.add_parser("validate-vr", help="score the pre-registered promotion candidate vs the locked bar")
|
||||
sub.add_parser("runner-rank", help="measure the cheap-price + premarket-gap selection lever")
|
||||
args = ap.parse_args()
|
||||
con = open_research_db("market")
|
||||
if args.cmd == "build":
|
||||
@@ -615,6 +678,8 @@ def main():
|
||||
report_vwap_reclaim(con)
|
||||
elif args.cmd == "validate-vr":
|
||||
validate_vwap_reclaim(con)
|
||||
elif args.cmd == "runner-rank":
|
||||
runner_rank_report(con)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Runner-propensity selection signal — rank gappers by how likely they are to RUN.
|
||||
|
||||
The bot ranks gappers on gap%×RVOL, but the labeler proved RVOL is FLAT (~1.0x, does
|
||||
not separate winners) while two FREE signals do separate runners from faders:
|
||||
- GAP MAGNITUDE (the dominant axis): premarket-gap high-tercile ~4.46x runner lift
|
||||
(labeler), and in the v1 gapper set gap>=50% runs 48% vs 21% for <50%.
|
||||
- CHEAP ABSOLUTE PRICE (a secondary, correlated co-signal): cheap<$5 runs 35% vs 17%
|
||||
for >=$5 (and the float audit's runner open median $1.19 vs fader $3.48). Big gappers
|
||||
are usually cheap microcaps, so price largely CO-OCCURS with gap and adds little on
|
||||
top of it -- it is a tie-breaker, not an independent edge.
|
||||
|
||||
This is a PURE, transparent, FROZEN-threshold primitive (no fitted weights, n is small
|
||||
and autocorrelated). It returns a coarse runner-propensity TIER for use as a SELECTION
|
||||
rank / size lever -- SHADOW/measurement first (see research.labeler runner-rank), never
|
||||
a hard gate. Gap is weighted heavier than price BY CONSTRUCTION (gap_tier 0-3 vs
|
||||
price_tier 0-2) because the data says gap dominates. premarket-gap is preferred over the
|
||||
RTH-open gap when available (it is the stronger, earlier signal).
|
||||
|
||||
NOTE vs the live caps: the bot's TRIGGER_GAP_MAX (~35%) EXCLUDES exactly the gap>=50%
|
||||
names that run most (it excluded NXTS at +81%). This primitive scores them HIGHEST --
|
||||
the lever is as much about not CAPPING the big gappers as about ranking them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerPropensity:
|
||||
tier: int # 0 (low) .. 5 (high) combined runner-propensity rank
|
||||
gap_tier: int # 0-3 from gap magnitude (the dominant axis)
|
||||
price_tier: int # 0-2 from cheapness (secondary co-signal)
|
||||
gap_used: float | None # the gap fraction actually used
|
||||
gap_source: str # "premarket" | "rth" | "none"
|
||||
reason: str
|
||||
|
||||
|
||||
def runner_propensity(
|
||||
price: float | None,
|
||||
gap_pct: float | None = None,
|
||||
*,
|
||||
premarket_gap_pct: float | None = None,
|
||||
cheap_below: float = 2.0,
|
||||
mid_below: float = 5.0,
|
||||
) -> RunnerPropensity:
|
||||
"""Coarse runner-propensity tier from cheap-price + gap magnitude. Pure.
|
||||
|
||||
tier = gap_tier (0-3) + price_tier (0-2); higher = more runner-prone. gap is the
|
||||
dominant axis (wider range); price is a secondary co-signal. premarket gap preferred."""
|
||||
# --- gap: prefer the premarket gap (stronger/earlier), fall back to the RTH gap ---
|
||||
if premarket_gap_pct is not None:
|
||||
gap, src = premarket_gap_pct, "premarket"
|
||||
elif gap_pct is not None:
|
||||
gap, src = gap_pct, "rth"
|
||||
else:
|
||||
gap, src = None, "none"
|
||||
|
||||
# gap tiers (frozen bands matching the labeler's separation: >=50% is the strong cut)
|
||||
if gap is None or gap < 0.10:
|
||||
gap_tier = 0 # not a gapper
|
||||
elif gap < 0.25:
|
||||
gap_tier = 1
|
||||
elif gap < 0.50:
|
||||
gap_tier = 2
|
||||
else:
|
||||
gap_tier = 3 # >=50% -- the strongest runner band
|
||||
|
||||
# price tiers (cheaper = higher; a secondary tie-breaker)
|
||||
if price is None or price <= 0 or price >= mid_below:
|
||||
price_tier = 0
|
||||
elif price < cheap_below:
|
||||
price_tier = 2 # < $2
|
||||
else:
|
||||
price_tier = 1 # $2 - $5
|
||||
|
||||
tier = gap_tier + price_tier
|
||||
reason = (f"gap_tier={gap_tier}({src}{'' if gap is None else f' {gap*100:.0f}%'})"
|
||||
f"+price_tier={price_tier}")
|
||||
return RunnerPropensity(tier=tier, gap_tier=gap_tier, price_tier=price_tier,
|
||||
gap_used=gap, gap_source=src, reason=reason)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Runner-propensity selection primitive: transparent, frozen, monotone tiers."""
|
||||
|
||||
from strategy.evaluation.runner_propensity import runner_propensity
|
||||
|
||||
|
||||
def test_big_gap_cheap_scores_highest():
|
||||
r = runner_propensity(1.50, premarket_gap_pct=0.80) # < $2 and +80% gap
|
||||
assert r.tier == 5 and r.gap_tier == 3 and r.price_tier == 2
|
||||
assert r.gap_source == "premarket"
|
||||
|
||||
|
||||
def test_non_gapper_scores_low():
|
||||
# a $40 name with a 2% gap is not a runner candidate
|
||||
r = runner_propensity(40.0, gap_pct=0.02)
|
||||
assert r.tier == 0 and r.gap_tier == 0 and r.price_tier == 0
|
||||
|
||||
|
||||
def test_gap_dominates_price():
|
||||
# a big-gap rich name outscores a tiny-gap cheap name -> gap is the heavier axis
|
||||
big_gap_rich = runner_propensity(12.0, premarket_gap_pct=0.60) # gap3 + price0 = 3
|
||||
small_gap_cheap = runner_propensity(1.0, premarket_gap_pct=0.12) # gap1 + price2 = 3
|
||||
assert big_gap_rich.gap_tier > small_gap_cheap.gap_tier
|
||||
# equal total here, but the gap axis spans more -> a >=50% gap alone (3) beats any
|
||||
# price-only contribution (max 2)
|
||||
assert runner_propensity(50.0, premarket_gap_pct=0.55).tier >= 3
|
||||
|
||||
|
||||
def test_monotonic_in_gap_and_cheapness():
|
||||
base = runner_propensity(3.0, premarket_gap_pct=0.20)
|
||||
assert runner_propensity(3.0, premarket_gap_pct=0.60).tier > base.tier # bigger gap
|
||||
assert runner_propensity(1.0, premarket_gap_pct=0.20).tier > base.tier # cheaper
|
||||
|
||||
|
||||
def test_premarket_gap_preferred_over_rth():
|
||||
# when both are present the premarket gap is the one used
|
||||
r = runner_propensity(3.0, gap_pct=0.05, premarket_gap_pct=0.60)
|
||||
assert r.gap_source == "premarket" and r.gap_used == 0.60 and r.gap_tier == 3
|
||||
|
||||
|
||||
def test_rth_gap_fallback_and_missing():
|
||||
assert runner_propensity(3.0, gap_pct=0.30).gap_source == "rth"
|
||||
assert runner_propensity(3.0).gap_source == "none" # no gap data -> tier from price only
|
||||
assert runner_propensity(3.0).gap_tier == 0
|
||||
Reference in new issue
Block a user