Files
momentum-system/tests/unit/test_regime_gate_live.py
T
momentum devandClaude Fable 5 43a3ace91d Regime gate: make the live gate actually decide (it shipped inert) + fix breadth_rvol units
The 07-15 live gate never made a real decision: live_session_favorable read
today's breadth_rvol from today's OWN setup_events rows, which only the
NIGHTLY labeler writes — always None intraday, fail-open every session (the
07-15 "validation block" was computed pre-guard on a zero-filled feature).
Worse, breadth_rvol was unit-corrupted: _session_context pooled all setup
versions and pm1 stores RAW cumulative premarket volume in relative_volume
(median ~261k) vs the ratio tracks (~0.8) — ~240/267 historical sessions'
breadth was actually median premarket volume, and plan 009's v1 RVOL fallback
was about to flip forward sessions to ratio units.

Fixes (TDD, 21 regime tests):
- breadth_rvol is now pm1-ONLY: median premarket volume — the dominant
  historical source, unit-consistent, and strictly pre-open knowable.
- _live_breadth_rvol recomputes today's pm1 fires IN MEMORY from premarket
  minute_bars (pure compute_premarket_setups, no insert); previous_close
  falls back to the latest prior daily close when today's daily_bars row is
  absent intraday (else the gap basis silently flips to the premarket open).
- _regime_reject: verdicts before 9:50 ET are PROVISIONAL (re-scored ~5 min;
  ingest lag can truncate the premarket frame) and the first verdict at/after
  9:50 is FINAL even when None — caps the scorer at a handful of runs/day
  instead of ~78 on quiet days (hot-path stall guard). Throttle arithmetic
  moved inside the fail-open try (naive/aware mixing must not escape).
- Live fit excludes breadth_rvol-NULL training rows (zero-fill taught a
  "zero premarket volume" class the serve path can never produce).

Prod re-validation on the cleaned feature (regime_features rebuilt, scores
rewritten): regime_meta_v1 fire-weighted expR_delta +0.0570 (pre-registered
bar +0.05, n=2058, PASS — the contaminated +0.0965 was inflated), keeps 37%;
recent-80-session delta +0.149R/session (always −0.141 → gated +0.008).
First genuine live verdict 2026-07-16: UNFAVORABLE (pm breadth 612,819).
Known accepted skew: live scaled-IEX volumes vs nightly SIP tape — the
meta_live-vs-EOD comparison is the standing forward measure.

Also backfilled the missed 07-15 nightly job supervised (SIP refetch, 50 v1
+ 6 pm1 setups, SPY/QQQ ingest, regime row) — plan 009's accrual clock is
unbroken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:00:07 -05:00

132 lines
5.2 KiB
Python

