Files
momentum-system/api/static/backtest.html
T
momentum devandClaude Fable 5 48d75816b6 feat: backtest page — live-taken vs not-taken split + per-trade live P&L column
/api/backtest now annotates each replay trade with the live loop's realized
P&L for the same (session, symbol) and returns a live_split summary (taken vs
not-taken n/sumR/avgR). Page grows two summary cells, a Live P&L column with
an amber ≠ marker when sim and live disagree on a trade's sign, and a footer
caveat: minute-bar granularity + adverse fills flip individual rows — trust
aggregates, not rows. Read-only annotation; cached replay result unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:58:24 -05:00

435 lines
22 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(8, 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><th>Live P&amp;L</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>per-trade sim &ne; per-trade reality (minute-bar granularity + adverse fills can flip individual rows, e.g. a live +$200 winner simming as a &minus;1R stop) — trust the aggregates and the live-taken/not-taken split, not single rows.</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, data.live_split);
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, split) {
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>';
const sp = (g) => g ? fmtR(g.sum_r) + ' <span style="font-size:10px;color:var(--muted)">avg ' +
fmtR(g.avg_r) + '</span>' : "—";
const taken = split && split.taken, not_taken = split && split.not_taken;
$("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>") +
cell("Live-taken (" + (taken ? taken.n : 0) + ")", sp(taken),
taken ? (taken.sum_r >= 0 ? "pos" : "neg") : "") +
cell("Not taken (" + (not_taken ? not_taken.n : 0) + ")", sp(not_taken),
not_taken ? (not_taken.sum_r >= 0 ? "pos" : "neg") : "");
}
// 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="7" 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";
let live = '<span style="color:var(--muted)">—</span>';
if (t.live_pnl != null) {
const lcls = t.live_pnl >= 0 ? "pos" : "neg";
// sign disagreement = the per-trade caveat made visible (minute-bar
// granularity + adverse fills); aggregates are the trustworthy view
const diverged = (t.live_pnl >= 0) !== (t.r_multiple >= 0)
? ' <span style="color:var(--amber)" title="sim and live disagree on this trade — minute-bar granularity + adverse fills; trust aggregates, not rows">&ne;</span>' : '';
live = '<span class="r ' + lcls + '">' + (t.live_pnl >= 0 ? "+$" : "−$") +
Math.abs(t.live_pnl).toFixed(0) + '</span>' + diverged;
}
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>' +
'<td>' + live + '</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>