Files
momentum devandClaude Opus 4.8 092d6072e8 Regime gate (plan 011): deploy-wire the shadow pipeline + on-demand admin trigger + prod backtest
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>
2026-07-15 14:05:34 -04:00

22 KiB
Raw Permalink Blame History

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.md when 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_events market-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)

  1. 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.
  2. Walk-forward / filtered only. The favorable flag 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.
  3. 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.
  4. Acceptance = NET expR improves, not AUC. A 53%-accurate classifier can still be net-negative. The bar is expR_delta > 0 on the harness AND on a held-out recent split.
  5. 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.
  6. 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 / the rank_one_bullet intervention already does exactly "keep this fire's baseline_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 with entry_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_r over 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.py population_reject — the regime gate composes ON TOP of it (population screens the name; regime screens the session).
  • Adjudication: research/experiments/adjudicate.py computes expR_delta = mean(variant_r - baseline_r) and gates on bars.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, pandas present. statsmodels is ABSENT — so the HMM rung (Step 3) is deferred and needs an owner pip 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) and score (walk-forward favorable flag → regime_scores). Pure offline analysis; imports nothing from the live loop.
  • storage/regime_schema.sql (or an idempotent CREATE TABLE IF NOT EXISTS in research/regime.py) — regime_features + regime_scores tables.
  • research/experiments/replay.py — ONE new intervention iv_regime_gate + wiring in run()/_compute_fire (mirror rank_one_bullet: keep baseline_r on 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 statsmodels or 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 favorable flag.

Steps

Step 0 — Data-availability probe (STOP gate)

Before building anything, confirm the data supports it:

  1. Count distinct session_date in setup_events with ≥1 setup carrying entry_reference_price and invalidation_price (the labelable sessions).
  2. Count SPY daily bars available (daily_bars WHERE symbol='SPY') and their date span vs the setup sessions.
  3. 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-level gap_pct/relative_volume breadth aggregated from setup_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

  1. research/regime.py build-features — for each labelable session_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's setup_events names with gap_pct > 0 (session-level breadth; knowable pre-open from the gapper scan).
    • breadth_rvol = median relative_volume across the session's names.
    • tape_regime = the session's tape_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 from daily_bars as 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 per regime_notes.md).

    Store in regime_features(session_date PRIMARY KEY, <feature cols>, label_expR, label_pos) where label_expR = mean _sim_baseline_r over the session's setup fires and label_pos = label_expR > 0. label_* are outcome columns — they are NEVER read as features; they exist so score can fit/evaluate without recomputing the harness.

  2. 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) AND tape_regime in the runner-favorable set.
    • If SPY features exist: optionally spy_vol20 on 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.
  3. Add iv_regime_gate to replay.py — mirror _run_rank. Load {session_date: favorable} for the experiment's intervention.model_version once 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.

  4. 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_v1 expR_delta > 0 with n ≥ 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

  1. research/regime.py score --model meta_v1 — a sklearn.ensemble.GradientBoostingClassifier(max_depth=3) (or RandomForestClassifier, ≤8 features, shallow). Walk-forward: for each session D, fit on sessions < D (embargo 1) with X = the feature columns, y = label_pos; predict p_profit(D). favorable(D) = p_profit(D) ≥ p_min (default 0.5, but pick p_min from a walk-forward-internal split, never from the test tail). Write regime_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).
  2. 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_v1 expR_delta > 0, n ≥ 300, AND its expR_delta exceeds regime_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_v1 expR_delta ≤ threshold's → the ML adds nothing; the rule is the product. If the threshold passed, carry threshold_v1 into 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:

  1. OOS split: recompute expR_delta on 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.
  2. Leakage sanity check: build a deliberately leaky variant — favorable computed from a FULL-SAMPLE fit (or an in-D percentile) — and compare its expR_delta to 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.
  3. Every-fold check: expR_delta should 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_gate returns baseline_r for a favorable session and 0.0 for an unfavorable one; a session with no score row is kept (fail-open) and flagged in detail.
  • 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 ≥ D must not change favorable(D). This is the leakage guard — it must be a real assert, not a smoke test.
  • test_regime_features_no_label_leak — the feature matrix X passed to the classifier excludes label_expR/label_pos (assert the column set).
  • test_threshold_experiment_registers_and_runs — regime_threshold_v1 registers, 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 -q green; 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_scores tables created idempotently; build-features + score runnable
  • iv_regime_gate added; test_regime_gate_keeps_and_zeros + test_regime_gate_expR_delta_sign green
  • test_regime_score_is_walk_forward green (the leakage guard)
  • regime_threshold_v1 registered + adjudicated; expR_delta + session split recorded in plans/README.md
  • regime_meta_v1 registered + 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.py is 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 any expR_delta until it does. A leaking scorer makes every downstream verdict a lie (regime_notes.md, discipline rule 2).
  • Adding statsmodels is 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 score ever 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. score chooses 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.