"""Live regime gate (plan 011): TradingExecutionService._regime_reject.
The gate can only ever SUBTRACT trades on bad-regime days; it must FAIL-OPEN on
disable / unfit model / error, and evaluate the (expensive) scorer at most once
per session. Same __new__-and-set-settings idiom as test_population_gate.py.
"""
from datetime import datetime, timedelta
import research.regime as regime_mod
from trading_execution import ExecutionSettings, TradingExecutionService
NOON = datetime(2026, 7, 15, 12, 0)
def _svc(monkeypatch, favorable, enabled=True, raises=False):
svc = TradingExecutionService.__new__(TradingExecutionService)
svc.settings = ExecutionSettings(regime_gate_enabled=enabled, regime_gate_p_min=0.5)
svc._regime_today = {"date": None, "favorable": None}
svc.store = type("_Store", (), {"con": None})() # .con passed through to the patched scorer
calls = {"n": 0}
def fake(con, session_date, p_min=0.5):
calls["n"] += 1
if raises:
raise RuntimeError("boom")
return favorable
monkeypatch.setattr(regime_mod, "live_session_favorable", fake)
return svc, calls
def test_disabled_never_blocks_and_never_scores(monkeypatch):
svc, calls = _svc(monkeypatch, favorable=False, enabled=False)
assert svc._regime_reject(now_et=NOON) is None
assert calls["n"] == 0 # disabled => scorer never even runs
def test_favorable_passes(monkeypatch):
svc, _ = _svc(monkeypatch, favorable=True)
assert svc._regime_reject(now_et=NOON) is None
def test_unfavorable_blocks(monkeypatch):
svc, _ = _svc(monkeypatch, favorable=False)
assert svc._regime_reject(now_et=NOON) == "regime_unfavorable"
def test_unfit_model_fails_open(monkeypatch):
# scorer returns None (fewer than MIN_TRAIN_SESSIONS priors) => never block
svc, _ = _svc(monkeypatch, favorable=None)
assert svc._regime_reject(now_et=NOON) is None
def test_scorer_error_fails_open(monkeypatch):
svc, _ = _svc(monkeypatch, favorable=False, raises=True)
assert svc._regime_reject(now_et=NOON) is None # exception must NOT block trading
def test_scored_once_per_session(monkeypatch):
svc, calls = _svc(monkeypatch, favorable=False)
for _ in range(5):
svc._regime_reject(now_et=NOON)
assert calls["n"] == 1 # cached: the GBM fit runs once per day, not per entry
def _svc_seq(monkeypatch, seq):
svc = TradingExecutionService.__new__(TradingExecutionService)
svc.settings = ExecutionSettings(regime_gate_enabled=True, regime_gate_p_min=0.5)
svc._regime_today = {"date": None, "favorable": None}
svc.store = type("_Store", (), {"con": None})()
calls = {"n": 0}
def fake(con, session_date, p_min=0.5):
calls["n"] += 1
return seq.pop(0)
monkeypatch.setattr(regime_mod, "live_session_favorable", fake)
return svc, calls
EARLY = datetime(2026, 7, 15, 9, 35) # before the 9:50 ET finality cutoff
LATE = datetime(2026, 7, 15, 9, 52) # after it
def test_fail_open_retried_before_final_cutoff(monkeypatch):
"""A pre-9:50 None (fail-open — live premarket bars may still be catching
up) must NOT stick for the whole session: a later evaluation past the
throttle window re-scores and can start gating."""
svc, calls = _svc_seq(monkeypatch, [None, False])
assert svc._regime_reject(now_et=EARLY) is None # scored: fail-open
assert svc._regime_reject(now_et=EARLY + timedelta(seconds=60)) is None # throttled
assert calls["n"] == 1
assert svc._regime_reject(now_et=EARLY + timedelta(minutes=6)) == "regime_unfavorable"
assert calls["n"] == 2
def test_early_definite_verdict_is_provisional(monkeypatch):
"""A definite verdict scored BEFORE 9:50 ET may come from a truncated
premarket frame (ingest lag) — it must be re-scored after the cutoff, not
cached all day as a permanent wrong BLOCK."""
svc, calls = _svc_seq(monkeypatch, [False, True])
assert svc._regime_reject(now_et=EARLY) == "regime_unfavorable" # provisional block
assert svc._regime_reject(now_et=LATE) is None # re-scored on complete data
assert calls["n"] == 2
def test_none_after_final_cutoff_not_retried(monkeypatch):
"""After 9:50 ET the premarket is complete and ingested — a None verdict is
FINAL (a no-pm1-fire day stays fail-open) so the expensive scorer never
re-runs on the shared entry hot path for the rest of the session."""
svc, calls = _svc_seq(monkeypatch, [None, None])
assert svc._regime_reject(now_et=NOON) is None
assert svc._regime_reject(now_et=NOON + timedelta(minutes=30)) is None
assert calls["n"] == 1 # final: never rescored
def test_mixed_naive_aware_clock_fails_open(monkeypatch):
"""The retry-throttle datetime arithmetic must honor the fail-open
contract: naive/aware mixing raises INSIDE _regime_reject, never out."""
from zoneinfo import ZoneInfo
svc, calls = _svc_seq(monkeypatch, [None, None])
aware = datetime(2026, 7, 15, 9, 35, tzinfo=ZoneInfo("America/New_York"))
assert svc._regime_reject(now_et=aware) is None
# naive now minus aware cache["at"] raises TypeError internally -> fail-open
assert svc._regime_reject(now_et=EARLY + timedelta(minutes=6)) is None
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-q"]))