The session-level metalabel P(our ORB profits today) — the one lever that survives out-of-sample. Backtested on 266 prod sessions (walk-forward): trade-always (trained subset): -0.111R/session, 36% hit meta_v1 GATED (trades best ~32% of days): -0.004R/session, 48% hit delta +0.107R in-sample, +0.128R OOS (recent 80 sessions), improves across folds It does NOT make the bot profitable — it takes a bleeding strategy to roughly flat by sitting out the worst ~2/3 of sessions. threshold_v1 rule gates too little to matter; meta_v1 (walk-forward GBM) is the signal. Leaky full-fit (+0.61R) >> walk-forward confirms the discipline was load-bearing; the OOS holding is the evidence the +0.078R is real, not look-ahead. Changes: - research/regime.py: batch minute-bar load per session (64k per-fire round-trips -> ~266 queries; fixed a >5min build to ~3min) + incremental label pass (skip already-labeled sessions; --rebuild forces full). Nightly cost now ~one new day. - docker-compose.yml + truenas-compose.yml: scheduler runs build-features + score (both models) before `experiments run`, nightly. ALL-SHADOW — does NOT gate live trading (that stays owner-gated Step 5). - admin_api.py + admin.html: POST /api/regime/run (detached) + GET /api/regime/status + a Run/Status button — the panel had no way to trigger this. - plans/011 + docs/research/regime_notes.md (6-book distillation) + plans 008/009 docs. Prod state (shadow, rollback = DROP regime_features/regime_scores + DELETE experiments WHERE id LIKE 'regime%'): 266 feature rows, 532 scores, 2 experiments registered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 KiB
Plan 011: Regime gate — a FREE-data daily signal that decides whether the ORB trades today, proven in the harness before it ever gates a live order
Executor instructions: This plan is GATED and staged. Build the rungs in order; each rung has its own STOP. The whole thing is SHADOW/analysis — it adds a harness intervention and two offline jobs, and changes NO live trading path until a final owner-gated promotion (Step 5, which this plan does NOT perform). Run every verification. Update
plans/README.mdwhen done.Drift check:
git diff --stat 1da168d..HEAD -- research/ strategy/population.py experiments/— the experiment harness + the shared population gate are the substrate. Line numbers may have shifted; verify the quoted code exists semantically.
Status
- Priority: P1 (the one genuinely untried lever this milestone — it targets the bot's OWN edge, not the market, and it directly explains why the bot printed on 2026-07-08/09 and bleeds now)
- Effort: L
- Risk: LOW to live — all offline + one new harness intervention; no live gate/sizing/exit change until Step 5 (owner-gated, NOT in this plan)
- Depends on: 006 (experiment harness), 004 (
strategy/population.py), 009 (SPY/QQQ ingest +setup_eventsmarket-context columns already landed) - Category: selection / regime (session-level, not per-name)
- Planned at: commit
1da168d, 2026-07-15 - Companion research:
docs/research/regime_notes.md(6-book distillation — read it first; it is the why behind every discipline rule below)
Why this matters (and the one insight that makes it different from the 7 refuted levers)
Every prior lever tried to separate which name will run (float, liquidity, RVOL, VWAP-reclaim, leading-gainer, gap-band…). All refuted at year scale — memory calls the bottleneck "entry-conversion, not selection." This lever is orthogonal: it does not pick names, it decides whether the ORB should trade at all today.
The reframe (Chan's metalabeling, regime_notes.md): do not detect abstract "market regime." Predict P(our ORB makes money this session) from free pre-open features, using our OWN per-session realized expR as the label. That targets exactly what matters (does our edge work today), sidesteps the "high-vol lumps runner-days with crash-days" trap, reuses the harness, is free, and is a private signal nobody arbitrages away.
Concretely: the bot did well 07-08/09 (a runner regime) and is ~breakeven-to-bleeding otherwise. If a free pre-open signal can tell a runner-friendly session from a chop session even a little, gating the ORB to the friendly ones lifts whole-population expR. If it can't — that is a real, cheap finding, and it closes the lever.
The load-bearing discipline (or the whole thing is a lie — from regime_notes.md)
- Threshold baseline FIRST. A trivial no-fit rule (Step 1) must be beaten by any ML (Step 2) net, out-of-sample, or you ship the rule. A trivial rule cannot leak; it is the honest floor.
- Walk-forward / filtered only. The
favorableflag for session D must be computed from data strictly before D (an embargo of ≥1 session dropped at the boundary). A full-sample fit, or any percentile computed over the whole set including D, peeks at the future. Sanity check: if a full-sample-fit gate crushes the walk-forward gate, your edge is look-ahead — report it, don't ship it. - Label is same-day outcome; every feature is strictly pre-open (lagged to the prior close). The session's realized ORB expR is the label; a feature that reads any intraday/at-close value of session D is leakage.
- Acceptance = NET expR improves, not AUC. A 53%-accurate classifier can still be net-negative. The bar is
expR_delta > 0on the harness AND on a held-out recent split. - Let the DATA pick the sign. Do NOT assume high-vol = bad. A long-dispersion small-cap momentum book likely needs volatility. Measure conditional expR per regime; the gate keeps whichever regime the data says is +EV.
- Small sample humility. ~150–260 sessions, fewer runner-days. Keep features ≤8, trees shallow (
max_depth ≤ 3), and expect the gate to lag (it enters/exits the runner regime a day late). The bar is net improvement, not perfection.
Current state (what already exists — reuse it, don't rebuild)
- Harness keep-or-zero pattern:
research/experiments/replay.py_run_rank/ therank_one_bulletintervention already does exactly "keep this fire'sbaseline_r, or zero it" per a session-level decision. The regime gate is the same shape, keyed on a session flag instead of a per-fire rank. - Setup-sourced fires (
reconstruct_fires_from_setups) give ~18k backtestable ORB fires withentry_reference_price/invalidation_price; their baseline is the LIVE exit simulated on the bar path (_sim_baseline_r). This is both the fire source AND the label source — the per-session label is the mean of_sim_baseline_rover that session's setup fires. - Per-session pre-open context already stored in
setup_events(plan 009):session_date,gap_pct,relative_volume,above_vwap_flag,tape_regime_at_trigger,spy_intraday_return_pct_at_trigger. - SPY/QQQ bars ingested by
research/ingestion/market_context.py(minute bars 04:00–20:00 ET + recent daily). SPY daily → 5d/20d momentum + 20d realized vol. - Shared population gate:
strategy/population.pypopulation_reject— the regime gate composes ON TOP of it (population screens the name; regime screens the session). - Adjudication:
research/experiments/adjudicate.pycomputesexpR_delta = mean(variant_r - baseline_r)and gates onbars.expR_delta_min+bars.min_n. For a keep-or-zero gate,expR_delta = -mean(baseline_r over ZEROED fires) × frac_zeroed— positive iff the sessions the gate removes were net losers. That is precisely the right verdict. - Deps:
sklearn 1.9.0,numpy,pandaspresent.statsmodelsis ABSENT — so the HMM rung (Step 3) is deferred and needs an ownerpip install; Steps 1–2 need no new dependency.
Commands you will need
| Purpose | Command | Expected |
|---|---|---|
| Suite | DATABASE_URL=<url> python3 -m pytest -q |
green (the ~15 WSL-loopback dashboard failures are pre-existing; non-loopback = 0) |
| Build regime features | python3 -m research.regime build-features |
writes regime_features rows, prints count |
| Score walk-forward | python3 -m research.regime score --model threshold_v1 |
writes regime_scores, prints favorable/unfavorable session counts |
| Register/run/adjudicate | python3 -m research.experiments {register <json>|run|adjudicate} |
as documented |
| ruff | ruff check --select F research/ strategy/ |
clean |
Never run against production from a test. Use MOMENTUM_PG_SCHEMA or :memory: (see the pg-test-isolation-landmine memory) — a file/default db_path silently hits the live DB.
Scope
In scope:
research/regime.py— NEW. Two offline subcommands:build-features(per-session pre-open feature table) andscore(walk-forwardfavorableflag →regime_scores). Pure offline analysis; imports nothing from the live loop.storage/regime_schema.sql(or an idempotentCREATE TABLE IF NOT EXISTSinresearch/regime.py) —regime_features+regime_scorestables.research/experiments/replay.py— ONE new interventioniv_regime_gate+ wiring inrun()/_compute_fire(mirrorrank_one_bullet: keepbaseline_ron favorable sessions, 0 otherwise). Additive; existing interventions untouched.experiments/regime_threshold_v1.json,experiments/regime_meta_v1.json— the two go/no-go experiments.tests/unit/+tests/unit/experiments/— coverage per the test plan.plans/README.md.
Out of scope (HARD — do not touch):
- ANY live trading path:
trading_execution.py, the live gate, sizing, exits,run_live_paper.py. The regime gate does NOT gate a live order in this plan. Live promotion is Step 5, owner-gated, its own change. strategy/population.py— reuse it, do not edit it.- Adding
statsmodelsor any new dependency for Steps 1–2. The HMM rung (Step 3) is deferred and owner-gated on the install. - Smoothed/full-sample regime fits used as the tradeable flag — leakage; they may appear ONLY as the labelled "look-ahead sanity check" in Step 4, never as the shipped
favorableflag.
Steps
Step 0 — Data-availability probe (STOP gate)
Before building anything, confirm the data supports it:
- Count distinct
session_dateinsetup_eventswith ≥1 setup carryingentry_reference_priceandinvalidation_price(the labelable sessions). - Count SPY daily bars available (
daily_bars WHERE symbol='SPY') and their date span vs the setup sessions. - Count sessions where the SPY 20-day lookback is satisfiable (≥20 prior SPY daily bars).
STOP conditions:
- Fewer than ~150 labelable sessions with a satisfiable SPY lookback → STOP and report. The sample is too thin for a walk-forward fit that means anything; note the count and recommend accruing more sessions (the scheduler ingests SPY nightly) before revisiting. The threshold rung (Step 1) may still be reported as descriptive-only, but do NOT fit the ML.
- SPY daily coverage does not overlap the setup sessions (market_context ingest only has ~30 sessions of SPY, per its docstring) → the SPY-momentum features are unavailable for most of history. Report this; fall back the Step-1 rule to features that DO cover the range (
tape_regime_at_trigger, session-levelgap_pct/relative_volumebreadth aggregated fromsetup_events), and note SPY-based features await backfill. Do NOT fabricate SPY history.
Verify: the probe prints the three counts; you have written down which feature set is actually populated over the setup date range.
Step 1 — Threshold baseline (no ML, no possible leak) — the shippable floor
-
research/regime.py build-features— for each labelablesession_date, compute a per-session feature row from pre-open / prior-close-lagged inputs only. Start with what is populated over the full range (per Step 0), a subset of:breadth_gap= fraction of that session'ssetup_eventsnames withgap_pct > 0(session-level breadth; knowable pre-open from the gapper scan).breadth_rvol= medianrelative_volumeacross the session's names.tape_regime= the session'stape_regime_at_trigger(already an ingested regime tag) — encode to an ordinal.spy_mom5,spy_mom20,spy_vol20= SPY 5d/20d return + 20d realized vol fromdaily_barsas of the prior close (only where Step 0 says SPY coverage exists).bot_trailing_expR_10d= the mean session-label expR over the prior 10 labelable sessions (a hot/cold-streak detector on our own edge — strictly backward-looking; the single most on-target feature perregime_notes.md).
Store in
regime_features(session_date PRIMARY KEY, <feature cols>, label_expR, label_pos)wherelabel_expR= mean_sim_baseline_rover the session's setup fires andlabel_pos=label_expR > 0.label_*are outcome columns — they are NEVER read as features; they exist soscorecan fit/evaluate without recomputing the harness. -
research/regime.py score --model threshold_v1— the trivial rule, computed walk-forward: for each session D,favorable(D)= the rule evaluated on D's features using ONLY thresholds derivable from sessions< D(with a 1-session embargo). Rule v1 (data picks the winning side — measure both directions, keep the +EV one):- Primary:
bot_trailing_expR_10d > 0(trade when our own recent edge is positive) ANDtape_regimein the runner-favorable set. - If SPY features exist: optionally
spy_vol20on the side the data prefers (do NOT hardcode low-vol=good; pick the side whose prior-sessions conditional label_expR is higher). - Write
regime_scores(model_version, session_date, p_profit, favorable)— for a rule,p_profit ∈ {0,1}=favorable.
- Primary:
-
Add
iv_regime_gatetoreplay.py— mirror_run_rank. Load{session_date: favorable}for the experiment'sintervention.model_versiononce per run; for each fire:variant_r = baseline_r if favorable.get(session_date) else 0.0;detail = {"kind":"regime_gate","favorable":bool,"model_version":...}. Fire sessions with no score row → keep (fail-open, like the population gate), and count them in the detail so a stale scores table is visible. -
Register + run + adjudicate
experiments/regime_threshold_v1.json: population = the live v1 population ({"min_entry_gap_pct":0.10,"min_day_volume":300000,"price_min":2.0,"price_max":20.0}), intervention ={"kind":"regime_gate","model_version":"threshold_v1"}, bars ={"expR_delta_min":0.05,"min_n":300}, fidelity ={"atr_frac":0.25,"spread_mult":3,"adverse_ticks":1}.
GO/NO-GO (Step 1):
regime_threshold_v1expR_delta > 0withn ≥ 300→ the trivial rule removes net-losing sessions. A real, shippable floor. Proceed to Step 2 to see if ML beats it.expR_delta ≤ 0→ the free features carry no session signal even for a trivial rule. This is a strong NO-GO signal. Proceed to Step 2 ONCE (a classifier can find a rule the hand-rule missed), but with a low prior. If Step 2 also fails, STOP and report "no exploitable regime signal in free data at this sample size" — a complete, valuable outcome.
Verify: python3 -m research.experiments run && python3 -m research.experiments adjudicate prints regime_threshold_v1: <verdict> (n=…); you record its expR_delta and the favorable/unfavorable session split.
Step 2 — Metalabel classifier (sklearn, walk-forward) — the primary
research/regime.py score --model meta_v1— asklearn.ensemble.GradientBoostingClassifier(max_depth=3)(orRandomForestClassifier, ≤8 features, shallow). Walk-forward: for each session D, fit on sessions< D(embargo 1) withX= the feature columns,y=label_pos; predictp_profit(D).favorable(D) = p_profit(D) ≥ p_min(default 0.5, but pickp_minfrom a walk-forward-internal split, never from the test tail). Writeregime_scores(model_version='meta_v1', …).- Require a minimum train size (e.g. ≥60 sessions) before scoring; below it,
favorable = True(fail-open, don't gate on an unfit model).
- Require a minimum train size (e.g. ≥60 sessions) before scoring; below it,
- Register + run + adjudicate
experiments/regime_meta_v1.json— same population/bars/fidelity as threshold,intervention.model_version = "meta_v1".
GO/NO-GO (Step 2):
regime_meta_v1expR_delta > 0,n ≥ 300, AND itsexpR_deltaexceedsregime_threshold_v1's by a real margin → the classifier adds signal over the trivial rule. Proceed to Step 4 (the OOS + leakage audit that decides promotion-readiness).regime_meta_v1expR_delta ≤ threshold's→ the ML adds nothing; the rule is the product. If the threshold passed, carrythreshold_v1into Step 4; if neither passed, STOP with the honest NO-GO.
Verify: adjudicate prints both experiments' verdicts side by side; you record expR_delta(meta) − expR_delta(threshold).
Step 3 — HMM regime as a FEATURE (DEFERRED, owner-gated on pip install statsmodels)
Only if Step 2 shows signal AND you want a volatility-state feature the tree lacks. Add a 2-state statsmodels MarkovRegression(spy_returns, k_regimes=2, switching_variance=True) filtered (not smoothed) state probability as one extra column into build-features, re-fit meta_v1, re-adjudicate. Requires the owner to install statsmodels first — do NOT add the dep yourself. If it doesn't lift expR_delta, drop it. This rung is optional by design; the plan is complete without it.
Step 4 — Out-of-sample split + leakage audit (the promotion-readiness gate)
The harness expR_delta is walk-forward already, but confirm it survives a held-out recent split and is not a look-ahead artifact:
- OOS split: recompute
expR_deltaon the most-recent ~30% of sessions only (fit/score used only their own priors — already true by walk-forward). The favorable-regime expR advantage must survive on this recent slice. "In-sample regimes need not persist out-of-sample" — if it evaporates here, the regime was in-sample noise. - Leakage sanity check: build a deliberately leaky variant —
favorablecomputed from a FULL-SAMPLE fit (or an in-D percentile) — and compare itsexpR_deltato the walk-forward one. If the leaky version is dramatically better, that gap is exactly the look-ahead you correctly excluded; note it. If walk-forward ≈ leaky, the signal is robust. - Every-fold check:
expR_deltashould be positive across each of ~3 sequential time folds, not driven by one lucky month (e.g. 07-08/09 alone).
GO/NO-GO (Step 4):
- Survives OOS + every fold, and the leaky-vs-walkforward gap is not the whole edge → promotion-ready. Present the owner the Step-5 decision (a live change), do NOT flip anything.
- Fails OOS or is one-month-driven → do NOT promote. Report it as "regime signal is in-sample only at this sample size." Keep the offline scorer running as a shadow so the verdict can be re-checked as sessions accrue.
Step 5 — Live promotion (OWNER-GATED — this plan does NOT perform it)
If Step 4 is promotion-ready, the owner decides whether the live loop consults regime_scores for today to gate/size fresh entries. That is a live-path change (a new session-level gate in trading_execution, computed from strictly-pre-open features on the live clock, fail-open when the model is unfit/stale) and gets its own owner-gated plan — never an env flip inside this one. Document the interface (regime_scores keyed by today's date, written by a pre-open scorer job) and stop.
Test plan
test_regime_gate_keeps_and_zeros—iv_regime_gatereturnsbaseline_rfor a favorable session and0.0for an unfavorable one; a session with no score row is kept (fail-open) and flagged indetail.test_regime_score_is_walk_forward— for a synthetic feature/label series,favorable(D)uses NO information from session≥ D: shuffling the labels of sessions≥ Dmust not changefavorable(D). This is the leakage guard — it must be a real assert, not a smoke test.test_regime_features_no_label_leak— the feature matrixXpassed to the classifier excludeslabel_expR/label_pos(assert the column set).test_threshold_experiment_registers_and_runs—regime_threshold_v1registers, runs over synthetic setup fires with a scored session map, adjudicates to a verdict.test_regime_gate_expR_delta_sign— construct fires where the zeroed sessions are net-negative →expR_delta > 0; where zeroed sessions are net-positive →expR_delta < 0(proves adjudication rewards removing losers, not just removing trades).- Verification:
DATABASE_URL=<url> python3 -m pytest -qgreen; non-loopback failures = 0;ruff check --select F research/ strategy/clean.
Done criteria
- Step 0 probe run; the labelable-session count + SPY coverage recorded in
plans/README.md; STOP honored if under threshold regime_features+regime_scorestables created idempotently;build-features+scorerunnableiv_regime_gateadded;test_regime_gate_keeps_and_zeros+test_regime_gate_expR_delta_signgreentest_regime_score_is_walk_forwardgreen (the leakage guard)regime_threshold_v1registered + adjudicated;expR_delta+ session split recorded inplans/README.mdregime_meta_v1registered + adjudicated;expR_delta(meta) − expR_delta(threshold)recorded- Step 4 OOS + every-fold + leakage-gap results recorded; a clear GO (promotion-ready, owner-gated) or NO-GO ("in-sample only / no free-data regime signal at this sample size") written down
- ZERO live-path change:
git diff 1da168d..HEAD -- trading_execution.py run_live_paper.py strategy/exits.pyis empty - Suite green; ruff clean
STOP conditions
- Step 0: under ~150 labelable sessions, or SPY coverage absent over the range → STOP/report; do not fit ML on a thin sample.
- Step 1 AND Step 2 both
expR_delta ≤ 0→ STOP: "no exploitable regime signal in free data at this sample size." A complete outcome — it closes the regime lever with data, same value as a yes. - Any step tempts a live gate/sizing/exit change or an env flip → HARD STOP (Step 5 is owner-gated, separate plan).
- The walk-forward test (
test_regime_score_is_walk_forward) cannot be made to pass → your scorer leaks; do NOT trust anyexpR_deltauntil it does. A leaking scorer makes every downstream verdict a lie (regime_notes.md, discipline rule 2). - Adding
statsmodelsis required to proceed → that is Step 3, deferred; do NOT install a dependency. Ship Steps 1–2 without it.
Maintenance notes
- The walk-forward flag is the load-bearing assumption. If
scoreever computes a threshold/percentile/fit over the whole set (including the session being scored), the gate silently becomes look-ahead and every backtest lies. The leakage test is the tripwire — keep it. - Let the data pick the sign, always. Do not hardcode "low vol = good." The bot is long-dispersion small-cap momentum; it may need volatility.
scorechooses the favorable side from prior-session conditional expR, not from a prior belief. - The gate lags by construction. A daily regime flag enters the runner regime a session or two late and exits late. It will sit out some good days and take some bad ones. The bar is net whole-population expR improvement across folds, never a perfect call.
- This is the most promising lever of the milestone AND the most likely to curve-fit. ~150–260 sessions with fewer runner-days is a small sample. The threshold-first rule, walk-forward, OOS split, and every-fold check are the four guards. If, after all four, the gated expR does not clearly beat trade-always out-of-sample → the honest finding is "no exploitable regime signal in free data at this sample size," and we learned it cheaply, without a single live dollar at risk.