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>
This commit is contained in:
1 parent
6c6d0cd799
commit
48d75816b6
2 files changed
+57
-8
No files matched your search
+32
-2
@@ -603,7 +603,9 @@ class DashboardState:
|
||||
}
|
||||
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}}
|
||||
"screened": {"population": 0, "regime": 0},
|
||||
"live_split": {"taken": {"n": 0, "sum_r": 0.0, "avg_r": 0.0},
|
||||
"not_taken": {"n": 0, "sum_r": 0.0, "avg_r": 0.0}}}
|
||||
|
||||
store = self.open_store()
|
||||
try:
|
||||
@@ -644,11 +646,39 @@ class DashboardState:
|
||||
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())
|
||||
|
||||
# Live-taken vs skipped split (2026-07-25): which replay fires did the
|
||||
# live loop actually trade? Closes for past sessions are immutable, so
|
||||
# re-annotating the cached result on every request is idempotent.
|
||||
rows = store.con.execute(
|
||||
"SELECT timestamp, payload_json FROM events "
|
||||
"WHERE event_type = 'position_closed' AND timestamp >= ? AND timestamp < ?",
|
||||
[win_since,
|
||||
(date.fromisoformat(win_until) + timedelta(days=1)).isoformat()],
|
||||
).fetchall()
|
||||
live_pnl: dict = {}
|
||||
for ts, pj in rows:
|
||||
p = pj if isinstance(pj, dict) else json.loads(pj or "{}")
|
||||
sym = p.get("symbol")
|
||||
if sym and p.get("realized_pnl") is not None:
|
||||
k = (str(ts.date()), sym)
|
||||
live_pnl[k] = round(live_pnl.get(k, 0.0) + float(p["realized_pnl"]), 2)
|
||||
live_split = {"taken": {"n": 0, "sum_r": 0.0},
|
||||
"not_taken": {"n": 0, "sum_r": 0.0}}
|
||||
for t in result["trades"]:
|
||||
pnl = live_pnl.get((str(t["session_date"]), t["symbol"]))
|
||||
t["live_pnl"] = pnl
|
||||
grp = live_split["taken" if pnl is not None else "not_taken"]
|
||||
grp["n"] += 1
|
||||
grp["sum_r"] += t["r_multiple"]
|
||||
for grp in live_split.values():
|
||||
grp["sum_r"] = round(grp["sum_r"], 3)
|
||||
grp["avg_r"] = round(grp["sum_r"] / grp["n"], 3) if grp["n"] else 0.0
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return {**result, "settings": settings, "clamped": clamped,
|
||||
"sessions_requested": sessions_requested}
|
||||
"live_split": live_split, "sessions_requested": sessions_requested}
|
||||
|
||||
def _with_service(self, fn):
|
||||
if self.execution_service is None:
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
main { padding: 16px 20px 40px; }
|
||||
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1px; background: var(--border);
|
||||
.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; }
|
||||
@@ -165,7 +165,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -174,6 +174,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -250,7 +251,7 @@ function render(data) {
|
||||
$("results").classList.remove("hidden");
|
||||
$("footer").classList.remove("hidden");
|
||||
renderChips(data.settings, data.clamped);
|
||||
renderSummary(data.summary, data.skipped, data.screened);
|
||||
renderSummary(data.summary, data.skipped, data.screened, data.live_split);
|
||||
renderDaily(data.daily);
|
||||
renderTrades(data.trades, null);
|
||||
const y = data.sessions_requested;
|
||||
@@ -274,10 +275,13 @@ function renderChips(s, clamped) {
|
||||
$("chips").innerHTML = chips.map(c => '<span class="chip ' + (c.includes("ON") ? "on" : "") + '">' + esc(c) + '</span>').join("");
|
||||
}
|
||||
|
||||
function renderSummary(sum, skipped, screened) {
|
||||
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") +
|
||||
@@ -285,7 +289,11 @@ function renderSummary(sum, skipped, screened) {
|
||||
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>");
|
||||
(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
|
||||
@@ -340,10 +348,20 @@ function renderTrades(trades, viewDate) {
|
||||
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; }
|
||||
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>' +
|
||||
@@ -351,6 +369,7 @@ function renderTrades(trades, viewDate) {
|
||||
'<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("");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user