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>
132 lines
5.2 KiB
Python
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"]))
|