Files
momentum-system/tests/unit/test_runner_quality.py
T
momentum devandClaude Opus 4.8 a33996f91c Runner-aware GRADE + leading-gainer runner DETECTOR, shadow-only
The default ORB grade (quality.py) pins catalyst RUNNERS at F/C: its two heaviest
components structurally break on a vertical spike -- STRUCTURE (30%) reads 0 (no
pullback to classify) and adv-RVOL (25%) reads 1.0x (NULL rolling-20d on fresh
reverse-split names). On the 2026-06-30 tape the source trader's winners (SVRE
+247%, JEM +348%, CELZ +472%) all graded C 0.565, indistinguishable from the bot's
F-grade chop. Two parallel, shadow-only artifacts fix this without touching the live
ORB path:

- strategy/evaluation/runner_quality.py::calculate_runner_quality -- a PARALLEL
  grade that drops the two broken components and scores the traits that actually
  separate runners from chop (gap saturating at +100% not +10%, price velocity,
  own-name volume burst, above-VWAP, catalyst). Validated on the real tape:
  CELZ/JEM/SVRE -> B/A, chop (SLS/RIVN/CCXI/BB/NVDX) -> F. quality.py untouched.

- strategy/evaluation/runner.py::detect_leading_gainer_runner -- catches the mover
  detect_momentum_ignition is blind to (it requires blue-sky > prior ATH, so it
  fires ZERO on beaten-down catalyst pops). Blue-sky deleted; gates = leading-gainer
  run + verticality + own-name burst + holding session-cumulative VWAP; PM-inclusive;
  tags (never gates) PM-exhaustion. Real-tape in-sample: 5/5 runners fire, 7/7 chop
  silent, zero false-fires. Cumulative VWAP is a prefix function, so a precomputed
  session_vwap makes a bar-by-bar slide O(n) not O(n^2) (identity verified exact).

- research/labeler.py -- build-runner/report-runner/validate-runner shadow track,
  mirroring vwap_reclaim: emit a non-tradeable setup on each fresh fire, score with
  the SAME _forward_labels. Pre-registered promotion bar LOCKED before forward data
  (median fwd-max >= +15%, median adverse > -8%, +1R > 50%, beat vwap_reclaim,
  PM-exhaustion tag must earn its keep). SHADOW-ONLY: never routes to approve_order,
  never gates live until the bar clears out-of-sample. The honest open question the
  track exists to answer: the detector confirms AT/AFTER the top for PM-driven names
  (SVRE fires at +242% already run) -- does that late entry actually convert to +1R?

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:10:19 -04:00

43 lines
2.2 KiB
Python

"""Runner-aware grade: vertical runners land A/B, chop stays F — the exact re-ranking
the default ORB grade can't do (it pins both at C/F)."""
from strategy.evaluation.runner_quality import calculate_runner_quality
def test_vertical_runner_grades_A_with_catalyst():
# SVRE-class: +247% gap, fast, bursting, above VWAP, real catalyst
r = calculate_runner_quality(gap_pct=2.47, velocity=0.40, vol_burst=1.8,
above_vwap=True, catalyst_score=0.7)
assert r.grade == "A" and r.score >= 0.80
def test_runner_grades_at_least_B_without_catalyst():
# gap + velocity + VWAP do the separating structurally (catalyst OFF / Ollama down)
r = calculate_runner_quality(gap_pct=2.47, velocity=0.40, vol_burst=1.8,
above_vwap=True, catalyst_score=0.0)
assert r.grade in ("A", "B") and r.score >= 0.65
def test_chop_stays_F():
# the bot's 2026-06-30 chop: tiny gap, no velocity, below VWAP
for gap, vel, burst, av in [(0.02, 0.02, 1.0, False), # SLS-ish
(0.03, 0.02, 1.1, False), # RIVN-ish
(0.00, 0.03, 1.2, False)]: # CCXI-ish
r = calculate_runner_quality(gap_pct=gap, velocity=vel, vol_burst=burst, above_vwap=av)
assert r.grade == "F", (gap, vel, r.score)
def test_losing_vwap_drops_the_grade():
# a runner that knifes back below session VWAP loses the 15% vwap term -> downgrades
up = calculate_runner_quality(gap_pct=1.0, velocity=0.20, vol_burst=2.0, above_vwap=True)
down = calculate_runner_quality(gap_pct=1.0, velocity=0.20, vol_burst=2.0, above_vwap=False)
assert down.score < up.score and down.score == up.score - 0.15
def test_gap_saturates_at_100pct_not_10pct():
# the default grade saturated gap at +10%, so +472% read like +11%; here +50% and
# +472% differ, and +100%+ is maxed (a real runner isn't flattened to the chop scale)
small = calculate_runner_quality(gap_pct=0.50, velocity=0.0, vol_burst=1.0, above_vwap=False)
big = calculate_runner_quality(gap_pct=4.72, velocity=0.0, vol_burst=1.0, above_vwap=False)
assert big.components["gap"] == 1.0 and small.components["gap"] == 0.5