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

- +
DateSymbolEntryExitReasonR
DateSymbolEntryExitReasonRLive 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) => '
' + label + '
' + val + '
'; + 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(""); }