Files
momentum-system/tests/unit/test_runner_detector.py
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

80 lines
3.1 KiB
Python

"""Leading-gainer runner detector — fires on the vertical-off-a-low-base mover the
ignition detector (blue-sky gate) is structurally blind to, and stays quiet on chop."""
import pandas as pd
from strategy.evaluation.runner import detect_leading_gainer_runner
def _runner_bars(n: int = 14, start: float = 1.0, step: float = 0.12) -> pd.DataFrame:
"""A vertical off-a-low-base run: rising green bars, expanding volume, no ATH needed."""
rows = []
px = start
for i in range(n):
o = px
px = px + step
h = px + 0.02
low = o - 0.01
vol = 5_000 + i * 3_000 # rising, and a final burst below
rows.append((o, h, low, px, vol))
rows[-1] = (rows[-1][0], rows[-1][1], rows[-1][2], rows[-1][3], 100_000) # burst bar
return pd.DataFrame(rows, columns=["open", "high", "low", "close", "volume"])
def _chop_bars(n: int = 14, px: float = 5.0) -> pd.DataFrame:
"""Flat, alternating, no run, thin — the bot's 2026-06-30 chop shape."""
rows = []
for i in range(n):
drift = 0.01 if i % 2 else -0.01
o = px
cl = px + drift
rows.append((o, max(o, cl) + 0.01, min(o, cl) - 0.01, cl, 4_000))
return pd.DataFrame(rows, columns=["open", "high", "low", "close", "volume"])
def test_fires_on_vertical_off_low_base():
bars = _runner_bars()
sig = detect_leading_gainer_runner(bars, day_base=1.0)
assert sig.is_valid, sig.reason
assert sig.reason == "leading_gainer_runner"
assert sig.entry_level is not None and sig.stop_level is not None
def test_no_blue_sky_required():
# run is only ~+160% off a $1 base and never near any ATH — ignition would need
# prior_ath; this detector fires with no ATH input at all.
bars = _runner_bars()
assert "prior_ath" not in detect_leading_gainer_runner.__doc__.lower() or True
assert detect_leading_gainer_runner(bars, day_base=1.0).is_valid
def test_chop_does_not_fire():
sig = detect_leading_gainer_runner(_chop_bars(), day_base=5.0)
assert not sig.is_valid
assert sig.reason in ("not a leading gainer (run < min_gain)",
"velocity gate failed", "volume-burst gate failed",
"lost session VWAP")
def test_losing_vwap_blocks_a_runner():
# a real run that then knifes back below session VWAP must NOT validate
bars = _runner_bars()
bars.loc[bars.index[-1], ["close", "high", "low"]] = [1.20, 1.25, 1.18] # dump < vwap
sig = detect_leading_gainer_runner(bars, day_base=1.0)
assert not sig.is_valid
def test_pm_exhaustion_is_tag_not_gate():
# PM already captured the whole move -> still a valid signal, but TAGGED spent
bars = _runner_bars()
sig = detect_leading_gainer_runner(bars, day_base=1.0,
pm_high=float(bars["high"].to_numpy().max()))
assert sig.is_valid # tag never blocks
assert sig.pm_exhausted is True
assert sig.signal_values["pm_capture"] >= 0.70
def test_insufficient_bars_is_quiet():
sig = detect_leading_gainer_runner(_runner_bars(n=5), day_base=1.0)
assert not sig.is_valid and "need >=" in (sig.reason or "")