From 053c927cf7845a9c5610c611bcefa29926b305da Mon Sep 17 00:00:00 2001 From: momentum dev Date: Fri, 17 Jul 2026 22:40:03 -0500 Subject: [PATCH] backtest: explain the regime-gated empty window + toggle to view raw-criteria trades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner saw a blank /backtest — it was CORRECT (the regime gate sat out every recent session) but unexplained. Now: ?regime=0|1 override on /api/backtest, an 'apply regime gate (as deployed)' checkbox, and an empty-state message saying WHY there are no trades and what to click. Gated view = what the bot would do; ungated = the raw simple criteria. Co-Authored-By: Claude Fable 5 --- api/main.py | 10 ++++++++-- api/static/backtest.html | 15 ++++++++++++++- tests/unit/api/test_backtest_endpoint.py | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index fe0579a..f1198fd 100644 --- a/api/main.py +++ b/api/main.py @@ -511,7 +511,7 @@ class DashboardState: return rows def backtest(self, days: int | None = 10, since: str | None = None, - until: str | None = None) -> dict: + until: str | None = None, regime_override: bool | None = None) -> dict: """Theoretical trades under CURRENT live settings (plan 015) — assembly only, the simulation lives in research.experiments.replay. @@ -530,6 +530,10 @@ class DashboardState: pop = PopulationConfig.from_env() regime_on = os.environ.get("TRADING_REGIME_GATE_ENABLED", "0").strip().lower() in { "1", "true", "yes", "on"} + if regime_override is not None: + # /backtest UX: let the page show RAW-criteria trades when the gate + # sits out the whole window (owner: 'no data in backtest'). + regime_on = regime_override fidelity = {"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1} settings = { "population": {"price_min": pop.price_min, "price_max": pop.price_max, @@ -805,8 +809,10 @@ class DashboardHandler(BaseHTTPRequestHandler): since = (q.get("since") or [""])[0] or None until = (q.get("until") or [""])[0] or None days = (q.get("days") or [""])[0] + rg = (q.get("regime") or [""])[0] self._send_json(self.state.backtest( - days=int(days) if days else 10, since=since, until=until)) + days=int(days) if days else 10, since=since, until=until, + regime_override=(rg == "1") if rg in ("0", "1") else None)) elif route == "/api/attribution": # per-component stage ratings (research/attribution.py); computed on # demand — a handful of indexed event queries, fine per click diff --git a/api/static/backtest.html b/api/static/backtest.html index b43d9e7..61b5427 100644 --- a/api/static/backtest.html +++ b/api/static/backtest.html @@ -141,6 +141,9 @@ + @@ -209,6 +212,9 @@ function setLoading(msg) { async function runBacktest(params, activeBtn) { document.querySelectorAll(".picker button[data-days]").forEach(b => b.classList.toggle("active", b === activeBtn)); + state.lastParams = params; state.lastBtn = activeBtn; + // the gate toggle rides every request: unchecked = raw-criteria trades + params = { ...params, regime: $("regime-toggle").checked ? "1" : "0" }; const n = params.days || "the requested"; setLoading("replaying " + n + " session" + (n === 1 ? "" : "s") + "… (can take up to 30s)"); try { @@ -229,6 +235,7 @@ async function runBacktest(params, activeBtn) { document.querySelectorAll(".picker button[data-days]").forEach(btn => { btn.onclick = () => runBacktest({ days: btn.dataset.days }, btn); }); +$("regime-toggle").onchange = () => runBacktest(state.lastParams || { days: 10 }, state.lastBtn || null); $("run-custom").onclick = () => { const since = $("since-input").value, until = $("until-input").value; if (!since || !until) return; @@ -285,7 +292,13 @@ function renderSummary(sum, skipped, screened) { // (cum_pnl -> cum_r, day_pnl -> sum_r); click a day filters the trade table. function renderDaily(days) { const host = $("daily-curve"); - if (!days || !days.length) { host.innerHTML = 'No trades in this window.'; return; } + if (!days || !days.length) { + const reg = ((state.data || {}).screened || {}).regime || 0; + host.innerHTML = '' + (reg > 0 + ? 'No trades: the regime gate sat out every replayed session in this window — the bot would not have traded these days at all. Uncheck \u201capply regime gate\u201d above to see what the raw criteria would have done.' + : 'No trades in this window (no qualifying setups).') + ''; + return; + } const n = days.length; const W = Math.max(host.clientWidth || 680, n * 48); const H = 184, padT = 16, padB = 30, padX = 12, plotH = H - padT - padB; diff --git a/tests/unit/api/test_backtest_endpoint.py b/tests/unit/api/test_backtest_endpoint.py index e4a23f6..b6cd0ac 100644 --- a/tests/unit/api/test_backtest_endpoint.py +++ b/tests/unit/api/test_backtest_endpoint.py @@ -244,3 +244,24 @@ def test_replay_window_replays_live_track_only(): from research.experiments.replay import replay_window out = replay_window(store, str(SESS1), str(SESS2)) assert len(out["trades"]) == 4 # the 4 v1 fires only; liq1 excluded + + +def test_backtest_regime_override_bypasses_gate(monkeypatch): + """/backtest UX fix (owner: 'no data in backtest'): when the regime gate + zeroes a whole window the page must be able to show RAW-criteria trades + via ?regime=0, and the response must echo which mode ran.""" + import api.main as m + store = _seed_store() + monkeypatch.setenv("TRADING_REGIME_GATE_ENABLED", "1") + # unfavorable scores for BOTH sessions -> gated run has zero trades + from research import regime as rg + rg.apply_schema(store.con) + for sess in (SESS1, SESS2): + store.con.execute( + "INSERT INTO regime_scores (model_version, session_date, p_profit, favorable, computed_at) " + "VALUES ('meta_v1', ?, 0.1, false, now()) ON CONFLICT DO NOTHING", [sess]) + state = m.DashboardState("momentum") + gated = state.backtest(days=10) + raw = state.backtest(days=10, regime_override=False) + assert gated["summary"]["n"] == 0 and gated["settings"]["regime_gate"] is True + assert raw["summary"]["n"] == 4 and raw["settings"]["regime_gate"] is False