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:
1 parent
c8e542fe6c
commit
43a3ace91d
5 files changed
+296
-27
No files matched your search
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user