research-tooling: /backtest replay dashboard — theoretical trades under current settings
Adds a read-only /backtest page showing how the bot WOULD have traded past sessions under the CURRENTLY configured live settings: theoretical ORB fires from setup_events, replayed through the shared exit engine (simulate_exit + ExitConfig.from_env(), same adverse-fill accounting the exit-experiment harness uses), screened through the shared population gate and (optionally) the live regime gate. No new simulation math — replay.replay_window() is pure assembly of existing pieces (reconstruct_fires_from_setups, _fire_in_population, _load_regime_favorable, simulate_exit); api/main.py adds the /backtest and /api/backtest routes plus a dedicated-lock 10-min cache so a slow replay never blocks the live dashboard's SSE stream. Also extends /api/chart with an optional date= anchor so the trade drill-in chart modal can reach sessions older than the existing 72h-from-now window, and adds a one-line BACKTEST nav link to the live dashboard header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ed0abe0e2
commit
8ac222a4ca
5 files changed
+877
-5
No files matched your search
+111
-5
@@ -8,8 +8,11 @@ GET /api/stream Server-Sent Events: pushes snapshots on
|
||||
GET /api/approval-queue pending approvals only
|
||||
GET /api/criteria?symbol=XYZ per-criterion pass/fail breakdown
|
||||
GET /api/bars?symbol=XYZ[&minutes=60] recent 1-min bars for a sparkline
|
||||
GET /api/chart?symbol=XYZ[&hours=24] 24h candles + fill markers + entry/stop/target levels
|
||||
GET /api/chart?symbol=XYZ[&hours=24][&date=YYYY-MM-DD] candles + fill markers + levels;
|
||||
date= anchors the window to that session (else "now")
|
||||
GET /api/decisions[?symbol=&limit=&date=] entry_gate_evaluated decision receipts (plan 014)
|
||||
GET /backtest theoretical-replay dashboard HTML (plan 015)
|
||||
GET /api/backtest[?days=10|since=&until=] theoretical trades under CURRENT settings (plan 015)
|
||||
GET /static/vendor/... vendored JS libs (e.g. Lightweight Charts)
|
||||
POST /api/trading/approvals/approve {"order_id": ..., "approved_by"?: ...}
|
||||
POST /api/trading/approvals/reject {"order_id": ..., "reason"?: ...}
|
||||
@@ -31,7 +34,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -249,6 +252,11 @@ class DashboardState:
|
||||
self._lock = threading.Lock()
|
||||
# lazily-built, process-wide SSE change signal (shared by all clients)
|
||||
self._change_signal: _ChangeSignal | None = None
|
||||
# backtest replay cache (plan 015) — a DEDICATED lock, not self._lock,
|
||||
# so a slow replay (10-30s) never blocks the live snapshot/SSE path.
|
||||
# key = (since, until, repr(settings)) -> (result, monotonic timestamp).
|
||||
self._backtest_lock = threading.Lock()
|
||||
self._backtest_cache: dict = {}
|
||||
|
||||
def change_stamp(self):
|
||||
"""SSE change token from the shared once-per-second signal (see _ChangeSignal)."""
|
||||
@@ -381,13 +389,19 @@ class DashboardState:
|
||||
]
|
||||
return {"symbol": symbol, "bars": bars}
|
||||
|
||||
def chart(self, symbol: str, hours: int = 24) -> dict:
|
||||
def chart(self, symbol: str, hours: int = 24, anchor_date: str | None = None) -> dict:
|
||||
"""24h candlestick bars + trade markers + entry/stop/target levels for
|
||||
the symbol drill-in chart (plan 013). Bars come from the research DB;
|
||||
markers from the fills feed; levels from the same account snapshots
|
||||
``snapshots()`` already loads. Timestamps in ``minute_bars``/``events``
|
||||
are naive US/Eastern — every timestamp returned here is a UTC epoch
|
||||
second (what Lightweight Charts wants).
|
||||
|
||||
``anchor_date`` (plan 015): anchors the window to that session's close
|
||||
instead of "now" — the live modal never passes it (unchanged
|
||||
behavior), but the backtest replay's drill-in needs bars from
|
||||
arbitrary past sessions the plain ``hours``-back-from-now window can
|
||||
never reach (it's capped at 72h).
|
||||
"""
|
||||
if self.research_con is None:
|
||||
return {"symbol": symbol, "bars": [], "reason": "no research db attached"}
|
||||
@@ -395,7 +409,11 @@ class DashboardState:
|
||||
|
||||
et = ZoneInfo("America/New_York")
|
||||
hours = max(1, min(int(hours or 24), 72))
|
||||
now_et = datetime.now(et)
|
||||
if anchor_date:
|
||||
d = date.fromisoformat(str(anchor_date))
|
||||
now_et = datetime(d.year, d.month, d.day, 20, 0, tzinfo=et) # well after the close
|
||||
else:
|
||||
now_et = datetime.now(et)
|
||||
cutoff_et = now_et - timedelta(hours=hours)
|
||||
cutoff_naive = cutoff_et.replace(tzinfo=None) # naive ET, matches storage
|
||||
cutoff_epoch = int(cutoff_et.timestamp())
|
||||
@@ -492,6 +510,84 @@ class DashboardState:
|
||||
row["epoch"] = _to_epoch_et(ts) if ts else None
|
||||
return rows
|
||||
|
||||
def backtest(self, days: int | None = 10, since: str | None = None,
|
||||
until: str | None = None) -> dict:
|
||||
"""Theoretical trades under CURRENT live settings (plan 015) —
|
||||
assembly only, the simulation lives in research.experiments.replay.
|
||||
|
||||
``days`` (default path): the most recent N trading sessions with
|
||||
setup_events, counted backwards. ``since``/``until``: an explicit
|
||||
date range instead. Either way capped at 45 sessions (``clamped``
|
||||
reports when the request exceeded it). Cached 10 min behind a
|
||||
DEDICATED lock (not self._lock) so a slow replay never blocks the
|
||||
live snapshot/SSE path — two concurrent identical requests compute
|
||||
the replay exactly once.
|
||||
"""
|
||||
from strategy.exits import ExitConfig
|
||||
from strategy.population import PopulationConfig
|
||||
|
||||
MAX_SESSIONS = 45
|
||||
pop = PopulationConfig.from_env()
|
||||
regime_on = os.environ.get("TRADING_REGIME_GATE_ENABLED", "0").strip().lower() in {
|
||||
"1", "true", "yes", "on"}
|
||||
fidelity = {"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1}
|
||||
settings = {
|
||||
"population": {"price_min": pop.price_min, "price_max": pop.price_max,
|
||||
"min_entry_gap_pct": pop.min_entry_gap_pct,
|
||||
"min_day_volume": pop.min_day_volume},
|
||||
"regime_gate": regime_on,
|
||||
"fidelity": fidelity,
|
||||
"target_r": ExitConfig.from_env().target_r,
|
||||
}
|
||||
empty = {"trades": [], "daily": [], "skipped": 0,
|
||||
"summary": {"n": 0, "sum_r": 0.0, "avg_r": 0.0, "hit_rate": 0.0, "sessions": 0},
|
||||
"screened": {"population": 0, "regime": 0}}
|
||||
|
||||
store = self.open_store()
|
||||
try:
|
||||
base_q = ("SELECT DISTINCT session_date FROM setup_events WHERE "
|
||||
"entry_reference_price IS NOT NULL AND invalidation_price IS NOT NULL")
|
||||
if since and until:
|
||||
rows = store.con.execute(
|
||||
base_q + " AND session_date >= ? AND session_date <= ? "
|
||||
"ORDER BY session_date DESC", [since, until]).fetchall()
|
||||
all_dates = sorted(r[0] for r in rows)
|
||||
clamped = len(all_dates) > MAX_SESSIONS
|
||||
dates = all_dates[-MAX_SESSIONS:] if clamped else all_dates
|
||||
sessions_requested = len(all_dates)
|
||||
else:
|
||||
requested = int(days or 10)
|
||||
clamped = requested > MAX_SESSIONS
|
||||
n_sessions = min(max(requested, 1), MAX_SESSIONS)
|
||||
rows = store.con.execute(
|
||||
base_q + " ORDER BY session_date DESC LIMIT ?", [n_sessions]).fetchall()
|
||||
dates = sorted(r[0] for r in rows)
|
||||
sessions_requested = n_sessions
|
||||
|
||||
if not dates:
|
||||
return {**empty, "settings": settings, "clamped": clamped,
|
||||
"sessions_requested": sessions_requested}
|
||||
win_since, win_until = str(dates[0]), str(dates[-1])
|
||||
|
||||
key = (win_since, win_until, repr(settings))
|
||||
with self._backtest_lock:
|
||||
cached = self._backtest_cache.get(key)
|
||||
if cached and (time.monotonic() - cached[1]) < 600:
|
||||
result = cached[0]
|
||||
else:
|
||||
from research.experiments.replay import replay_window
|
||||
result = replay_window(store, win_since, win_until, population_cfg=pop,
|
||||
regime_gate=regime_on, fidelity=fidelity)
|
||||
for t in result["trades"]:
|
||||
t["entry_epoch"] = _to_epoch_et(t["entry_ts"])
|
||||
t["exit_epoch"] = _to_epoch_et(t["exit_ts"])
|
||||
self._backtest_cache[key] = (result, time.monotonic())
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return {**result, "settings": settings, "clamped": clamped,
|
||||
"sessions_requested": sessions_requested}
|
||||
|
||||
def _with_service(self, fn):
|
||||
if self.execution_service is None:
|
||||
return None
|
||||
@@ -665,6 +761,8 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
route = urlparse(self.path).path
|
||||
if route in ("/", "/index.html", "/dashboard"):
|
||||
self._send_html(STATIC_DIR / "dashboard.html")
|
||||
elif route == "/backtest":
|
||||
self._send_html(STATIC_DIR / "backtest.html")
|
||||
elif route.startswith("/static/vendor/"):
|
||||
# path-traversal guard: resolve then require it stays inside
|
||||
# STATIC_DIR/vendor (e.g. "/static/vendor/../dashboard.html" 404s).
|
||||
@@ -693,7 +791,8 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
q = self._query()
|
||||
symbol = (q.get("symbol") or [""])[0]
|
||||
hours = int((q.get("hours") or ["24"])[0])
|
||||
self._send_json(self.state.chart(symbol, hours))
|
||||
anchor = (q.get("date") or [""])[0] or None
|
||||
self._send_json(self.state.chart(symbol, hours, anchor_date=anchor))
|
||||
elif route == "/api/decisions":
|
||||
q = self._query()
|
||||
symbol = (q.get("symbol") or [""])[0] or None
|
||||
@@ -701,6 +800,13 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
d = (q.get("date") or [""])[0] or None
|
||||
self._send_json({"decisions":
|
||||
self.state.decisions(symbol=symbol, limit=limit, for_date=d)})
|
||||
elif route == "/api/backtest":
|
||||
q = self._query()
|
||||
since = (q.get("since") or [""])[0] or None
|
||||
until = (q.get("until") or [""])[0] or None
|
||||
days = (q.get("days") or [""])[0]
|
||||
self._send_json(self.state.backtest(
|
||||
days=int(days) if days else 10, since=since, until=until))
|
||||
elif route == "/api/attribution":
|
||||
# per-component stage ratings (research/attribution.py); computed on
|
||||
# demand — a handful of indexed event queries, fine per click
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Momentum — Backtest Replay</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #080a10;
|
||||
--panel: #111622;
|
||||
--panel-2: #161c2a;
|
||||
--panel-hi: #1c2435;
|
||||
--border: #232c41;
|
||||
--border-soft: #18202f;
|
||||
--text: #e3e8f2;
|
||||
--muted: #7884a2;
|
||||
--accent: #5ba3ff;
|
||||
--accent-dim: #2a4a78;
|
||||
--green: #34d17e;
|
||||
--red: #ff5a60;
|
||||
--amber: #f7b13b;
|
||||
--purple: #c2a6e2;
|
||||
--glow-green: rgba(52,209,126,.5);
|
||||
--glow-red: rgba(255,90,96,.45);
|
||||
--shadow: 0 8px 24px -12px rgba(0,0,0,.7), 0 1px 0 0 rgba(255,255,255,.035) inset;
|
||||
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 560px at 82% -12%, rgba(30,58,110,.28), transparent 60%),
|
||||
radial-gradient(820px 480px at -5% -5%, rgba(60,40,120,.16), transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font: 14px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
header { display: flex; align-items: center; gap: 14px; padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
header h1 { font-size: 16px; letter-spacing: .3px; }
|
||||
header h1 a { color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 600;
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 3px 8px; margin-left: 10px; }
|
||||
header h1 a:hover { color: var(--text); border-color: var(--accent); }
|
||||
.picker { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-wrap: wrap; }
|
||||
.picker button { background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 5px; padding: 6px 12px; font: 600 12px var(--mono); cursor: pointer; }
|
||||
.picker button:hover { border-color: var(--accent); }
|
||||
.picker button.active { background: var(--accent-dim); border-color: var(--accent); color: #fff; }
|
||||
.picker input[type=date] { background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 5px; padding: 5px 8px; font: 600 12px var(--mono); }
|
||||
.picker .run { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
.banner { margin: 14px 20px 0; padding: 10px 14px; border-radius: 8px;
|
||||
background: rgba(247,177,59,.10); border: 1px solid rgba(247,177,59,.35);
|
||||
color: var(--amber); font: 700 12px/1.4 var(--mono); letter-spacing: .2px; }
|
||||
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 20px 0; }
|
||||
.chip { background: var(--panel-2); border: 1px solid var(--border); border-radius: 20px;
|
||||
padding: 4px 12px; font: 600 11px var(--mono); color: var(--muted); }
|
||||
.chip.on { color: var(--green); border-color: rgba(52,209,126,.4); }
|
||||
.chip.off { color: var(--muted); }
|
||||
|
||||
.loading { margin: 40px 20px; text-align: center; color: var(--muted); font: 600 13px var(--mono); }
|
||||
.loading .spin { width: 30px; height: 30px; margin: 0 auto 14px; border: 3px solid var(--border);
|
||||
border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error { margin: 20px; padding: 14px; border-radius: 8px; background: rgba(255,90,96,.08);
|
||||
border: 1px solid rgba(255,90,96,.35); color: var(--red); font: 600 12px var(--mono); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
main { padding: 16px 20px 40px; }
|
||||
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1px; background: var(--border);
|
||||
border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 16px; }
|
||||
.summary .cell { background: var(--panel); padding: 12px 14px; }
|
||||
.summary .label { font: 600 10px var(--mono); color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.summary .value { font: 700 20px var(--mono); margin-top: 4px; }
|
||||
.summary .value.pos { color: var(--green); }
|
||||
.summary .value.neg { color: var(--red); }
|
||||
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: var(--shadow); margin-bottom: 16px; }
|
||||
.panel h2 { font-size: 13px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft);
|
||||
display: flex; align-items: center; gap: 8px; }
|
||||
.panel h2 .sub { font-weight: 500; color: var(--muted); font-size: 11px; }
|
||||
.panel-body { padding: 12px 16px; overflow-x: auto; }
|
||||
.empty { color: var(--muted); font: 600 12px var(--mono); padding: 20px 0; display: block; text-align: center; }
|
||||
|
||||
.daily-svg { display: block; }
|
||||
.daily-col { cursor: pointer; }
|
||||
.daily-hit { fill: transparent; }
|
||||
.daily-col:hover .daily-hit { fill: rgba(91,163,255,.06); }
|
||||
.daily-lbl { font: 600 9px var(--mono); fill: var(--muted); text-anchor: middle; }
|
||||
.daily-lbl.daily-sel { fill: var(--accent); }
|
||||
.daily-col.daily-sel .daily-hit { fill: rgba(91,163,255,.10); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; font: 600 10px var(--mono); color: var(--muted); text-transform: uppercase;
|
||||
letter-spacing: .4px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); position: sticky; top: 0; background: var(--panel); }
|
||||
td { padding: 7px 10px; border-bottom: 1px solid var(--border-soft); font: 500 12px var(--mono); white-space: nowrap; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: var(--panel-hi); }
|
||||
td.sym { font-weight: 700; color: var(--accent); }
|
||||
td.r.pos { color: var(--green); font-weight: 700; }
|
||||
td.r.neg { color: var(--red); font-weight: 700; }
|
||||
|
||||
footer { padding: 10px 20px 30px; color: var(--muted); font: 500 10px/1.6 var(--mono); }
|
||||
|
||||
/* chart modal — imitates dashboard.html's plan-013 drill-in */
|
||||
.chart-modal { position: fixed; inset: 0; background: rgba(6,8,12,.82); display: none; place-items: center; z-index: 70; }
|
||||
.chart-modal.show { display: grid; }
|
||||
.chart-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
width: min(920px, 94vw); height: min(560px, 88vh); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.chart-hdr { display: flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.chart-hdr .sym { font: 700 15px var(--mono); letter-spacing: .3px; }
|
||||
.chart-hdr .meta { color: var(--muted); font: 600 11px var(--mono); }
|
||||
.chart-hdr .close { margin-left: auto; background: transparent; border: 1px solid var(--border);
|
||||
color: var(--muted); border-radius: 4px; padding: 4px 10px; cursor: pointer; font: 600 11px var(--mono); }
|
||||
.chart-hdr .close:hover { color: var(--text); border-color: var(--accent); }
|
||||
#chart-container { flex: 1; min-height: 0; }
|
||||
.chart-ftr { padding: 6px 16px; border-top: 1px solid var(--border-soft); font: 500 10px var(--mono);
|
||||
color: var(--muted); flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 900px) { .summary { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 640px) { .summary { grid-template-columns: repeat(2, 1fr); } header { padding: 10px 14px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Backtest Replay <a href="/">← live dashboard</a></h1>
|
||||
<div class="picker">
|
||||
<button data-days="5">5d</button>
|
||||
<button data-days="10">10d</button>
|
||||
<button data-days="20">20d</button>
|
||||
<button data-days="45">45d</button>
|
||||
<input type="date" id="since-input">
|
||||
<input type="date" id="until-input">
|
||||
<button class="run" id="run-custom">Run range</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner">THEORETICAL — simulated fills (adverse-tick fidelity), setup-sourced entries; not live P&L.</div>
|
||||
<div class="chips" id="chips"></div>
|
||||
|
||||
<div class="loading hidden" id="loading"><div class="spin"></div><span id="loading-msg">replaying…</span></div>
|
||||
<div class="error hidden" id="error"></div>
|
||||
|
||||
<main class="hidden" id="results">
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Equity curve (R) <span class="sub">click a day to filter the trade table</span></h2>
|
||||
<div class="panel-body"><div id="daily-curve"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Trades <span class="sub" id="trades-sub"></span></h2>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th>Symbol</th><th>Entry</th><th>Exit</th><th>Reason</th><th>R</th></tr></thead>
|
||||
<tbody id="trades-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer id="footer" class="hidden">
|
||||
<div>entries are setup_events-sourced ORB triggers (the labeler's population); entry fill = trigger + adverse ticks — optimistic vs real slippage on thin names.</div>
|
||||
<div id="coverage-line"></div>
|
||||
</footer>
|
||||
|
||||
<div id="chart-modal" class="chart-modal">
|
||||
<div class="chart-card">
|
||||
<div class="chart-hdr">
|
||||
<span class="sym" id="chart-sym"></span>
|
||||
<span class="meta" id="chart-meta"></span>
|
||||
<button class="close" id="chart-close">CLOSE (Esc)</button>
|
||||
</div>
|
||||
<div id="chart-container"></div>
|
||||
<div class="chart-ftr">blue = theoretical entry · purple = theoretical exit (reason labeled) · Charts by TradingView</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/vendor/lightweight-charts.standalone.production.js" defer></script>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
const fmtR = (v) => (v == null || isNaN(+v)) ? "—" : (v >= 0 ? "+" : "") + (+v).toFixed(2) + "R";
|
||||
const fmtPx = (v) => (v == null || isNaN(+v)) ? "—" : (+v).toFixed(2);
|
||||
const fmtTs = (iso) => { try { return iso.replace("T", " ").slice(5, 16); } catch { return iso || "—"; } };
|
||||
|
||||
let state = { data: null, viewDate: null };
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// fetch + orchestration
|
||||
// -------------------------------------------------------------------------
|
||||
function setLoading(msg) {
|
||||
$("loading-msg").textContent = msg;
|
||||
$("loading").classList.remove("hidden");
|
||||
$("error").classList.add("hidden");
|
||||
$("results").classList.add("hidden");
|
||||
$("footer").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function runBacktest(params, activeBtn) {
|
||||
document.querySelectorAll(".picker button[data-days]").forEach(b => b.classList.toggle("active", b === activeBtn));
|
||||
const n = params.days || "the requested";
|
||||
setLoading("replaying " + n + " session" + (n === 1 ? "" : "s") + "… (can take up to 30s)");
|
||||
try {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const r = await fetch("/api/backtest?" + qs);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || ("HTTP " + r.status));
|
||||
state.data = data;
|
||||
state.viewDate = null;
|
||||
render(data);
|
||||
} catch (e) {
|
||||
$("loading").classList.add("hidden");
|
||||
$("error").textContent = "backtest failed: " + e.message;
|
||||
$("error").classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".picker button[data-days]").forEach(btn => {
|
||||
btn.onclick = () => runBacktest({ days: btn.dataset.days }, btn);
|
||||
});
|
||||
$("run-custom").onclick = () => {
|
||||
const since = $("since-input").value, until = $("until-input").value;
|
||||
if (!since || !until) return;
|
||||
runBacktest({ since, until }, null);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// render
|
||||
// -------------------------------------------------------------------------
|
||||
function render(data) {
|
||||
$("loading").classList.add("hidden");
|
||||
$("results").classList.remove("hidden");
|
||||
$("footer").classList.remove("hidden");
|
||||
renderChips(data.settings, data.clamped);
|
||||
renderSummary(data.summary, data.skipped, data.screened);
|
||||
renderDaily(data.daily);
|
||||
renderTrades(data.trades, null);
|
||||
const y = data.sessions_requested;
|
||||
const x = (data.daily || []).length;
|
||||
$("coverage-line").textContent = "sessions replayed: " + x + " of " + y + " requested" +
|
||||
(data.clamped ? " (clamped to the 45-session cap)" : "") +
|
||||
(x < y ? " — a nightly setup_events build gap may explain the shortfall" : "");
|
||||
}
|
||||
|
||||
function renderChips(s, clamped) {
|
||||
if (!s) { $("chips").innerHTML = ""; return; }
|
||||
const gap = s.population.min_entry_gap_pct > 0 ? "gap≥" + (s.population.min_entry_gap_pct * 100).toFixed(0) + "%" : "gap gate off";
|
||||
const chips = [
|
||||
"target_r=" + s.target_r,
|
||||
"price $" + s.population.price_min + "-" + s.population.price_max,
|
||||
gap,
|
||||
"regime gate " + (s.regime_gate ? "ON" : "OFF"),
|
||||
"fidelity atr×" + s.fidelity.atr_frac + " spread×" + s.fidelity.spread_mult + " adverse=" + s.fidelity.adverse_ticks + "tick",
|
||||
];
|
||||
if (clamped) chips.push("CLAMPED to 45 sessions");
|
||||
$("chips").innerHTML = chips.map(c => '<span class="chip ' + (c.includes("ON") ? "on" : "") + '">' + esc(c) + '</span>').join("");
|
||||
}
|
||||
|
||||
function renderSummary(sum, skipped, screened) {
|
||||
sum = sum || { n: 0, sum_r: 0, avg_r: 0, hit_rate: 0, sessions: 0 };
|
||||
const cell = (label, val, cls) => '<div class="cell"><div class="label">' + label +
|
||||
'</div><div class="value ' + (cls || "") + '">' + val + '</div></div>';
|
||||
$("summary").innerHTML =
|
||||
cell("Trades", sum.n) +
|
||||
cell("Sum R", fmtR(sum.sum_r), sum.sum_r >= 0 ? "pos" : "neg") +
|
||||
cell("Avg R", fmtR(sum.avg_r), sum.avg_r >= 0 ? "pos" : "neg") +
|
||||
cell("Hit Rate", (sum.hit_rate * 100).toFixed(0) + "%") +
|
||||
cell("Sessions", sum.sessions) +
|
||||
cell("Skipped", skipped + " <span style=\"font-size:10px;color:var(--muted)\">/ pop " +
|
||||
(screened ? screened.population : 0) + " · regime " + (screened ? screened.regime : 0) + "</span>");
|
||||
}
|
||||
|
||||
// day-over-day R curve — waterfall adapted from dashboard.html's renderDaily
|
||||
// (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 = '<span class="empty">No trades in this window.</span>'; 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;
|
||||
const cums = days.map(d => +d.cum_r || 0);
|
||||
const lo = Math.min(0, ...cums), hi = Math.max(0, ...cums), rng = (hi - lo) || 1;
|
||||
const Y = v => padT + plotH * (1 - (v - lo) / rng);
|
||||
const slot = (W - 2 * padX) / n, cx = i => padX + slot * (i + 0.5);
|
||||
const barW = Math.min(slot * 0.5, 34);
|
||||
const z = Y(0).toFixed(1);
|
||||
let bars = "", labels = "", dots = "", prev = 0;
|
||||
days.forEach((d, i) => {
|
||||
const cur = cums[i], up = (+d.sum_r || 0) >= 0, col = up ? "#34d17e" : "#ff5a60";
|
||||
const y1 = Y(prev), y2 = Y(cur), top = Math.min(y1, y2), h = Math.max(1, Math.abs(y1 - y2));
|
||||
const x = (cx(i) - barW / 2).toFixed(1);
|
||||
const sel = state.viewDate === d.date ? " daily-sel" : "";
|
||||
const tip = d.date + " · " + d.n + " trades · sum " + fmtR(d.sum_r) + " · cum " + fmtR(cur);
|
||||
bars += '<g class="daily-col' + sel + '" onclick="filterByDay(\'' + d.date + '\')"><title>' + esc(tip) + '</title>'
|
||||
+ '<rect class="daily-hit" x="' + (padX + slot * i).toFixed(1) + '" y="' + padT + '" width="' + slot.toFixed(1) + '" height="' + plotH + '"/>'
|
||||
+ '<rect x="' + x + '" y="' + top.toFixed(1) + '" width="' + barW.toFixed(1) + '" height="' + h.toFixed(1) + '" rx="2" fill="' + col + '" opacity=".5"/>'
|
||||
+ '</g>';
|
||||
labels += '<text class="daily-lbl' + sel + '" x="' + cx(i).toFixed(1) + '" y="' + (H - 10) + '">' + d.date.slice(5) + '</text>';
|
||||
dots += '<circle cx="' + cx(i).toFixed(1) + '" cy="' + y2.toFixed(1) + '" r="2.8" fill="#5ba3ff"/>';
|
||||
prev = cur;
|
||||
});
|
||||
const line = days.map((d, i) => cx(i).toFixed(1) + "," + Y(cums[i]).toFixed(1)).join(" ");
|
||||
host.innerHTML = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" class="daily-svg">'
|
||||
+ '<line x1="' + padX + '" y1="' + z + '" x2="' + (W - padX) + '" y2="' + z + '" stroke="#30363d" stroke-width="1" stroke-dasharray="3 3"/>'
|
||||
+ bars + '<polyline points="' + line + '" fill="none" stroke="#5ba3ff" stroke-width="1.6"/>' + dots + labels + '</svg>';
|
||||
}
|
||||
|
||||
function filterByDay(date) {
|
||||
state.viewDate = (state.viewDate === date) ? null : date;
|
||||
renderDaily(state.data.daily);
|
||||
renderTrades(state.data.trades, state.viewDate);
|
||||
}
|
||||
|
||||
function renderTrades(trades, viewDate) {
|
||||
trades = trades || [];
|
||||
const rows = viewDate ? trades.filter(t => t.session_date === viewDate) : trades;
|
||||
$("trades-sub").textContent = viewDate ? (rows.length + " on " + viewDate + " — click the day again to clear") :
|
||||
(rows.length + " total");
|
||||
if (!rows.length) { $("trades-body").innerHTML = '<tr><td colspan="6" class="empty">No trades.</td></tr>'; return; }
|
||||
$("trades-body").innerHTML = rows.map((t, i) => {
|
||||
const idx = trades.indexOf(t);
|
||||
const cls = t.r_multiple >= 0 ? "pos" : "neg";
|
||||
return '<tr onclick="openTradeChart(' + idx + ')">' +
|
||||
'<td>' + esc(t.session_date) + '</td>' +
|
||||
'<td class="sym">' + esc(t.symbol) + '</td>' +
|
||||
'<td>' + fmtTs(t.entry_ts) + ' @ ' + fmtPx(t.entry_fill) + '</td>' +
|
||||
'<td>' + fmtTs(t.exit_ts) + ' @ ' + fmtPx(t.fills.length ? t.fills[t.fills.length - 1].price : null) + '</td>' +
|
||||
'<td>' + esc(t.exit_reason) + '</td>' +
|
||||
'<td class="r ' + cls + '">' + fmtR(t.r_multiple) + '</td>' +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// chart drill-in — imitates dashboard.html's openChart/refreshChart (plan 013)
|
||||
// -------------------------------------------------------------------------
|
||||
let chartState = { chart: null, series: null, markersApi: null };
|
||||
|
||||
function closeChart() {
|
||||
$("chart-modal").classList.remove("show");
|
||||
if (chartState.chart) chartState.chart.remove();
|
||||
chartState = { chart: null, series: null, markersApi: null };
|
||||
}
|
||||
|
||||
async function openTradeChart(idx) {
|
||||
if (!window.LightweightCharts) return;
|
||||
const t = state.data.trades[idx];
|
||||
if (!t) return;
|
||||
if (chartState.chart) closeChart();
|
||||
$("chart-sym").textContent = t.symbol;
|
||||
$("chart-meta").textContent = t.session_date + " · " + t.exit_reason + " · " + fmtR(t.r_multiple);
|
||||
$("chart-modal").classList.add("show");
|
||||
const container = $("chart-container");
|
||||
container.innerHTML = "";
|
||||
const chart = LightweightCharts.createChart(container, {
|
||||
autoSize: true,
|
||||
layout: { background: { color: "#111622" }, textColor: "#e3e8f2" },
|
||||
grid: { vertLines: { color: "#18202f" }, horzLines: { color: "#18202f" } },
|
||||
timeScale: { timeVisible: true, secondsVisible: false },
|
||||
});
|
||||
const series = chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||
upColor: "#2fbf71", downColor: "#e5484d", borderVisible: false,
|
||||
wickUpColor: "#2fbf71", wickDownColor: "#e5484d",
|
||||
});
|
||||
const markersApi = LightweightCharts.createSeriesMarkers(series, []);
|
||||
chartState = { chart, series, markersApi };
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/chart?symbol=" + encodeURIComponent(t.symbol) +
|
||||
"&hours=24&date=" + encodeURIComponent(t.session_date));
|
||||
const data = await r.json();
|
||||
series.setData((data.bars || []).map(b => ({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close })));
|
||||
const markers = [
|
||||
{ time: t.entry_epoch, position: "belowBar", shape: "arrowUp", color: "#5ba3ff", text: "entry " + fmtPx(t.entry_fill) },
|
||||
{ time: t.exit_epoch, position: "aboveBar", shape: "arrowDown", color: "#c2a6e2", text: t.exit_reason },
|
||||
].sort((a, b) => a.time - b.time);
|
||||
markersApi.setMarkers(markers);
|
||||
} catch (e) { /* leave the empty chart on screen */ }
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => { if (e.target.id === "chart-modal") closeChart(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeChart(); });
|
||||
$("chart-close").onclick = closeChart;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// init — default 10-session window
|
||||
// -------------------------------------------------------------------------
|
||||
runBacktest({ days: 10 }, document.querySelector('.picker button[data-days="10"]'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -471,6 +471,7 @@
|
||||
<button id="sound-toggle" class="hdr-btn" title="Toggle signal alert sound">SOUND OFF</button>
|
||||
<button id="kbd-toggle" class="hdr-btn" title="Keyboard shortcuts (?)">KEYS</button>
|
||||
<button id="panels-toggle" class="hdr-btn" title="Expand or collapse every panel">EXPAND ALL</button>
|
||||
<a href="/backtest" class="hdr-btn" style="text-decoration:none;display:inline-flex;align-items:center" title="Theoretical replay: how the bot would have traded past sessions under current settings">BACKTEST</a>
|
||||
<div class="conn"><div id="conn-dot" class="dot"></div><span id="stamp" class="stamp">connecting...</span></div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -556,6 +556,143 @@ def _run_rank(con, exp: dict, new_fires: list[Fire], ranks: dict) -> int:
|
||||
return inserted
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backtest replay (plan 015) — assembly only, no new simulation math. Screens
|
||||
# + simulates the SAME pieces run() uses per-fire, just returned to a caller
|
||||
# (the /api/backtest route) instead of written to experiment_runs.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _session_bars(con, session_date: date, symbols: list[str]) -> dict:
|
||||
"""All minute bars for `symbols` on one session, grouped by symbol — ONE
|
||||
query per session instead of one per fire (copied from research.regime's
|
||||
_session_bars idiom: the per-fire round trip was the build-features
|
||||
bottleneck). Each per-symbol frame matches query_minute_bars' shape (same
|
||||
cols, timestamp-sorted, fresh RangeIndex) so _entry_index works unchanged."""
|
||||
if not symbols:
|
||||
return {}
|
||||
ph = ",".join(["?"] * len(symbols))
|
||||
df = con.execute(
|
||||
"SELECT symbol, timestamp, session_date, is_premarket, is_regular_hours, "
|
||||
"open, high, low, close, volume, vwap, quality_score FROM minute_bars "
|
||||
f"WHERE session_date = ? AND symbol IN ({ph}) ORDER BY symbol, timestamp",
|
||||
[session_date, *symbols]).df()
|
||||
if df is None or not len(df):
|
||||
return {}
|
||||
return {sym: g.reset_index(drop=True) for sym, g in df.groupby("symbol")}
|
||||
|
||||
|
||||
def _iso(ts) -> str:
|
||||
return ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
|
||||
|
||||
|
||||
def replay_window(store, since, until, *, population_cfg=None,
|
||||
regime_gate: bool = False, fidelity: dict | None = None) -> dict:
|
||||
"""Theoretical trades for [since, until] under the CURRENT live exit
|
||||
config. Reuses reconstruct_fires_from_setups + _fire_in_population +
|
||||
simulate_exit with the harness's adverse-fill accounting (the same R
|
||||
formula _sim_baseline_r uses, so numbers match the exit-experiment
|
||||
harness) — assembly only, no new simulation logic."""
|
||||
fidelity = fidelity or {"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1}
|
||||
until_d = _to_date(until)
|
||||
fires = [f for f in reconstruct_fires_from_setups(store, since=str(since))
|
||||
if f.session_date <= until_d]
|
||||
|
||||
screened_population = 0
|
||||
if population_cfg is not None:
|
||||
kept = []
|
||||
for f in fires:
|
||||
if _fire_in_population(store.con, population_cfg, f):
|
||||
kept.append(f)
|
||||
else:
|
||||
screened_population += 1
|
||||
fires = kept
|
||||
|
||||
screened_regime = 0
|
||||
if regime_gate:
|
||||
favorable = _load_regime_favorable(store.con, "meta_v1")
|
||||
kept = []
|
||||
for f in fires:
|
||||
if favorable.get(f.session_date) is False:
|
||||
screened_regime += 1
|
||||
else:
|
||||
kept.append(f)
|
||||
fires = kept
|
||||
|
||||
by_session: dict = defaultdict(list)
|
||||
for f in fires:
|
||||
by_session[f.session_date].append(f)
|
||||
|
||||
adv = _adverse(fidelity)
|
||||
cfg = _live_exit_cfg()
|
||||
trades: list[dict] = []
|
||||
skipped = 0
|
||||
daily_totals: dict = {}
|
||||
for sd in sorted(by_session):
|
||||
day_fires = by_session[sd]
|
||||
bars_by_sym = _session_bars(store.con, sd, sorted({f.symbol for f in day_fires}))
|
||||
day_r, day_n = 0.0, 0
|
||||
for f in day_fires:
|
||||
bars = bars_by_sym.get(f.symbol)
|
||||
risk = (f.entry_price - f.stop_price) if (
|
||||
f.entry_price is not None and f.stop_price is not None) else None
|
||||
if bars is None or not len(bars) or not risk or risk <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
idx = _entry_index(bars, f.fill_ts)
|
||||
bars_after = bars.iloc[idx + 1:].reset_index(drop=True)
|
||||
if not len(bars_after):
|
||||
skipped += 1
|
||||
continue
|
||||
entry_fill = f.entry_price + adv
|
||||
res = simulate_exit(entry_fill, f.stop_price, bars_after, cfg)
|
||||
if not res.fills:
|
||||
skipped += 1
|
||||
continue
|
||||
r_multiple = sum(fl.frac * ((fl.price - adv) - entry_fill) / risk
|
||||
for fl in res.fills)
|
||||
exit_row = bars_after.iloc[res.exit_index]
|
||||
trades.append({
|
||||
"symbol": f.symbol,
|
||||
"session_date": str(sd),
|
||||
"entry_ts": _iso(f.fill_ts),
|
||||
"entry_price": f.entry_price,
|
||||
"entry_fill": entry_fill,
|
||||
"stop_price": f.stop_price,
|
||||
"exit_ts": _iso(exit_row["timestamp"]),
|
||||
"exit_reason": res.reason,
|
||||
"r_multiple": r_multiple,
|
||||
"fills": [{"frac": fl.frac, "price": fl.price, "reason": fl.reason}
|
||||
for fl in res.fills],
|
||||
})
|
||||
day_r += r_multiple
|
||||
day_n += 1
|
||||
if day_n:
|
||||
daily_totals[sd] = (day_n, day_r)
|
||||
|
||||
daily: list[dict] = []
|
||||
cum = 0.0
|
||||
for sd in sorted(daily_totals):
|
||||
n, sum_r = daily_totals[sd]
|
||||
cum += sum_r
|
||||
daily.append({"date": str(sd), "n": n, "sum_r": sum_r, "cum_r": cum})
|
||||
|
||||
n_total = len(trades)
|
||||
sum_r_total = sum(t["r_multiple"] for t in trades)
|
||||
summary = {
|
||||
"n": n_total,
|
||||
"sum_r": sum_r_total,
|
||||
"avg_r": (sum_r_total / n_total) if n_total else 0.0,
|
||||
"hit_rate": (sum(1 for t in trades if t["r_multiple"] > 0) / n_total) if n_total else 0.0,
|
||||
"sessions": len(daily),
|
||||
}
|
||||
return {
|
||||
"trades": trades,
|
||||
"daily": daily,
|
||||
"summary": summary,
|
||||
"skipped": skipped,
|
||||
"screened": {"population": screened_population, "regime": screened_regime},
|
||||
}
|
||||
|
||||
|
||||
def run(store, since: str | None = None) -> int:
|
||||
"""Replay every running experiment over fires not yet in experiment_runs.
|
||||
Idempotent (PK (experiment_id, fire_id) + ON CONFLICT DO NOTHING). Returns
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Backtest replay page (plan 015): replay_window assembly + /api/backtest
|
||||
route + cache. Fixtures modeled on test_chart_endpoint.py / test_regime.py's
|
||||
_seed_session idiom (direct DashboardState/handler-level calls — the urllib
|
||||
HTTP-fixture group in this directory hits a pre-existing local-sandbox
|
||||
failure unrelated to this feature)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import pytest
|
||||
|
||||
from api.main import DashboardHandler, DashboardState
|
||||
from research import regime
|
||||
from research.experiments.replay import replay_window
|
||||
from storage.event_store import EventStore
|
||||
from strategy.population import PopulationConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_pg_schema(monkeypatch):
|
||||
"""Pin every EventStore in this file to one throwaway schema (mirrors
|
||||
test_chart_endpoint.py) so seeded setups/bars never touch prod."""
|
||||
schema = f"mem_{uuid.uuid4().hex[:12]}"
|
||||
monkeypatch.setenv("MOMENTUM_PG_SCHEMA", schema)
|
||||
yield
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
conn = psycopg2.connect(url)
|
||||
conn.autocommit = True
|
||||
conn.cursor().execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
||||
conn.close()
|
||||
|
||||
|
||||
class CaptureHandler(DashboardHandler):
|
||||
"""Drives do_GET without a real socket (same pattern as test_chart_endpoint.py)."""
|
||||
|
||||
def __init__(self, state, path):
|
||||
self.state = state
|
||||
self.path = path
|
||||
self.rfile = io.BytesIO(b"")
|
||||
self.headers = {}
|
||||
self.captured = []
|
||||
|
||||
def _send_json(self, payload, status=200):
|
||||
self.captured.append(("json", status, payload))
|
||||
|
||||
|
||||
def _get(state, path):
|
||||
h = CaptureHandler(state, path)
|
||||
h.do_GET()
|
||||
return h.captured[-1]
|
||||
|
||||
|
||||
SESS1 = date(2026, 6, 10)
|
||||
SESS2 = date(2026, 6, 11)
|
||||
# a clean runner: rises steadily so simulate_exit resolves inside the session
|
||||
# (same bar path idiom test_replay_run.py uses for its setup-sourced fire)
|
||||
RUNNER_BARS = [(10 + i * 0.1, 10.2 + i * 0.1, 9.99 + i * 0.1, 10.1 + i * 0.1) for i in range(10)]
|
||||
|
||||
|
||||
def _seed_setup(store, symbol, sess, entry=10.0, stop=9.0):
|
||||
store.con.execute(
|
||||
"INSERT INTO setup_events (setup_id, symbol, session_date, setup_time, "
|
||||
"setup_name, setup_version, entry_reference_price, invalidation_price) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
[f"s-{symbol}-{sess}", symbol, sess,
|
||||
datetime(sess.year, sess.month, sess.day, 13, 35), "orb", "v1", entry, stop])
|
||||
|
||||
|
||||
def _seed_minutes(store, symbol, sess, rows, start_min=36):
|
||||
for i, (o, h, low, c) in enumerate(rows):
|
||||
ts = datetime(sess.year, sess.month, sess.day, 13, start_min + i)
|
||||
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, sess, False, True, o, h, low, c, 1000, c, 1.0])
|
||||
|
||||
|
||||
def _seed_store():
|
||||
"""2 sessions x 2 setups (AAA, BBB), each with a runner bar path."""
|
||||
store = EventStore("momentum")
|
||||
for sess in (SESS1, SESS2):
|
||||
for sym in ("AAA", "BBB"):
|
||||
_seed_setup(store, sym, sess)
|
||||
_seed_minutes(store, sym, sess, RUNNER_BARS)
|
||||
return store
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test plan case 1: replay_window over 2 sessions x 2 setups -> 4 trades
|
||||
# --------------------------------------------------------------------------
|
||||
def test_replay_window_two_sessions_two_setups():
|
||||
store = _seed_store()
|
||||
try:
|
||||
out = replay_window(store, str(SESS1), str(SESS2))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
assert len(out["trades"]) == 4
|
||||
for t in out["trades"]:
|
||||
assert t["session_date"] in (str(SESS1), str(SESS2))
|
||||
# exit_ts is inside the session it was simulated on
|
||||
assert t["exit_ts"].startswith(t["session_date"])
|
||||
assert out["skipped"] == 0
|
||||
assert out["screened"] == {"population": 0, "regime": 0}
|
||||
|
||||
assert len(out["daily"]) == 2
|
||||
d0, d1 = out["daily"]
|
||||
assert d0["date"] == str(SESS1) and d1["date"] == str(SESS2)
|
||||
assert d0["n"] == 2 and d1["n"] == 2
|
||||
# cumulative is consistent with per-day sums
|
||||
assert abs(d0["cum_r"] - d0["sum_r"]) < 1e-9
|
||||
assert abs(d1["cum_r"] - (d0["sum_r"] + d1["sum_r"])) < 1e-9
|
||||
assert out["summary"]["n"] == 4
|
||||
assert abs(out["summary"]["sum_r"] - d1["cum_r"]) < 1e-9
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test plan case 2: population screen excludes an out-of-band setup
|
||||
# --------------------------------------------------------------------------
|
||||
def test_replay_window_population_screen_excludes_out_of_band():
|
||||
store = _seed_store()
|
||||
try:
|
||||
# BBB priced outside the default [2,20] band via daily_bars.open
|
||||
for sess in (date(2026, 6, 9), SESS1, SESS2):
|
||||
store.con.execute(
|
||||
"INSERT INTO daily_bars (symbol, trade_date, open, close, volume) "
|
||||
"VALUES (?,?,?,?,?)", ["BBB", sess, 25.0, 25.0, 500_000])
|
||||
out = replay_window(store, str(SESS1), str(SESS2), population_cfg=PopulationConfig())
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
symbols = {t["symbol"] for t in out["trades"]}
|
||||
assert "BBB" not in symbols # rejected: price outside $2-20 band
|
||||
assert "AAA" in symbols # no daily_bars row -> fail-open, kept
|
||||
assert out["screened"]["population"] == 2 # BBB on both SESS1 and SESS2
|
||||
assert len(out["trades"]) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test plan case 3: regime gate drops an unfavorable session, keeps unscored
|
||||
# --------------------------------------------------------------------------
|
||||
def test_replay_window_regime_gate_drops_unfavorable_session():
|
||||
store = _seed_store()
|
||||
try:
|
||||
regime.apply_schema(store.con)
|
||||
store.con.execute(
|
||||
"INSERT INTO regime_scores (model_version, session_date, p_profit, favorable, computed_at) "
|
||||
"VALUES (?,?,?,?,?)", ["meta_v1", SESS1, 0.1, False, datetime.now(timezone.utc)])
|
||||
# SESS2 has no regime_scores row -> fail-open, kept
|
||||
out = replay_window(store, str(SESS1), str(SESS2), regime_gate=True)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
dates = {t["session_date"] for t in out["trades"]}
|
||||
assert str(SESS1) not in dates
|
||||
assert str(SESS2) in dates
|
||||
assert out["screened"]["regime"] == 2 # AAA + BBB on SESS1
|
||||
assert len(out["trades"]) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test plan case 4: /api/backtest?days=9999 -> clamped
|
||||
# --------------------------------------------------------------------------
|
||||
def test_api_backtest_clamped_for_huge_days():
|
||||
state = DashboardState("momentum")
|
||||
kind, status, payload = _get(state, "/api/backtest?days=9999")
|
||||
assert kind == "json" and status == 200
|
||||
assert payload["clamped"] is True
|
||||
assert payload["sessions_requested"] == 45
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test plan case 5: cache — two identical calls compute the replay once
|
||||
# --------------------------------------------------------------------------
|
||||
def test_api_backtest_cache_hits_second_call(monkeypatch):
|
||||
_seed_store().close()
|
||||
|
||||
calls = {"n": 0}
|
||||
import research.experiments.replay as replay_mod
|
||||
real = replay_mod.replay_window
|
||||
|
||||
def counting(*a, **kw):
|
||||
calls["n"] += 1
|
||||
return real(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(replay_mod, "replay_window", counting)
|
||||
|
||||
state = DashboardState("momentum")
|
||||
kind1, status1, payload1 = _get(state, "/api/backtest?days=10")
|
||||
kind2, status2, payload2 = _get(state, "/api/backtest?days=10")
|
||||
|
||||
assert status1 == 200 and status2 == 200
|
||||
assert calls["n"] == 1
|
||||
assert payload1["trades"] and payload1["trades"] == payload2["trades"]
|
||||
assert payload1["settings"] == payload2["settings"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Route wiring: GET /backtest serves the page
|
||||
# --------------------------------------------------------------------------
|
||||
def test_backtest_route_serves_html():
|
||||
kind, status, body = None, None, None
|
||||
|
||||
class _H(DashboardHandler):
|
||||
def __init__(self, state, path):
|
||||
self.state = state
|
||||
self.path = path
|
||||
self.rfile = io.BytesIO(b"")
|
||||
self.headers = {}
|
||||
self.sent = []
|
||||
|
||||
def _send_html(self, path):
|
||||
self.sent.append(("html", path))
|
||||
|
||||
h = _H(DashboardState("momentum"), "/backtest")
|
||||
h.do_GET()
|
||||
assert h.sent and h.sent[0][0] == "html"
|
||||
assert h.sent[0][1].name == "backtest.html"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
pytest.main([__file__, "-q"])
|
||||
Reference in new issue
Block a user