Merge branch 'worktree-agent-ae4d3f36e18c6e7a5'

This commit is contained in:
momentum dev committed 2026-07-17 09:05:47 -05:00
commit a4e7a6d534
5 files changed
+877 -5

No files matched your search

+111 -5
View File
@@ -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
+401
View File
@@ -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&amp;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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>
+1
View File
@@ -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>
+137
View File
@@ -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
+227
View File
@@ -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"])