diff --git a/api/main.py b/api/main.py
index b49cedd..24e3a1e 100644
--- a/api/main.py
+++ b/api/main.py
@@ -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:
diff --git a/api/static/backtest.html b/api/static/backtest.html
index 61b5427..8eb0dae 100644
--- a/api/static/backtest.html
+++ b/api/static/backtest.html
@@ -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 @@
Trades
- | Date | Symbol | Entry | Exit | Reason | R |
+ | Date | Symbol | Entry | Exit | Reason | R | Live P&L |
@@ -174,6 +174,7 @@
@@ -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 => '' + esc(c) + '').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) => '';
+ const sp = (g) => g ? fmtR(g.sum_r) + ' avg ' +
+ fmtR(g.avg_r) + '' : "—";
+ 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 + " / pop " +
- (screened ? screened.population : 0) + " · regime " + (screened ? screened.regime : 0) + "");
+ (screened ? screened.population : 0) + " · regime " + (screened ? screened.regime : 0) + "") +
+ 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 = '| No trades. |
'; return; }
+ if (!rows.length) { $("trades-body").innerHTML = '| No trades. |
'; return; }
$("trades-body").innerHTML = rows.map((t, i) => {
const idx = trades.indexOf(t);
const cls = t.r_multiple >= 0 ? "pos" : "neg";
+ let live = '—';
+ 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)
+ ? ' ≠' : '';
+ live = '' + (t.live_pnl >= 0 ? "+$" : "−$") +
+ Math.abs(t.live_pnl).toFixed(0) + '' + diverged;
+ }
return '' +
'| ' + esc(t.session_date) + ' | ' +
'' + esc(t.symbol) + ' | ' +
@@ -351,6 +369,7 @@ function renderTrades(trades, viewDate) {
'' + fmtTs(t.exit_ts) + ' @ ' + fmtPx(t.fills.length ? t.fills[t.fills.length - 1].price : null) + ' | ' +
'' + esc(t.exit_reason) + ' | ' +
'' + fmtR(t.r_multiple) + ' | ' +
+ '' + live + ' | ' +
'
';
}).join("");
}