Files
momentum-system/api/static/backtest.html
T
momentum devandClaude Fable 5 053c927cf7 backtest: explain the regime-gated empty window + toggle to view raw-criteria trades
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 <noreply@anthropic.com>
2026-07-17 22:40:03 -05:00

416 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="1">1d</button>
<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>
<label style="font: 600 11px var(--mono); color: var(--muted); display:flex; align-items:center; gap:5px; margin-left:8px">
<input type="checkbox" id="regime-toggle" checked> apply regime gate (as deployed)
</label>
</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));
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 {
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);
});
$("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;
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) {
const reg = ((state.data || {}).screened || {}).regime || 0;
host.innerHTML = '<span class="empty">' + (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).') + '</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>