diff --git a/.gitignore b/.gitignore
index 08d5425..787e2fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,6 @@ momentum-rebuilt.zip
archive/
uv.lock
.firecrawl/
+
+# large research PDFs (local only)
+books/
diff --git a/admin_api.py b/admin_api.py
index 87a07c7..d2edec5 100644
--- a/admin_api.py
+++ b/admin_api.py
@@ -21,6 +21,8 @@ import http.client
import json
import os
import socket
+import subprocess
+import sys
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -33,6 +35,45 @@ _load_overrides()
from runtime import env_overrides # noqa: E402
DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
+# Regime backtest (plan 011) on-demand trigger — logs to the app-data volume so
+# GET /api/regime/status can tail it (the panel had no way to fire this).
+REGIME_LOG = os.environ.get(
+ "REGIME_RUN_LOG", os.path.join(os.path.dirname(__file__), "data", "regime_run.log"))
+_REGIME_DONE = "REGIME PIPELINE DONE"
+
+
+def run_regime_pipeline() -> int:
+ """Fire the full regime shadow pipeline DETACHED (it takes a few min): rebuild
+ session labels -> walk-forward score both models -> replay + adjudicate the
+ regime_gate experiments. Returns the child pid. Shadow only — never touches a
+ live order. stdout+stderr -> REGIME_LOG (truncated each run so status shows
+ the CURRENT run); a trailing marker lets status report completion."""
+ py = sys.executable
+ steps = " && ".join([
+ f"{py} -m research.regime build-features",
+ f"{py} -m research.regime score --model threshold_v1",
+ f"{py} -m research.regime score --model meta_v1",
+ f"{py} -m research.experiments run",
+ f"{py} -m research.experiments adjudicate",
+ f"echo {_REGIME_DONE}",
+ ])
+ os.makedirs(os.path.dirname(REGIME_LOG), exist_ok=True)
+ log = open(REGIME_LOG, "wb") # noqa: SIM115 — handed to the detached child
+ proc = subprocess.Popen(["/bin/sh", "-c", steps], stdout=log,
+ stderr=subprocess.STDOUT, start_new_session=True)
+ return proc.pid
+
+
+def regime_status() -> dict:
+ """Tail REGIME_LOG + whether the last run finished (the marker) — no DB query
+ needed; the adjudicate step prints each experiment's verdict into the log."""
+ try:
+ with open(REGIME_LOG, encoding="utf-8", errors="replace") as fh:
+ lines = fh.read().splitlines()
+ except FileNotFoundError:
+ return {"log_tail": [], "running": False, "ran": False}
+ return {"log_tail": lines[-40:], "ran": True,
+ "running": _REGIME_DONE not in lines[-3:] if lines else False}
PROFILES_DIR = os.environ.get(
"PROFILES_DIR", os.path.join(os.path.dirname(__file__), "data", "profiles"))
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
@@ -282,6 +323,8 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, profiles())
if path == "/api/config/effective":
return self._send(200, {"config": effective_config()})
+ if path == "/api/regime/status":
+ return self._send(200, regime_status())
return self._send(404, {"error": "unknown route"})
def do_POST(self): # noqa: N802
@@ -345,6 +388,15 @@ class Handler(BaseHTTPRequestHandler):
return self._send(400, {"error": "mode must be paper|live"})
return self._send(200, {"ok": True, "mode": mode, "overrides": merged,
"note": "restart the loop to apply"})
+
+ if path == "/api/regime/run":
+ try:
+ pid = run_regime_pipeline()
+ except Exception as e: # noqa: BLE001
+ return self._send(500, {"ok": False, "error": str(e)[:200]})
+ return self._send(200, {"ok": True, "pid": pid, "log": REGIME_LOG,
+ "note": "regime shadow pipeline started (~a few min); "
+ "poll GET /api/regime/status"})
return self._send(404, {"error": "unknown route"})
def log_message(self, *a): # quiet
diff --git a/api/static/admin.html b/api/static/admin.html
index 14528ec..054f54c 100644
--- a/api/static/admin.html
+++ b/api/static/admin.html
@@ -75,6 +75,15 @@ color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;tr
+
Research jobs
+
+
Regime backtest — plan 011, SHADOW
+
Rebuild session labels → walk-forward score both models → replay + adjudicate the regime_gate experiments. Runs a few min; does NOT touch live trading.
+
+
+
+
+
Secrets present
@@ -87,6 +96,15 @@ function saveToken(){localStorage.setItem('admintok',$('#token').value);load()}
async function api(p,opt={}){opt.headers={...(opt.headers||{}),'X-Admin-Token':tok(),'Content-Type':'application/json'};
const r=await fetch(p,opt);if(r.status===401){toast('bad token');throw 0}return r.json()}
function toast(m){const t=$('#toast');t.textContent=m;t.style.opacity=1;setTimeout(()=>t.style.opacity=0,2600)}
+async function runRegime(){toast('starting regime pipeline…');
+ try{const r=await api('/api/regime/run',{method:'POST',body:'{}'});
+ toast(r.ok?('started (pid '+r.pid+') — poll Status'):('failed: '+(r.error||'')));
+ setTimeout(regimeStatus,2000)}catch(e){toast('failed')}}
+async function regimeStatus(){const r=await api('/api/regime/status');
+ $('#regimeout').style.display='block';
+ $('#regimelog').textContent=(r.log_tail&&r.log_tail.length?r.log_tail.join('\n'):'(no run yet — click Run)')
+ +(r.running?'\n\n… running …':(r.ran?'\n\n[finished]':''));
+ if(r.running)setTimeout(regimeStatus,4000)}
function dot(ok){return ``}
async function load(){
const s=await api('/api/status');
diff --git a/deploy/docker/truenas-compose.yml b/deploy/docker/truenas-compose.yml
index c3a7b86..5bfe607 100644
--- a/deploy/docker/truenas-compose.yml
+++ b/deploy/docker/truenas-compose.yml
@@ -205,6 +205,14 @@ services:
# NOT look-ahead (replay is a labeling job, not a live gate).
python eod_replay.py || echo "[scheduler] eod_replay FAILED"
python nightly_tune.py || echo "[scheduler] nightly_tune FAILED"
+ # Regime gate (plan 011, ALL-SHADOW): a session-level metalabel
+ # P(our ORB profits today). build-features labels each session from its
+ # OWN realized expR; score writes the WALK-FORWARD favorable flag the
+ # experiments' regime_gate intervention reads. Must run BEFORE
+ # experiments run. Does NOT gate live trading (that is owner-gated).
+ python -m research.regime build-features || echo "[scheduler] regime build-features FAILED"
+ python -m research.regime score --model threshold_v1 || echo "[scheduler] regime score threshold FAILED"
+ python -m research.regime score --model meta_v1 || echo "[scheduler] regime score meta FAILED"
# Experiment OS (plan 006, ALL-SHADOW): replay running experiments over
# newly-logged fires, then adjudicate against pre-registered bars.
python -m research.experiments run || echo "[scheduler] experiments run FAILED"
diff --git a/docker-compose.yml b/docker-compose.yml
index c112416..b753851 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -164,6 +164,14 @@ services:
# NOT look-ahead (replay is a labeling job, not a live gate).
python eod_replay.py || echo "[scheduler] eod_replay FAILED"
python nightly_tune.py || echo "[scheduler] nightly_tune FAILED"
+ # Regime gate (plan 011, ALL-SHADOW): a session-level metalabel
+ # P(our ORB profits today). build-features labels each session from its
+ # OWN realized expR; score writes the WALK-FORWARD favorable flag the
+ # experiments' regime_gate intervention reads. Must run BEFORE
+ # experiments run. Does NOT gate live trading (that is owner-gated).
+ python -m research.regime build-features || echo "[scheduler] regime build-features FAILED"
+ python -m research.regime score --model threshold_v1 || echo "[scheduler] regime score threshold FAILED"
+ python -m research.regime score --model meta_v1 || echo "[scheduler] regime score meta FAILED"
# Experiment OS (plan 006, ALL-SHADOW): replay running experiments over
# newly-logged fires, then adjudicate against pre-registered bars.
python -m research.experiments run || echo "[scheduler] experiments run FAILED"
diff --git a/docs/research/regime_notes.md b/docs/research/regime_notes.md
new file mode 100644
index 0000000..e84bc4f
--- /dev/null
+++ b/docs/research/regime_notes.md
@@ -0,0 +1,55 @@
+# Regime-detection research notes — distilled for the momentum bot
+
+**Date:** 2026-07-15
+**Method:** 6 books in `books/market_regime_state_detection/` distilled by parallel readers (Ang & Timmermann NBER survey; Kritzman *Regime Shifts*; Zucchini/MacDonald/Langrock *HMM for Time Series*; Chan *Hands-On AI Trading* & *Quantitative Trading*; Kakkar et al *Trading Decoded*). Hamilton 1989 is a scanned image (no extractable text); its Markov-switching method is covered by the others.
+**Problem this feeds:** the bot is ~breakeven with no entry edge after honest fills; the one thing untried is **regime awareness** — it did well 07-08/09 (a runner regime) and sits out / bleeds otherwise. Goal: a FREE-data daily regime signal that gates/sizes ORB entries, harness-backtestable over ~260 sessions.
+
+---
+
+## The one insight that reframes everything (Chan's metalabeling)
+
+> "The key to successfully apply AI/ML to finance is to focus on **metalabeling** — finding the probability of profit of *your own* simple trading strategy, and **not** to use it to predict the market directly. Your own strategy's past track record is private; no one else is trying to predict it." — Chan, *Quantitative Trading*
+
+Don't detect abstract "market regime." **Predict `P(our ORB makes money today)`** from free pre-open features, using our OWN per-session expR (which the plan-006 harness already computes) as the label. This targets exactly what matters (does *our* edge work today), sidesteps the "high-vol lumps runner-days with crash-days" problem, reuses the harness, is free, and is a *private* signal nobody arbs away. Every practical book (Chan CPO, Trading Decoded RF) converges here; the academic survey (Ang-Timmermann) calls the same thing "regime-conditioned position sizing."
+
+## The method stack (what to build, in escalation order — ponytail)
+
+1. **Threshold baseline first** (Kritzman, Trading Decoded both insist): a trivial rule — e.g. `SPY 5d momentum > 0 AND SPY 20d vol < median`, or Kritzman **Financial Turbulence** (`sqrt((r−μ)Σ⁻¹(r−μ)')` on ~11 sector-ETF returns) below its 75th percentile. If the fancy model can't beat this net-of-costs out-of-sample, ship the rule.
+2. **Metalabel classifier** (the primary): `GradientBoostingClassifier` / `RandomForest` (free sklearn — the books' `predictnow.ai`/`gbdt` replaced) predicting `P(profitable ORB session)` from ~5–8 free features. Gate/size on the probability.
+3. **HMM regime as a FEATURE** (not the whole model): a 2-state Markov-switching state on SPY (`statsmodels MarkovRegression(spy_returns, k_regimes=2, switching_variance=True)`) or on the turbulence series — one column feeding the classifier, or a standalone gate to compare.
+
+## Free features (all pre-open, all we have or can ingest free)
+
+| Feature | Source | Note |
+|---|---|---|
+| SPY 5d/20d momentum + 20d realized vol (VIX proxy) | already ingesting SPY (plan 009) | the level-1 input every book uses |
+| IWM (small-cap index) + sector-ETF momentum | Alpaca/yfinance, **free** | small-caps specifically; sector rotation |
+| Kritzman turbulence (Mahalanobis on ~11 sector ETFs) | sector-ETF daily returns, **free** | "prices behaving uncharacteristically" |
+| Breadth = % of the gapper universe green / held-VWAP yesterday | our own stored data | the SIGNED feature that breaks vol-symmetry |
+| **Bot's own trailing-N-day realized expR + hit-rate** | our harness | the most on-target feature — a hot/cold-streak detector on our OWN edge |
+| VIX level + 1d change | CBOE/yfinance, **free** | the vol axis directly |
+
+Keep to **≤5–8 features** — only ~260 sessions, ~a third fewer "runner days." More features = curve-fit.
+
+## The load-bearing discipline (or the whole thing is a lie)
+
+- **Filtered / walk-forward / purged only.** Only a regime label computed from data **≤ today** is tradeable. Smoothed HMM probs and full-sample fits **peek at the future**. Sanity check every book demanded: *if a smoothed-gated backtest crushes the filtered-gated one, your edge is look-ahead.* Use purged/embargoed walk-forward CV (drop the boundary day) — the same leakage-safe split used in `plans/008`.
+- **Label is same-day-outcome, features are strictly pre-open.** The session's expR (label) uses open-knowable exits; every feature is lagged to the prior close.
+- **Out-of-sample split is mandatory.** Fit on the older half, apply to the recent half; confirm the favorable-regime expR advantage survives. "In-sample regimes need not persist out-of-sample" (Ang-Timmermann) — if the edge evaporates OOS, the regimes were in-sample noise.
+- **Acceptance = NET expR improves across EVERY walk-forward fold**, not AUC. A 52.8% classifier can still be net-negative (Chan Hands-On's own OOS accuracy was 52.77%).
+- **Let the DATA pick the sign.** Ang-Timmermann finds low-vol = high-Sharpe for a *long-index* investor — but a long-*dispersion* small-cap momentum strategy likely inverts this (runners need volatility). Measure conditional expR per regime; never assume high-vol = bad.
+- **Small-sample humility.** 2 states (don't LR-test the count), shallow trees (`max_depth ≤ 3`), report bootstrap/posterior bands on the regime probs, deflate any OOS Sharpe by the number of tweaks tried (Deflated Sharpe Ratio).
+- **Regimes lag.** A daily gate enters the runner regime a day or two late and exits late — structural. It sits out *some* good days and takes *some* bad ones. The bar is net improvement, not perfection.
+
+## Honest prior
+
+This is the most promising lever of the session — it targets the bot's own edge, uses free data, reuses the harness, and directly explains the 07-08/09-vs-today swing. **But 260 sessions is a small sample**, so the real risk is curve-fitting a regime that doesn't persist. The threshold-baseline-first rule, purged walk-forward, and OOS split are the guards. If, after all that, the gated expR doesn't clearly beat trade-always out-of-sample → the honest finding is "no exploitable regime signal in free data at this sample size," and we've learned it cheaply.
+
+## Sources (in `books/market_regime_state_detection/`)
+
+- Ang & Timmermann (2011), *Regime Changes and Financial Markets*, NBER WP 17182 — the survey + the sizing template + every caveat.
+- Kritzman et al, *Regime Shifts: Implications for Dynamic Strategies* — Financial Turbulence + absorption ratio.
+- Zucchini, MacDonald, Langrock, *Hidden Markov Models for Time Series* — the estimation recipe (forward-filter, BIC state selection, why filtered ≠ smoothed).
+- Chan et al, *Hands-On AI Trading* — the exact `statsmodels MarkovRegression` daily-gate recipe (Example 4) + the 4-year-lookback calibration.
+- Chan, *Quantitative Trading* — Conditional Parameter Optimization + the metalabeling principle.
+- Kakkar, Sahni, Shanmugamani, *Trading Decoded* — the RandomForest regime-as-feature recipe + purged walk-forward CV + the "beat the trivial rule or ship the rule" discipline.
diff --git a/plans/008-conversion-signal-model.md b/plans/008-conversion-signal-model.md
new file mode 100644
index 0000000..ac8676d
--- /dev/null
+++ b/plans/008-conversion-signal-model.md
@@ -0,0 +1,143 @@
+# Plan 008: Measure, then model — does the labeled data contain a separable conversion signal, and can a model (+ an Ollama catalyst feature) find it?
+
+> **Executor instructions**: This plan is GATED. Step 1 is a measurement whose
+> result decides whether Steps 2–4 run at all. Do not build the model before
+> Step 1's go/no-go passes. Run every verification command; if a STOP condition
+> fires, stop and report. Update the status row in `plans/README.md` when done.
+>
+> **Drift check**: `git diff --stat d28fe25..HEAD -- research/ storage/` — the
+> experiment harness (`research/experiments/`) and labeler tables are the
+> substrate; if they moved, re-confirm the schema in Step 1 before proceeding.
+
+## Status
+
+- **Priority**: P1 (this is the one entry-conversion lever never tested)
+- **Effort**: L (but Step 1 alone is S and may end the plan)
+- **Risk**: LOW to the live system — this is all SHADOW (predict + log, never gate) until an experiment verdict promotes it
+- **Depends on**: 006 (experiment harness — the promotion path), 004 (population module)
+- **Category**: direction / research-infrastructure
+- **Planned at**: commit `d28fe25`, 2026-07-14
+
+## MEASURED RESULT (Step 1 ran 2026-07-14) — verdict: NO-GO on the current data
+
+Leakage-safe expanding-window CV (5 date-blocks, whole-day splits) over 63,803 labeled setups, HistGradientBoostingClassifier on the trigger-time features that are actually populated:
+
+- **Mean CV AUC = 0.514** (folds: 0.489 / 0.538 / 0.528 / 0.526 / 0.487) — noise. The largest fold (62k rows) is 0.487, below random.
+- **Top-decile +1R = 0.456 vs base 0.428** — a ~+2.8pp lift, inconsistent across folds (some below base). Not tradeable.
+- **Top feature by far: `session_minute_number` (+0.010 AUC)**, then `relative_volume` (+0.003); everything else ≈ 0. The only faint signal is *enter early* + *higher RVOL* — both already known and already in the live gate.
+
+**Two data-quality findings that gate any future attempt:**
+1. **The rich features are mostly NULL** — `gap_pct` 85% null, `impulse_pct` 94%, `pullback_depth_pct` 98%, `catalyst_freshness_at_trigger` 76%, `float_rotation`/`tape_regime`/`spy_return` ~all null. The labeler populates different fields per track, so the "24-feature vector" is really ~8 dense features for most rows. A model can't find signal in columns that aren't filled.
+2. **The dataset is 85% `liq1`** (53,992/63,803 — the *refuted* liquid-breakout track); the live ORB `v1` is only 6,217 rows and converts WORSE (+1R rate 0.241 vs liq1's 0.437). So this isn't even a clean test of the live strategy.
+
+**Conclusion:** on the data as it exists, there is no separable joint signal — the model confirms at the multi-feature level what the 7 single-lever tests found. This is NOT a clean rejection of "could a model ever work," because the potentially-predictive features (gap, structure, catalyst, tape regime) are unpopulated. The honest next move is a **prerequisite data-plumbing fix** (populate those features for live ORB setups + turn on catalyst enrichment so `catalyst_freshness` fills), then re-run this probe — with a strong negative prior. Absent that, the lever is a *different entry* (premarket axis), not a better filter on ORB.
+
+## Why this matters (read before building anything)
+
+The project has refuted **7+ single-indicator selection levers** (float, RVOL beyond a floor, liquidity, VWAP-reclaim, leading-gainer, premarket-volume, runner-propensity) and, this week, **every exit variant** (volume_fade wash, time_stop worse, asym_exit killed). The consistent finding: **no lone indicator separates the ~50% of ORB setups that convert from the ~50% that stop out.** The bottleneck is entry-conversion.
+
+But every lever was tested **in isolation**. The move never tried: a **model over the full feature vector**. There are now **18,164 labeled setups** (`setup_events` JOIN `outcome_labels`) with ~24 features (gap, rvol, VWAP position, float rotation, impulse/pullback structure, session minute, tape regime, catalyst freshness, SPY return) and a clean binary label (`reached_1r_before_minus_1r`). A model asks *"does the combination separate winners?"* and its feature importances are literally *"which indicators matter, jointly"* — the disciplined version of the operator's "surely some indicator works." The ML approach was correctly deferred before because **n was tiny**; it is no longer tiny.
+
+The "humanistic / LLM / Alpaca-data" idea has a disciplined home too: the catalyst engine (Ollama + Alpaca news) is already built and gated off. The wrong use is a live gate (that is the refuted-folklore class). The right use is a **feature**: classify each setup's catalyst via Ollama over Alpaca news, add it to the vector, and let the model say whether the qualitative "story" separates winners.
+
+**The hard discipline** (this project has been burned by look-ahead 3+ times): purged/embargoed time-series splits, strict out-of-sample validation, and **shadow-first promotion through the experiment harness** — never a live gate on an unvalidated score.
+
+## Current state
+
+- Labeled dataset: `setup_events` (features) JOIN `outcome_labels` (labels) on `setup_id`, ~18,164 rows.
+ - **Feature columns** (`setup_events`): `entry_reference_price, invalidation_price, target_r_multiple, impulse_pct, pullback_depth_pct, pullback_bars, pullback_volume_ratio, above_vwap_flag, vwap_at_trigger, gap_pct, relative_volume, float_rotation_at_trigger, catalyst_freshness_at_trigger, session_minute_number, tape_regime_at_trigger, spy_intraday_return_pct_at_trigger` (+ `symbol, session_date, setup_time, setup_name, setup_version` as keys/metadata — NOT features).
+ - **Label columns** (`outcome_labels`): `reached_1r_before_minus_1r` (primary target), `reached_2r_before_minus_1r`, `max_upside_next_{5,15,60}m`, `max_drawdown_next_{5,15,60}m`, `held_vwap_{5,15}m`, `trend_day_flag`, `failed_breakout_flag`, `time_to_max_upside_minutes`.
+- **LEAKAGE RULE (non-negotiable)**: the model may use ONLY features knowable at `setup_time`. Every `*_at_trigger` field and gap/rvol/vwap/impulse/pullback/session-minute qualify. **No `outcome_labels` column may be a feature** (they are the future). `target_r_multiple` is a config, fine. Confirm each feature is trigger-time-knowable before including it.
+- Experiment harness (`research/experiments/`) is the promotion path: a conversion-score gate becomes a registered experiment replayed over logged fires before it ever gates live.
+- Ollama catalyst engine: `config.py` `OllamaConfig`; enrichment gated by `NEWS_ENRICH_ENABLED` (currently off). Alpaca news via the existing news ingestion. `catalyst_freshness_at_trigger` already exists as a feature (mostly null until enrichment runs).
+- Tooling: `scikit-learn` is available in the analysis environment (1.9.0). No sklearn dependency exists in `requirements.txt` yet — Step 3 adds it only if the model ships.
+- DB: `DATABASE_URL` → the production Postgres. Analysis is READ-ONLY. Extract with `python3 -c "…"` reading `DATABASE_URL`; never source `.env`.
+
+## Commands you will need
+
+| Purpose | Command | Expected |
+|---|---|---|
+| DB reachable | `python3 -c "import os,psycopg2;psycopg2.connect(os.environ['DATABASE_URL']).close();print('ok')"` | `ok` |
+| Suite | `DATABASE_URL= python3 -m pytest -q` | green (baseline: 15 WSL-loopback dashboard failures only) |
+| ruff | `ruff check --select F research/` | clean |
+
+## Scope
+
+**In scope**:
+- `research/conversion/` (new package: `dataset.py`, `signal_probe.py`, `model.py`, `cli.py`)
+- `research/experiments/` — a new intervention/experiment that gates on the model's score (shadow), reusing the harness
+- `config.py` / catalyst wiring — ONLY to expose the catalyst classification as a feature (Step 4), no live-gate change
+- `requirements.txt` — add `scikit-learn` ONLY if Step 3 ships a model
+- tests under `tests/unit/conversion/`
+- `plans/README.md` (status row)
+
+**Out of scope**:
+- ANY live gate/sizing/exit change. The model produces a SHADOW score; promotion to live is a separate, owner-approved change through the harness (like every other lever).
+- Turning `NEWS_ENRICH_ENABLED` on as a live gate. Catalyst is a feature only.
+- Deep-learning / new heavy deps. Gradient-boosted trees (sklearn `HistGradientBoostingClassifier`) is the right tool for imbalanced tabular data and is already available.
+- Predicting anything other than trigger-time-knowable → `reached_1r_before_minus_1r` (and optionally `reached_2r`). No regression on P&L.
+
+## Steps
+
+### Step 1 — GO/NO-GO: does separable signal exist at all? (leakage-safe CV AUC)
+
+Build `research/conversion/dataset.py` (load the JOINed dataset, drop leakage columns, coerce types, one-hot `tape_regime`/`setup_name`) and `research/conversion/signal_probe.py`:
+
+- Split by TIME, not randomly: sort by `session_date`; use `sklearn.model_selection.TimeSeriesSplit` (5 folds) with a **1-day embargo** between train and test (drop the boundary day) so no same-day leakage.
+- Train `HistGradientBoostingClassifier` (handles NaNs natively — important, many features are sparse) on each fold; score test-fold **ROC-AUC** and **precision@top-decile** (if you only took the model's top 10% most-confident setups, what's the +1R rate vs the base rate?).
+- Report per-fold and mean AUC, the top-decile lift, and permutation feature importances on the held-out folds.
+
+**Verify**: `DATABASE_URL= python3 -m research.conversion.signal_probe` prints mean CV AUC, top-decile +1R rate vs base rate, and ranked feature importances.
+
+**GO/NO-GO gate (this decides the plan):**
+- **Mean CV AUC ≥ ~0.55 AND top-decile +1R rate materially above base rate** → signal exists. Proceed to Step 2.
+- **AUC ≈ 0.50 (0.47–0.53) and no top-decile lift** → **STOP and report**: the data contains no trigger-time-knowable signal that separates conversion. That is the definitive answer to "is there an indicator" — there isn't one in this feature set, and the lever is a *different setup* (the premarket axis), not more modeling of this one. Do NOT proceed to build a model that fits noise.
+
+### Step 2 — Honest out-of-sample evaluation + calibration
+
+Only if Step 1 passed. `research/conversion/model.py`: train on the first ~80% of sessions, hold out the most recent ~20% as a true forward test (never touched in tuning). Report forward AUC, a calibration curve (`CalibratedClassifierCV`), and the top-decile forward +1R rate. Persist the trained model + the exact feature list + the training date range to `data/conversion_model.pkl` (atomic write, like `learned_params.json`).
+
+**Verify**: forward-test AUC within ~0.03 of CV AUC (no overfit blowup); calibration curve roughly diagonal. If forward AUC collapses to ~0.50 while CV was high → **STOP**, that is leakage or overfit; report which feature leaked.
+
+### Step 3 — Shadow score through the experiment harness (NOT a live gate)
+
+Only if Step 2 held. Register a harness experiment `conversion_gate_v1`: intervention = "take only setups whose model score ≥ threshold τ"; replay over logged fires/setups (the harness already does this); pre-register the bar (expR_delta_min vs taking all). Add `scikit-learn` to `requirements.txt`. The loop may COMPUTE and LOG a conversion score per candidate (a new shadow field on the decision receipt) but MUST NOT gate on it.
+
+**Verify**: `python3 -m research.experiments run && … adjudicate` shows `conversion_gate_v1` accruing; score logged on receipts; zero change to live gate code (`git diff` touches no live-path gate).
+
+### Step 4 — The catalyst/LLM feature (the "humanistic" layer), as a feature only
+
+Only if Steps 1–3 showed promise AND `catalyst_freshness`/tape features ranked meaningfully (i.e. qualitative context looks predictive). Wire the Ollama catalyst classification (news type, freshness, dilution risk from Alpaca news) into the labeler so it lands as `setup_events` features at build time; rebuild the dataset; re-run Step 1's probe WITH the catalyst features and compare AUC. If catalyst features lift AUC out-of-sample, that is the evidence to enrich; if not, leave enrichment off.
+
+**Verify**: probe AUC with-vs-without catalyst features, reported side by side. Promotion of any of this to a live gate remains a separate owner decision.
+
+## Test plan
+
+- `tests/unit/conversion/test_dataset_no_leakage.py` — assert the feature list contains ZERO `outcome_labels` columns and only trigger-time-knowable fields (the single most important test; a leaked label is how you get a fake AUC of 0.9).
+- `test_signal_probe_time_split` — the CV uses time-ordered folds with an embargo (no shuffle); a synthetic dataset with a known signal recovers AUC > 0.5, pure noise recovers ~0.5.
+- `test_model_persist_roundtrip` — save/load preserves the feature list + date range.
+- Verification: `DATABASE_URL= python3 -m pytest -q` green; non-loopback failures = 0.
+
+## Done criteria
+
+- [ ] Step 1 probe runs and prints mean CV AUC + top-decile lift + feature importances
+- [ ] The go/no-go was recorded (AUC value + decision) in `plans/README.md`
+- [ ] If NO-GO: plan stops here with the honest "no separable signal" finding logged — this is a valid, complete outcome
+- [ ] If GO: forward-test AUC + calibration reported; model persisted; harness experiment registered; live gate code unchanged (`git diff` proves it)
+- [ ] Leakage test green; suite green
+- [ ] `scikit-learn` in requirements.txt only if a model shipped
+
+## STOP conditions
+
+- DB unreachable (the current blocker) — wait; do not fabricate results.
+- Step 1 AUC ≈ 0.50 — STOP, report "no signal", do not model.
+- Step 2 forward AUC collapses vs CV — STOP, report leakage/overfit and which feature.
+- Any step requires touching a live gate — STOP, that is promotion (owner-gated).
+- A feature turns out to be trigger-time-UNknowable (leak) — remove it, re-run, report.
+
+## Maintenance notes
+
+- The model is a SHADOW predictor until the harness proves its score separates forward and the owner promotes it. Same discipline as every refuted lever — the graveyard is full of things that looked good in-sample.
+- Feature importances are the real deliverable even if the model never gates: they tell the owner which of the 24 measured things actually co-vary with conversion, ending the one-indicator-at-a-time guessing.
+- If Step 1 says no-signal, that is genuinely useful: it redirects effort from re-modeling this setup to the premarket axis (the one unexplored setup), which is a *new entry*, not a *better filter* on this one.
+- Retrain cadence + drift: if promoted, the model retrains on the same nightly scheduler as `nightly_tune`, with the same staleness alarm.
diff --git a/plans/009-feature-population-prerequisite.md b/plans/009-feature-population-prerequisite.md
new file mode 100644
index 0000000..7c69bac
--- /dev/null
+++ b/plans/009-feature-population-prerequisite.md
@@ -0,0 +1,133 @@
+# Plan 009: Instrument the data so a conversion model gets ONE fair test — fill the three feature gaps, then re-run the go/no-go
+
+> **Executor instructions**: This is the prerequisite to plan 008. It fixes the
+> data-quality reasons the 2026-07-14 signal probe was inconclusive, then re-runs
+> that probe scoped to the live strategy. It is GATED: the final step's AUC
+> decides whether modeling is ever worth pursuing. Run every verification; honor
+> STOP conditions; update `plans/README.md` when done.
+>
+> **Drift check**: `git diff --stat d28fe25..HEAD -- research/labeler.py research/ingestion/ config.py` — the labeler is the substrate; re-confirm the excerpts below if it moved.
+
+## Status
+
+- **Priority**: P2 (real payoff, but a multi-week accrual play against a negative prior — read "Honest framing")
+- **Effort**: L (mostly forward data accrual; the code is M)
+- **Risk**: LOW to live trading — all of this is labeler/feature plumbing + a config flag; no live gate/sizing/exit changes
+- **Depends on**: 006 (harness), 008 (the probe this re-runs)
+- **Category**: research-infrastructure / data-quality
+- **Planned at**: commit `d28fe25`, 2026-07-14
+
+## Honest framing (read before starting — this is not a quick win)
+
+The 2026-07-14 probe (plan 008) found **CV AUC 0.514 ≈ noise** on 63,803 labeled setups. Investigation showed the test was crippled by data quality, not necessarily by absence of signal:
+
+- 85% of rows are `liq1` (a *refuted* track); the live ORB `v1` is only 6,217 rows and converts at 24%.
+- Per-version null audit:
+ - `v1` (live ORB): has `gap_pct`, `above_vwap_flag`, `vwap_at_trigger`, `session_minute_number`; **missing `relative_volume` (100% null), catalyst, market-context.** (Pullback/impulse fields are legitimately N/A for a breakout setup — do not "fix" those.)
+ - `pm1` (premarket): the **best-instrumented** track — `gap_pct`, `relative_volume`, `impulse_pct` all populated; only catalyst missing. Also the one unexplored strategy axis.
+ - `tape_regime_at_trigger` and `spy_intraday_return_pct_at_trigger` are **100% null on every track** — hardcoded `None` in the labeler.
+
+This plan fills the three *fillable* gaps so a model gets one fair, fully-instrumented test. **Set expectations honestly:** even fully instrumented, the prior is strongly negative (7 refuted single levers + this joint-model probe), `v1` is small (~6k) and low-converting, and the market-context + catalyst features can only accrue **forward** (thin history), so a fair re-test needs a few weeks of data. If the re-run still returns ~0.51, that is the **definitive** answer — stop filtering ORB and build the premarket entry (which this plan also leaves better-instrumented). The alternative to running this plan is to skip straight to the premarket-setup work; this plan is worth it only if the owner wants modeling to get its one clean shot first.
+
+## Current state
+
+- Labeler: `research/labeler.py`. The v1/ORB `SetupEvent` build (~lines 288–316) computes `rvol = (vol_upto / avg_vol) if avg_vol else None` — so `relative_volume` is None whenever `avg_vol` (average daily volume) is 0/null, which is the norm for thin IPO-class small caps (documented blind spot; see the `ignition.py` comment "scan_gappers RVOL zeros out on thin IPOs, avg-daily-vol = 0").
+- Market context: `research/labeler.py:~322` literally sets `tape_regime=None, spy_intraday_return=None`. These features are declared in `setup_events` but never populated.
+- SPY/QQQ data: `SELECT count(*), count(DISTINCT session_date) FROM minute_bars WHERE symbol IN ('SPY','QQQ')` → 53,452 rows across **only 30 sessions**. So market-context backfill is limited to ~30 days; forward-fill requires ingesting SPY/QQQ daily going forward.
+- Catalyst: `config.py` `OllamaConfig`; enrichment gated by `NEWS_ENRICH_ENABLED` (off in the deployed compose — verified). `catalyst_freshness_at_trigger` / `catalyst_type` columns exist but are 73–97% null because enrichment isn't running. `_catalyst_at(...)` in the labeler already reads catalyst events IF they exist.
+- The probe: plan 008's `research/conversion/signal_probe.py` (to be created per plan 008) — this plan re-runs it scoped to a single `setup_version`.
+- READ-ONLY analysis + labeler rebuilds only. No live-path changes. `DATABASE_URL` = production; never source `.env` (extract `DATABASE_URL` only).
+
+## Commands you will need
+
+| Purpose | Command | Expected |
+|---|---|---|
+| Suite | `DATABASE_URL= python3 -m pytest -q` | green (baseline: 15 WSL-loopback dashboard failures only) |
+| ruff | `ruff check --select F research/` | clean |
+| Rebuild a track | `DATABASE_URL= python3 -c "from research.labeler import build_