/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>
435 lines
22 KiB
HTML
435 lines
22 KiB
HTML
<!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&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&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 ≠ per-trade reality (minute-bar granularity + adverse fills can flip individual rows, e.g. a live +$200 winner simming as a −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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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">≠</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>
|