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>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-16 14:00:07 -05:00
1 parent c8e542fe6c
commit 43a3ace91d
5 files changed
+296 -27

No files matched your search

+2 -2
View File
@@ -25,8 +25,8 @@ your row when done.
| 007 | Operator control plane — dashboard truth + kill switch + effective config (step 1 = prod bug fix, ship early) | P2 | M | 001, 003 (006 for ledger data) | DONE |
| 008 | Conversion-signal model — measure if the labeled data separates winners | P1 | L | 006, 004 | **MEASURED 2026-07-14 (corrected): the 0.514 was the liq1-dominated MIX. Scoped to live v1: CV AUC 0.641 — REAL signal, but it is ~entirely the VWAP gate the bot ALREADY runs (above-VWAP +1R 35.4% vs below 12.7%). Residual within above-VWAP = 0.526 ≈ noise. RVOL-fallback added (read-only test 2026-07-14): 0.526→0.535, within noise — RVOL adds nothing beyond VWAP. Verdict: VWAP is the whole story for current features; no second indicator. Only market-context + catalyst remain untested (forward-accrual only, negative prior). The lever is a different setup (premarket), not more ORB filters.** |
| 010 | Premarket shadow entry — prove a FILL-ABLE edge before any live premarket order | P2 | L | 006, 009 | **NO-GO (Step 1 measured 2026-07-15): premarket is a paper-fill mirage. pm1+fat-tail-exit expR: −0.50R @0.5% fill, −1.27R @1% (realistic thin book), −2.74R @2%, vs the ORB's −0.11R. The 22-min fat tail can't outrun the 1-2% premarket spread. STOP — do not build the live shadow track. The ORB's 5-min wait exists to trade in penny-spread regular-session liquidity.** |
| 009 | Feature-population prerequisite — instrument v1/pm1 (market context, RVOL fallback, catalyst-on), then re-run 008's probe | P2 | L | 006, 008 | code DONE, awaiting supervised rebuild+probe (market context + thin-name RVOL fallback + SPY/QQQ ingest + enrichment-on + committed probe all landed & tested; Steps 4-5 rebuild/AUC run supervised) |
| 011 | Regime gate — a FREE-data daily signal deciding whether the ORB trades today; metalabel `P(our ORB profits this session)`, proven in the harness (walk-forward) before any live gate | P1 | L | 006, 004, 009 | **BACKTESTED ON PROD (2026-07-15) — VIABLE as a SHADOW damage-control signal, NOT yet a live gate. 266 walk-forward sessions: trade-always −0.111R/session (36% hit) → meta_v1 GATED −0.004R/session (48% hit), trading only the best ~32% of days; delta +0.107R in-sample, +0.128R OOS (recent 80 sessions), improves across 3 folds. threshold_v1 rule gates too little to matter. Leaky full-fit +0.61R ≫ walk-forward +0.078R confirms the walk-forward discipline was load-bearing; OOS holding = the edge is real. Verdict: turns a bleeding strategy roughly FLAT by sitting out bad regimes. FIRE-WEIGHTED harness (the LIVE v1 population — gap≥10%, vol≥300k — 2041 fires): BOTH experiments PASS their pre-registered bars (first PASS of the milestone) — regime_meta_v1 expR_delta +0.0965R, baseline −0.085R/fire → GATED +0.0117R/fire (POSITIVE on the real population, keeps ~33%); regime_threshold_v1 +0.083R but keeps only 26 fires (its pass = 'sit out 99%', thin/ignore). So on the actual tradeable population the gate is mildly POSITIVE, not just flat. DEPLOYED (shadow): scheduler runs build-features+score nightly; admin `/api/regime/run` button triggers on demand. LIVE-GATED IN PAPER (Step 5 shipped 2026-07-15, commit `0ac09d3`): `_regime_reject` blocks fresh entries on unfavorable sessions, `TRADING_REGIME_GATE_ENABLED=1` in both compose, fail-open everywhere. Blocker-1 (tape_regime not pre-open) resolved by DELETING tape_regime — measured 0.000 importance; LIVE feature set = breadth_rvol(vol_upto/avg, non-look-ahead) + spy_lagged + trailing. Takes effect on next deploy. Real-money promotion still a separate owner decision. Prior status below. CODE DONE + REVIEW-APPROVED (commit `5cef4aa`), deploy-wired + backtested at `092d607`. Built: `research/regime.py` (probe + walk-forward `build-features`/`score`), `iv_regime_gate` harness intervention (keep-or-zero, fail-open), `regime_threshold_v1`+`regime_meta_v1` JSONs, 5 tests incl. the mutate-the-future leakage guard. Reviewer re-ran: 9 new + 35 existing experiments tests green, ruff clean, live paths untouched. HMM rung skipped (statsmodels absent). Research: `docs/research/regime_notes.md`. SUPERVISED next step (hits production Postgres — NOT done by the executor): `python3 -m research.regime probe` (Step-0 STOP gate) → `build-features` → `score --model threshold_v1`/`meta_v1` → register+run+adjudicate the two experiments → record expR_delta + OOS/fold results here. Review findings to fold in at that point: (1) `tape_regime` = earliest setup's at-trigger (~9:35) reading, not strictly pre-open — fine for the shadow harness, must be lagged before Step-5 live promotion; (2) minor: `_load_regime_favorable` model_version has no None-guard (both JSONs set it, fail-open if absent). Honest prior unchanged: most promising AND most curve-fit-prone (~150-260 sessions); walk-forward + threshold-floor + OOS decide GO vs "no free-data regime signal at this sample size".** |
| 009 | Feature-population prerequisite — instrument v1/pm1 (market context, RVOL fallback, catalyst-on), then re-run 008's probe | P2 | L | 006, 008 | code DONE, awaiting supervised rebuild+probe (market context + thin-name RVOL fallback + SPY/QQQ ingest + enrichment-on + committed probe all landed & tested; Steps 4-5 rebuild/AUC run supervised). **2026-07-16: the 07-15 nightly job never fired (docker outage overlapped 20:00 ET) — 07-15 was a data hole (0 setup_events, no SPY ingest, no regime row). BACKFILLED supervised: SIP refetch + 50 v1 setups + 6 pm1 fires + SPY/QQQ ingest + regime row for 07-15. Accrual clock unbroken; verify tonight's 20:00 scheduler fires.** |
| 011 | Regime gate — a FREE-data daily signal deciding whether the ORB trades today; metalabel `P(our ORB profits this session)`, proven in the harness (walk-forward) before any live gate | P1 | L | 006, 004, 009 | **BACKTESTED ON PROD (2026-07-15) — VIABLE as a SHADOW damage-control signal, NOT yet a live gate. 266 walk-forward sessions: trade-always −0.111R/session (36% hit) → meta_v1 GATED −0.004R/session (48% hit), trading only the best ~32% of days; delta +0.107R in-sample, +0.128R OOS (recent 80 sessions), improves across 3 folds. threshold_v1 rule gates too little to matter. Leaky full-fit +0.61R ≫ walk-forward +0.078R confirms the walk-forward discipline was load-bearing; OOS holding = the edge is real. Verdict: turns a bleeding strategy roughly FLAT by sitting out bad regimes. FIRE-WEIGHTED harness (the LIVE v1 population — gap≥10%, vol≥300k — 2041 fires): BOTH experiments PASS their pre-registered bars (first PASS of the milestone) — regime_meta_v1 expR_delta +0.0965R, baseline −0.085R/fire → GATED +0.0117R/fire (POSITIVE on the real population, keeps ~33%); regime_threshold_v1 +0.083R but keeps only 26 fires (its pass = 'sit out 99%', thin/ignore). So on the actual tradeable population the gate is mildly POSITIVE, not just flat. DEPLOYED (shadow): scheduler runs build-features+score nightly; admin `/api/regime/run` button triggers on demand. LIVE-GATED IN PAPER (Step 5 shipped 2026-07-15, commit `0ac09d3`): `_regime_reject` blocks fresh entries on unfavorable sessions, `TRADING_REGIME_GATE_ENABLED=1` in both compose, fail-open everywhere. Blocker-1 (tape_regime not pre-open) resolved by DELETING tape_regime — measured 0.000 importance; LIVE feature set = breadth_rvol(vol_upto/avg, non-look-ahead) + spy_lagged + trailing. Takes effect on next deploy. Real-money promotion still a separate owner decision. **2026-07-16 AUDIT + FIX: the shipped live gate was structurally INERT — `live_session_favorable` read today's breadth_rvol from today's OWN setup_events rows, which the labeler only writes at 20:00 ET, so the fail-open guard returned None every live session (verified: no meta_live score + 1288 all-pass gate receipts on 07-16; the 07-15 'validation block' p=0.080 was computed PRE-guard on a zero-filled feature — an artifact, not a validation). Also found breadth_rvol was UNIT-CONTAMINATED: `_session_context` pooled all setup versions, and pm1 stores RAW cumulative premarket volume in relative_volume (median 261k) vs ratio tracks (~0.8) — ~240/267 historical sessions were pm1-raw-volume, and plan 009's v1 RVOL fallback was about to flip forward sessions to ratio units (07-15 rebuilt = 0.18), silently breaking the feature. FIX (same-day): breadth_rvol redefined pm1-ONLY (median premarket volume — the dominant historical source, unit-consistent, strictly pre-open knowable); live gate recomputes today's pm1 fires IN MEMORY from premarket minute_bars (pure compute_premarket_setups, no insert); _regime_reject retries fail-open verdicts every ~5 min instead of caching None all day (definite verdicts still once/day). regime_features rebuilt (267 sessions, labels unchanged), scores rewritten, fire-weighted re-validation vs the PRE-REGISTERED bars: regime_meta_v1 expR_delta +0.0570 (bar +0.05, n=2058, PASS — weaker than the contaminated +0.0965 but real), keeps 37%, baseline −0.078→gated −0.021; recent-80-session delta +0.149R/session (always −0.141 → gated +0.008, trading 29% of days). threshold_v1 +0.0766 but keeps 1% (sit-out-everything, ignore as before). Live check 07-16 on fixed code: breadth 612,819, verdict UNFAVORABLE — first genuine live decision. Adversarial review (19-agent, 9 findings) hardened the live path: previous_close falls back to the latest prior session's close when today's daily_bars row is absent intraday (else the gap basis silently flips to the premarket open = different fire population than training); 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); NULL-breadth training rows excluded from the live fit (zero-fill taught a 'zero premarket volume' class the serve path can never produce); throttle datetime arithmetic moved inside the fail-open try. KNOWN ACCEPTED SKEW: live premarket volumes are scaled-IEX estimates vs the nightly SIP tape (up to ~25-50x low on symbols without a volume_ratio row) — regime_scores['meta_live'] vs the EOD label is the standing forward measure of whether this skew flips verdicts; revisit (intraday SIP refetch at 9:46+, 16-min embargo) if agreement is poor. Takes effect on next image deploy.** Prior status below. CODE DONE + REVIEW-APPROVED (commit `5cef4aa`), deploy-wired + backtested at `092d607`. Built: `research/regime.py` (probe + walk-forward `build-features`/`score`), `iv_regime_gate`Line truncated
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
+84 -13
View File
@@ -33,7 +33,7 @@ import argparse
import os
from bisect import bisect_left
from collections import defaultdict
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from statistics import mean, median, pstdev
@@ -203,14 +203,21 @@ def _session_context(con, sessions: list[date]) -> dict[date, dict]:
session's OWN labelable setup_events rows (gap_pct/relative_volume/
tape_regime_at_trigger — the same open-knowable columns
strategy/population.py and the labeler already treat as pre-entry
context). tape_regime is the EARLIEST (by setup_time) setup's reading."""
context). tape_regime is the EARLIEST (by setup_time) setup's reading.
breadth_rvol is pm1-ONLY: pm1 stores RAW cumulative premarket volume in
relative_volume (labeler: "Stores gap + premarket volume as
stratification") while the ratio tracks store vol/avg_vol — pooling
versions mixed units session-to-session. pm1 is the dominant historical
source (~240 of ~267 sessions) and the one quantity the LIVE gate can
recompute pre-open (_live_breadth_rvol), so train and serve match."""
rows = con.execute(
"SELECT session_date, gap_pct, relative_volume, tape_regime_at_trigger, setup_time "
"FROM setup_events WHERE entry_reference_price IS NOT NULL "
"SELECT session_date, gap_pct, relative_volume, tape_regime_at_trigger, "
"setup_version FROM setup_events WHERE entry_reference_price IS NOT NULL "
"AND invalidation_price IS NOT NULL ORDER BY session_date, setup_time").fetchall()
by_session: dict[date, list] = defaultdict(list)
for sd, gap, rvol, tape, _st in rows:
by_session[_as_date(sd)].append((gap, rvol, tape))
for sd, gap, rvol, tape, ver in rows:
by_session[_as_date(sd)].append((gap, rvol, tape, ver))
out = {}
for d in sessions:
@@ -218,8 +225,8 @@ def _session_context(con, sessions: list[date]) -> dict[date, dict]:
if not items:
out[d] = {"breadth_gap": None, "breadth_rvol": None, "tape_regime": None}
continue
gaps = [g for g, _r, _t in items if g is not None]
rvols = [r for _g, r, _t in items if r is not None]
gaps = [g for g, _r, _t, _v in items if g is not None]
rvols = [r for _g, r, _t, v in items if r is not None and v == "pm1"]
out[d] = {
"breadth_gap": (sum(1 for g in gaps if g > 0) / len(gaps)) if gaps else None,
"breadth_rvol": median(rvols) if rvols else None,
@@ -439,14 +446,67 @@ def _feature_rows(con) -> list[dict]:
# (tape_regime, breadth_gap — measured 0.000 importance on the prod fit, so
# dropping them changes nothing) . tape_regime was also the only NOT-strictly-
# open feature, so its removal makes the live gate non-look-ahead. Every
# remaining feature is knowable by the entry decision: breadth_rvol =
# median(relative_volume) over the session's setups SO FAR (relative_volume =
# vol_upto/avg_vol_20d, the SAME up-to-trigger calc the live bot runs — NOT
# day-end); spy_* lagged to the prior close; trailing from PRIOR sessions.
# remaining feature is knowable BEFORE the first entry decision: breadth_rvol =
# median premarket volume over the session's pm1 fires (premarket completes at
# 9:30; _live_breadth_rvol recomputes the same fires in memory intraday);
# spy_* lagged to the prior close; trailing from PRIOR sessions.
LIVE_FEATURE_COLS = ("breadth_rvol", "spy_mom5", "spy_mom20", "spy_vol20",
"bot_trailing_expr_10d")
def _live_breadth_rvol(con, session_date) -> float | None:
"""Intraday breadth_rvol for `session_date`: median premarket volume over
the session's pm1 fires, recomputed IN MEMORY from today's premarket
minute_bars (the loop ingests them live; premarket is complete by 9:30, so
this is strictly pre-open knowable). setup_events rows for today only
appear after the nightly labeler run — the nightly build persists the SAME
fires this recomputes (compute_premarket_setups is pure/deterministic).
None when no premarket bars or no pm1 fire yet."""
import pandas as pd
from research import labeler
rows = con.execute(
f"SELECT symbol, {', '.join(labeler._MB_COLS)} FROM minute_bars "
"WHERE session_date=? AND is_premarket ORDER BY symbol, timestamp",
[session_date]).fetchall()
if not rows:
return None
df = pd.DataFrame(rows, columns=["symbol", *labeler._MB_COLS])
# previous_close: intraday there is usually NO daily_bars row for today yet
# (boot-time backfill runs premarket, before Alpaca has today's bar), and
# without it detect_premarket_setup measures the gap off the premarket OPEN
# — a different fire population than the nightly build. Fall back to the
# latest PRIOR session's close (== today's previous close).
daily: dict[str, float | None] = {}
prior_close: dict[str, tuple] = {}
for sym, td, pc, close in con.execute(
"SELECT symbol, trade_date, previous_close, close FROM daily_bars "
"WHERE trade_date <= ? AND trade_date >= ?",
[session_date, session_date - timedelta(days=7)]).fetchall():
td = _as_date(td)
if td == session_date:
if pc is not None:
daily[sym] = float(pc)
elif sym not in prior_close or td > prior_close[sym][0]:
prior_close[sym] = (td, close)
for sym, (_td, close) in prior_close.items():
if sym not in daily and close is not None:
daily[sym] = float(close)
vals = []
for sym, gdf in df.groupby("symbol"):
pc = daily.get(sym)
try:
setups = labeler.compute_premarket_setups(
sym, session_date, gdf.reset_index(drop=True),
float(pc) if pc is not None else None, None, {})
except Exception: # noqa: BLE001 — one bad symbol can't kill the gate read
continue
vals += [s["relative_volume"] for s, _l in setups
if s.get("relative_volume") is not None]
return median(vals) if vals else None
def live_session_favorable(con, session_date, p_min: float = P_MIN_DEFAULT,
min_train: int = MIN_TRAIN_SESSIONS, write: bool = True):
"""LIVE gate decision for `session_date`: fit meta on ALL prior labeled
@@ -461,7 +521,11 @@ def live_session_favorable(con, session_date, p_min: float = P_MIN_DEFAULT,
flag can be compared against the EOD label to measure whether it matters."""
apply_schema(con)
rows = _feature_rows(con)
prior = [r for r in rows if r["session_date"] < session_date and r.get("label_pos") is not None]
# breadth_rvol IS NULL rows (sessions with no pm1 fire) are EXCLUDED from
# the live fit: _build_X would 0.0-fill them — a "zero premarket volume"
# class the serve path can never produce (live None abstains instead).
prior = [r for r in rows if r["session_date"] < session_date
and r.get("label_pos") is not None and r.get("breadth_rvol") is not None]
if len(prior) < min_train:
return None
y = [bool(r["label_pos"]) for r in prior]
@@ -472,6 +536,13 @@ def live_session_favorable(con, session_date, p_min: float = P_MIN_DEFAULT,
prior_labels = [r["label_expr"] for r in prior[-TRAILING_WINDOW:] if r["label_expr"] is not None]
row = {**ctx, **spy,
"bot_trailing_expr_10d": mean(prior_labels) if prior_labels else None}
# setup_events rows for TODAY are written by the NIGHTLY labeler, so during
# the live session _session_context always comes back empty — recompute
# today's pm1 fires in memory from the premarket minute_bars instead
# (_live_breadth_rvol; same pure compute_premarket_setups the nightly
# build persists).
if row.get("breadth_rvol") is None:
row["breadth_rvol"] = _live_breadth_rvol(con, session_date)
# FAIL-OPEN when the load-bearing feature is absent: breadth_rvol is the #1
# feature, and it is None before any setup has fired. Predicting on a 0.0-fill
# (a value the model never saw in training) biases toward a spurious BLOCK, so
+123
View File
@@ -166,3 +166,126 @@ def test_build_features_and_score_threshold_end_to_end(store):
"SELECT session_date, favorable FROM regime_scores WHERE model_version='threshold_v1' "
"ORDER BY session_date").fetchall()
assert [r[0] for r in scores] == [sess1, sess2]
# --------------------------------------------------------------------------- #
# breadth_rvol units (fix 2026-07-16): pm1 stores RAW premarket volume in
# relative_volume (cum_pm_volume, documented), the ratio tracks store
# vol/avg_vol. Pooling versions mixed units across sessions, so breadth_rvol
# is defined as pm1-only (median premarket volume) — the dominant historical
# source AND the one quantity computable live BEFORE the open.
# --------------------------------------------------------------------------- #
def test_session_context_breadth_rvol_is_pm1_only(store):
d = date(2026, 6, 10)
_insert_setup(store, "AAA", d, 0) # v1, ratio units
store.con.execute("UPDATE setup_events SET relative_volume=5.0 WHERE setup_version='v1'")
store.con.execute(
"INSERT INTO setup_events (setup_id, symbol, session_date, setup_time, "
"setup_name, setup_version, entry_reference_price, invalidation_price, relative_volume) "
"VALUES (?,?,?,?,?,?,?,?,?)",
[f"pm-AAA-{d}", "AAA", d, datetime(d.year, d.month, d.day, 8, 30),
"premarket_setup", "pm1", 10.0, 9.0, 100000.0])
ctx = regime._session_context(store.con, [d])
assert ctx[d]["breadth_rvol"] == 100000.0 # pm1 volume, never the v1 ratio
# --------------------------------------------------------------------------- #
# LIVE gate — setup_events rows for TODAY are only written by the nightly
# labeler, so during the session breadth_rvol must be computed IN MEMORY from
# today's premarket bars (complete by 9:30 — strictly pre-open knowable).
# --------------------------------------------------------------------------- #
def _insert_regime_feature(store, d, rvol, label_pos):
store.con.execute(
"INSERT INTO regime_features (session_date, breadth_rvol, label_expr, label_pos) "
"VALUES (?,?,?,?)",
[d, rvol, 0.1 if label_pos else -0.1, label_pos])
def _seed_prior_sessions(store, n=70):
d0 = date(2026, 3, 2)
days = [d0 + timedelta(days=i) for i in range(n)]
for i, dd in enumerate(days):
_insert_regime_feature(store, dd, rvol=20_000.0 + 10_000.0 * (i % 2),
label_pos=bool(i % 2))
return days
def _seed_premarket_run(store, symbol, d, n=30, start=3.0, step=0.06, vol=20_000):
"""Premarket bars that make detect_premarket_setup fire (mirrors
tests/unit/test_premarket_setup.py::_pm_run: ~+50% gap off prev_close 2.5,
real participation), plus the daily_bars row carrying previous_close."""
px = start
for i in range(n):
ts = datetime(d.year, d.month, d.day, 8, 0) + timedelta(minutes=i)
o = px
px = px + step
store.con.execute(
"INSERT INTO minute_bars (symbol, timestamp, session_date, is_premarket, "
"is_regular_hours, open, high, low, close, volume, vwap, quality_score) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[symbol, ts, d, True, False, o, px + 0.01, o - 0.01, px, vol, px, 1.0])
store.con.execute(
"INSERT INTO daily_bars (symbol, trade_date, previous_close, rolling_avg_volume_20d) "
"VALUES (?,?,?,?)", [symbol, d, 2.5, 50_000])
def test_live_gate_scores_from_intraday_premarket_bars(store):
days = _seed_prior_sessions(store)
today = days[-1] + timedelta(days=1)
# no setup_events for `today` — only raw premarket bars, as during a live session
_seed_premarket_run(store, "AAA", today)
assert regime._live_breadth_rvol(store.con, today) is not None
fav = regime.live_session_favorable(store.con, today, write=False)
assert fav is not None # scored a real verdict, not fail-open
def test_live_gate_still_fails_open_before_any_fire(store):
days = _seed_prior_sessions(store)
today = days[-1] + timedelta(days=1)
assert regime.live_session_favorable(store.con, today, write=False) is None
def test_live_breadth_uses_latest_prior_close_when_today_daily_row_missing(store):
"""Intraday there is usually NO daily_bars row for today yet (the loop
backfills daily bars at boot, premarket) — the gap basis must fall back to
the latest PRIOR session's close, not silently flip to the premarket open
(which fires a different population than the nightly training build)."""
d = date(2026, 6, 10)
# gapped +50% vs yesterday's close but moving only ~+4% within premarket
# (flat, then an 8-bar pop that clears the 3% velocity bar): with the
# fallback prev close the detector's gap gate passes; measured off the
# premarket open alone the gap never reaches min_gap=10% and nothing fires.
px = 3.75
for i in range(30):
ts = datetime(d.year, d.month, d.day, 8, 0) + timedelta(minutes=i)
o = px
px = px + (0.019 if 12 <= i < 20 else 0.0)
store.con.execute(
"INSERT INTO minute_bars (symbol, timestamp, session_date, is_premarket, "
"is_regular_hours, open, high, low, close, volume, vwap, quality_score) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
["BBB", ts, d, True, False, o, px + 0.001, o - 0.001, px, 20_000, px, 1.0])
# only YESTERDAY's daily row exists; its close is today's previous close
store.con.execute(
"INSERT INTO daily_bars (symbol, trade_date, close) VALUES (?,?,?)",
["BBB", d - timedelta(days=1), 2.5])
assert regime._live_breadth_rvol(store.con, d) is not None
def test_live_fit_excludes_null_breadth_training_rows(store):
"""Sessions with no pm1 fires carry breadth_rvol=NULL; zero-filling them in
the LIVE fit teaches the model a 'zero premarket volume' class the serve
path can never produce (live None abstains). They must be excluded: here
every positive-label session has NULL breadth, so excluding them leaves a
single-class prior -> fail-open None (inclusion would return a verdict)."""
d0 = date(2026, 3, 2)
for i in range(65):
_insert_regime_feature(store, d0 + timedelta(days=i), rvol=20_000.0 + i,
label_pos=False)
for i in range(30):
store.con.execute(
"INSERT INTO regime_features (session_date, breadth_rvol, label_expr, label_pos) "
"VALUES (?,?,?,?)", [d0 + timedelta(days=100 + i), None, 0.1, True])
today = d0 + timedelta(days=200)
_seed_premarket_run(store, "AAA", today)
assert regime.live_session_favorable(store.con, today, write=False) is None
+63 -1
View File
@@ -5,7 +5,7 @@ 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
from datetime import datetime, timedelta
import research.regime as regime_mod
from trading_execution import ExecutionSettings, TradingExecutionService
@@ -64,6 +64,68 @@ def test_scored_once_per_session(monkeypatch):
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"]))
+24 -11
View File
@@ -913,19 +913,31 @@ class TradingExecutionService:
def _regime_reject(self, now_et=None) -> str | None:
"""Session-level regime gate (plan 011): ``"regime_unfavorable"`` when
today's walk-forward metalabel scores the session unfavorable, else None.
Evaluated ONCE per session and cached (``self._regime_today``). FAIL-OPEN
by construction: disabled, an unfit model (early history / <60 prior
sessions), missing data, or ANY exception returns None — the gate can
only ever SUBTRACT trades on bad-regime days, never block on an error."""
The scorer's input (today's premarket fires) is complete once the
premarket is fully ingested, so a verdict computed at/after 9:50 ET —
even a fail-open None — is FINAL for the session (the expensive scorer
never re-runs on the shared entry hot path). A verdict computed EARLIER
(first ORB entries ~9:35, live bars may still be catching up) is
PROVISIONAL — even a definite BLOCK — and re-scored every ~5 min until
a final one lands. FAIL-OPEN by construction: disabled, an unfit model
(early history / <60 prior sessions), missing data, or ANY exception
returns None — the gate can only ever SUBTRACT trades on bad-regime
days, never block on an error."""
if not self.settings.regime_gate_enabled:
return None
try:
from datetime import datetime
from datetime import datetime, time as dtime
from zoneinfo import ZoneInfo
today = (now_et or datetime.now(ZoneInfo("America/New_York"))).date()
except Exception: # noqa: BLE001 — a broken clock must not block trading
now = now_et or datetime.now(ZoneInfo("America/New_York"))
today = now.date()
cache = self._regime_today
rescore = (cache.get("date") != today
or (not cache.get("final")
and (cache.get("at") is None
or (now - cache["at"]).total_seconds() >= 300)))
except Exception: # noqa: BLE001 — a broken clock/cache must not block trading
return None
if self._regime_today.get("date") != today:
if rescore:
fav = None
try:
from research.regime import live_session_favorable
@@ -934,7 +946,8 @@ class TradingExecutionService:
except Exception as exc: # noqa: BLE001 — a scorer failure NEVER blocks trading
logger.warning("regime gate scoring failed (fail-open): %s", exc)
fav = None
self._regime_today = {"date": today, "favorable": fav}
self._regime_today = {"date": today, "favorable": fav, "at": now,
"final": now.time() >= dtime(9, 50)}
return "regime_unfavorable" if self._regime_today.get("favorable") is False else None
def _evaluate_entry_gates(self, ctx: EntryContext) -> GateDecision:
@@ -967,8 +980,8 @@ class TradingExecutionService:
# REGIME — session-level metalabel (plan 011): sit out days the walk-forward
# model scores unfavorable. Quiet skip like SESSION (a normal daily policy
# call, not a per-name risk anomaly); the gate receipt records it. Computed
# once per day and cached; fail-open inside _regime_reject.
# call, not a per-name risk anomaly); the gate receipt records it. Definite
# verdicts cached per day, fail-open retried ~5 min inside _regime_reject.
if self._regime_reject():
gates["regime"] = {"value": self._regime_today.get("favorable"), "outcome": "reject"}
return _done(False, "regime_unfavorable", throttle=False)