diff --git a/dashboard/backend/api/routers/admin_traces.py b/dashboard/backend/api/routers/admin_traces.py
index 3c94ad2f..84f072d9 100644
--- a/dashboard/backend/api/routers/admin_traces.py
+++ b/dashboard/backend/api/routers/admin_traces.py
@@ -127,6 +127,57 @@ def finite(value):
}
+@router.get("/{trace_id}/chart-data")
+def get_trace_chart_data(trace_id: str):
+ """The Backtest panel's chart payload for this trace's exact run.
+
+ Same builder, market-hour filter and benchmarks as the owner-scoped
+ ``/api/backtest/{run_id}/chart-data``; only the lookup differs, by run ID
+ across owners, because an administrator reads every user's runs. A trace
+ whose run has no stored equity answers ``available: False`` rather than
+ 404, so the panel can still show its decision snapshots.
+ """
+ from dashboard.backend.api.routers import backtests as bt
+ from dashboard.backend.database import db
+ from dashboard.backend.equity_plot import build_backtest_chart_data
+
+ trace = trace_store.get_trace(trace_id)
+ if trace is None:
+ raise HTTPException(status_code=404, detail="Trace not found")
+ run_id = trace.get("run_id")
+ run = db.get_run(run_id) if run_id else None
+ if run is None:
+ return {"available": False, "run_id": run_id}
+ agent_curve = bt._filter_equity_for_run(run, db.get_equity_curve(run_id))
+ if not agent_curve:
+ return {"available": False, "run_id": run_id}
+ profile = bt._market_profile_for_run(run)
+ try:
+ payload = build_backtest_chart_data(
+ run_id=run_id,
+ agent_name=run.get("agent_name") or "Agent",
+ llm_model=run.get("llm_model"),
+ start_date=run.get("start_date") or "",
+ end_date=run.get("end_date") or "",
+ initial_capital=bt._run_initial_capital(run, agent_curve[0].get("equity")),
+ agent_curve=agent_curve,
+ stored_baselines=bt._stored_buyhold_baseline(run),
+ include_market_indexes=profile.index_baseline_enabled,
+ market_timezone=profile.timezone,
+ )
+ except ValueError:
+ return {"available": False, "run_id": run_id}
+ # The comparison model reads these to pick its columns (A-share runs have
+ # no US index benchmarks) and to match the stored buy-and-hold series.
+ metadata = run.get("metadata") if isinstance(run.get("metadata"), dict) else {}
+ payload["run"] = {
+ "data_source": metadata.get("data_source"),
+ "baseline_buyhold_run_id": run.get("baseline_buyhold_run_id"),
+ "reporting_currency": metadata.get("reporting_currency"),
+ }
+ return {"available": True, **payload}
+
+
@router.get("/{trace_id}/export")
def export_trace(trace_id: str, format: str = Query(default="json", pattern="^(json|markdown)$")):
from fastapi.responses import StreamingResponse
diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py
index 976a1be4..31669ad1 100644
--- a/dashboard/backend/tests/test_admin_analytics_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_frontend.py
@@ -165,7 +165,7 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert 'app.js?v=161' in APP_HTML
assert 'js/admin-tabs.js?v=12' in APP_HTML
for tag in (
- 'href="admin.css?v=13"',
+ 'href="admin.css?v=14"',
'src="js/admin-shell.js?v=8"',
'src="js/credit-format.js?v=1"',
'src="js/admin-live.js?v=1"',
diff --git a/dashboard/backend/tests/test_admin_page_modules.py b/dashboard/backend/tests/test_admin_page_modules.py
index d76ec6fb..212eaff2 100644
--- a/dashboard/backend/tests/test_admin_page_modules.py
+++ b/dashboard/backend/tests/test_admin_page_modules.py
@@ -68,7 +68,7 @@ def test_each_module_is_an_iife_exposing_exactly_one_global():
def test_every_module_admin_html_loads_exists_and_nothing_else_is_loaded():
srcs = re.findall(r'', ADMIN_HTML)
- assert set(srcs) == set(NAMES) | {"credit-format.js"} | {"admin-ticker.js"}
+ assert set(srcs) == set(NAMES) | {"credit-format.js"} | {"admin-ticker.js"} | {"backtest-comparison.js"}
for name in srcs:
assert (FRONTEND / "js" / name).exists(), name
diff --git a/dashboard/backend/tests/test_admin_page_shell.py b/dashboard/backend/tests/test_admin_page_shell.py
index 5d0bffa4..6a81bffb 100644
--- a/dashboard/backend/tests/test_admin_page_shell.py
+++ b/dashboard/backend/tests/test_admin_page_shell.py
@@ -27,9 +27,11 @@
"js/admin-providers.js?v=1",
# The absorbed credits console (design N2/PR2).
"js/admin-credits.js?v=4",
- "js/admin-trace-timeline.js?v=1",
- "js/admin-trace-performance.js?v=2",
- "js/admin-traces.js?v=3",
+ # The trace chart reuses the Backtest panel's comparison model.
+ "js/backtest-comparison.js?v=1",
+ "js/admin-trace-timeline.js?v=2",
+ "js/admin-trace-performance.js?v=4",
+ "js/admin-traces.js?v=5",
]
# This guard's whole job is "no inline script anywhere in this page", so a
@@ -94,7 +96,7 @@ def test_gate_module_loads_first_and_every_script_is_pinned():
def test_stylesheet_is_admin_css_and_the_page_does_not_inherit_styles_css():
- assert '' in ADMIN_HTML
+ assert '' in ADMIN_HTML
assert "styles.css" not in ADMIN_HTML
assert "@import" not in ADMIN_CSS
assert "cdn.jsdelivr.net" not in ADMIN_HTML
diff --git a/dashboard/backend/tests/test_admin_trace_chart_data.py b/dashboard/backend/tests/test_admin_trace_chart_data.py
new file mode 100644
index 00000000..9de10d93
--- /dev/null
+++ b/dashboard/backend/tests/test_admin_trace_chart_data.py
@@ -0,0 +1,78 @@
+"""The admin trace chart reuses the Backtest panel's payload for the exact run."""
+
+import pytest
+from fastapi import HTTPException
+
+import dashboard.backend.api.routers.backtests as bt
+from dashboard.backend.api.routers import admin_traces as api
+from dashboard.backend.database import db
+
+
+def _curve():
+ return [
+ {"timestamp": "2026-05-04T14:30:00", "equity": 1_000.0},
+ {"timestamp": "2026-05-04T15:30:00", "equity": 1_010.0},
+ ]
+
+
+def _install(monkeypatch, *, run, curves):
+ monkeypatch.setattr(api.trace_store, "get_trace", lambda _: {"run_id": "agent-1", "status": "completed"})
+ calls = []
+
+ def get_run(run_id):
+ calls.append(run_id)
+ return run if run_id == "agent-1" else {"run_id": run_id, "agent_name": "buy-and-hold"}
+
+ monkeypatch.setattr(db, "get_run", get_run)
+ monkeypatch.setattr(db, "get_equity_curve", lambda run_id: curves.get(run_id, []))
+ monkeypatch.setattr(bt, "_filter_equity_for_run", lambda _run, curve: curve)
+
+ def fake_indexes(timestamps, *_args, **_kwargs):
+ return ([
+ ("DJIA index", "index:^DJI", [1_000.0, 1_005.0]),
+ ("Nasdaq-100", "index:^NDX", [1_000.0, 1_015.0]),
+ ], True)
+
+ monkeypatch.setattr("dashboard.backend.equity_plot.market_index_baselines_with_status", fake_indexes)
+ return calls
+
+
+def test_chart_data_has_the_backtest_series_for_the_exact_run(monkeypatch):
+ run = {
+ "run_id": "agent-1", "agent_name": "Agent", "start_date": "2026-05-04", "end_date": "2026-05-04",
+ "initial_equity": 1_000.0, "baseline_buyhold_run_id": "buyhold-1",
+ "metadata": {"data_source": "alpaca"},
+ }
+ calls = _install(monkeypatch, run=run, curves={"agent-1": _curve(), "buyhold-1": _curve()})
+ result = api.get_trace_chart_data("trace")
+ assert calls[0] == "agent-1"
+ assert result["available"] is True
+ assert result["agent_run_id"] == "agent-1"
+ assert [s["run_id"] for s in result["series"]] == ["agent-1", "buyhold-1", "index:^DJI", "index:^NDX"]
+ assert result["series"][0]["dashed"] is False
+ assert all(s["dashed"] for s in result["series"][1:])
+ assert result["run"] == {"data_source": "alpaca", "baseline_buyhold_run_id": "buyhold-1", "reporting_currency": None}
+
+
+def test_a_run_without_stored_equity_is_unavailable_not_an_error(monkeypatch):
+ _install(monkeypatch, run={"run_id": "agent-1", "metadata": {}}, curves={})
+ assert api.get_trace_chart_data("trace") == {"available": False, "run_id": "agent-1"}
+
+
+def test_a_trace_whose_run_is_gone_is_unavailable(monkeypatch):
+ monkeypatch.setattr(api.trace_store, "get_trace", lambda _: {"run_id": "gone", "status": "failed"})
+ monkeypatch.setattr(db, "get_run", lambda _: None)
+ assert api.get_trace_chart_data("trace") == {"available": False, "run_id": "gone"}
+
+
+def test_missing_trace_is_404(monkeypatch):
+ monkeypatch.setattr(api.trace_store, "get_trace", lambda _: None)
+ with pytest.raises(HTTPException) as exc:
+ api.get_trace_chart_data("missing")
+ assert exc.value.status_code == 404
+
+
+def test_chart_data_route_keeps_admin_dependency():
+ from dashboard.backend.api.auth import require_admin
+ route = next(r for r in api.router.routes if r.path.endswith("/chart-data"))
+ assert require_admin in [dependency.call for dependency in route.dependant.dependencies]
diff --git a/dashboard/backend/tests/test_admin_traces_frontend.py b/dashboard/backend/tests/test_admin_traces_frontend.py
index f671c431..7af20930 100644
--- a/dashboard/backend/tests/test_admin_traces_frontend.py
+++ b/dashboard/backend/tests/test_admin_traces_frontend.py
@@ -11,6 +11,7 @@
def _eval(expression: str) -> object:
return run_node(
SHELL,
+ source("backtest-comparison.js"),
source("admin-trace-performance.js"),
source("admin-trace-timeline.js"),
TRACES,
@@ -44,9 +45,9 @@ def test_timeline_keeps_order_and_serializes_payload_as_text():
" return {events: node.children.map((item) => item.textContent), pre: byTag(node, 'pre').map((item) => item.textContent)};"
"})()"
)
- assert "#1 Run Started" in result["events"][0]
+ assert "#1Run Started" in result["events"][0]
assert "" in result["pre"][0]
- assert "#2 Decision Recorded" in result["events"][1]
+ assert "#2Decision Recorded" in result["events"][1]
def test_empty_trace_states_are_explicit():
@@ -61,12 +62,31 @@ def test_performance_filters_invalid_snapshots_and_uses_market_time():
assert result['points'][0]['timestamp'] == '2026-05-04T10:00:00Z'
-def test_failed_performance_renders_partial_and_links_by_decision_id():
- result = _eval("(() => { const n = window.AdminTracePerformance.render({status:'failed'}, [{sequence_no:1,event_type:'decision_recorded',decision_id:'d',payload:{decision_at:'2026-05-04T10:00:00Z',state:{equity:100}}},{sequence_no:2,event_type:'decision_recorded',payload:{decision_at:'2026-05-04T11:00:00Z',state:{equity:90}}},{sequence_no:3,event_type:'execution_result',decision_id:'d',payload:{fills:[{side:'BUY',symbol:'AAPL'}]}}],null); return {text:n.textContent, circles:byTag(n,'circle').length, buttons:byTag(n,'button').map(b=>b.textContent)}; })()")
+def test_failed_performance_renders_partial_and_marks_trades_by_decision_id():
+ """Without the run's chart payload the curve falls back to decision
+ snapshots: one agent column, its metrics from those points, and a marker
+ only on the decision whose execution produced an order."""
+ result = _eval("(() => { const n = window.AdminTracePerformance.render({status:'failed'}, [{sequence_no:1,event_type:'decision_recorded',decision_id:'d',payload:{decision_at:'2026-05-04T10:00:00Z',state:{equity:100}}},{sequence_no:2,event_type:'decision_recorded',payload:{decision_at:'2026-05-04T11:00:00Z',state:{equity:90}}},{sequence_no:3,event_type:'execution_result',decision_id:'d',payload:{fills:[{side:'BUY',symbol:'AAPL'}]}}],null); return {text:n.textContent, circles:byTag(n,'circle').length, legend:byClass(n,'trace-legend-btn').map(b=>b.textContent), cells:byTag(n,'td').map(c=>c.textContent)}; })()")
assert 'Run failed' in result['text']
- assert 'Snapshot change: -10.00%' in result['text']
assert result['circles'] == 1
- assert result['buttons'][1] == 'Execution'
+ assert result['legend'] == ['Your Agent']
+ assert result['cells'] == ['$90', '-10.00%', '-10.00%']
+
+
+def test_chart_and_comparison_use_the_backtest_model_when_the_run_has_benchmarks():
+ result = _eval("(() => {"
+ " const chart = {available: true, agent_run_id: 'r', timestamps: ['2026-05-04T14:30:00', '2026-05-04T15:30:00', '2026-05-05T14:30:00'],"
+ " series: [{run_id: 'r', label: 'Agent', values: [1000, 1010, 1020], color: '#4FC3F7', dashed: false},"
+ " {run_id: 'index:^DJI', label: 'DJIA index', values: [1000, 1005, 1030], color: '#F5C04A', dashed: true}], run: {}};"
+ " const n = window.AdminTracePerformance.render({status:'completed'}, [], {points: []}, chart);"
+ " return {legend: byClass(n,'trace-legend-btn').map(b=>b.textContent), lines: byTag(n,'polyline').length,"
+ " best: byClass(n,'trace-best').length, deltas: byClass(n,'trace-delta').map(d=>d.textContent), head: byTag(n,'th').map(t=>t.textContent)};"
+ "})()")
+ assert result['legend'] == ['Your Agent', 'DJIA']
+ assert result['lines'] == 2
+ assert result['deltas'] == ['DJIA -1.00pp']
+ # Sharpe is the engine's to compute (2026-10-14 direction), never this table's.
+ assert 'Sharpe Ratio' not in result['head']
def test_groups_only_by_explicit_links_and_keeps_event_order():
@@ -113,7 +133,7 @@ def test_poll_reads_the_envelope_before_events_so_a_finished_run_keeps_its_tail(
assert result["calls"][0] == "/api/admin/traces/t"
assert result["calls"][1].startswith("/api/admin/traces/t/events?after_sequence=1")
assert result["calls"][2] == "/api/admin/traces/t/performance"
- assert "#3 Run Completed" in result["text"]
+ assert "#3Run Completed" in result["text"]
def test_poll_without_changes_keeps_the_rendered_tree():
@@ -157,38 +177,95 @@ def test_an_open_backtest_report_is_fetched_once_per_trace_status():
assert "Llm Model: deepseek-v4-pro" in result["text"]
-def test_trading_log_takes_each_fills_reason_from_its_decision():
- """Tape fills carry no decision_id and decisions record reasoning_summaries
- (a list, one per action), so the log links through the execution event."""
+_TWO_ORDERS = (
+ " const events = ["
+ " {sequence_no: 1, event_type: 'decision_recorded', decision_id: 'd1', payload: {decision_at: '2026-05-04T14:00:00Z',"
+ " actions: [{symbol: 'AAPL', action: 'BUY', shares: 10, reason: 'momentum'}, {symbol: 'MSFT', action: 'SELL', shares: 5, reason: 'trim'}],"
+ " reasoning_summaries: ['momentum', 'trim']}},"
+ " {sequence_no: 2, event_type: 'execution_result', decision_id: 'd1', payload: {fills: ["
+ " {timestamp: '2026-05-04T14:00:00Z', symbol: 'MSFT', side: 'SELL', shares: 5, price: 300}],"
+ " rejected: [{symbol: 'AAPL', side: 'BUY', requested_shares: 10, reason: 'insufficient_cash'}]}},"
+ " {sequence_no: 3, event_type: 'decision_recorded', decision_id: 'd2', payload: {decision_at: '2026-05-04T15:00:00Z',"
+ " actions: [{symbol: 'NVDA', action: 'BUY'}, {symbol: 'AMZN', action: 'BUY'}], reasoning_summaries: ['equal weight', 'equal weight']}},"
+ " {sequence_no: 4, event_type: 'execution_result', decision_id: 'd2', payload: {fills: ["
+ " {symbol: 'NVDA', side: 'BUY', shares: 1, price: 237.05}, {symbol: 'AMZN', side: 'BUY', shares: 1, price: 251.58}]}},"
+ " ];"
+)
+
+
+def test_trading_log_groups_orders_by_decision_with_each_reason_once():
+ """An order takes the reason of the action on its symbol and side; a reason
+ every order in the group shares prints once, above them."""
+ result = _eval(
+ "(() => {" + _TWO_ORDERS +
+ " const log = window.AdminTraces.renderTradingLog(events);"
+ " return {summary: byClass(log, 'trace-log-summary')[0].textContent,"
+ " groups: byClass(log, 'trace-log-group').map((g) => ({id: g.getAttribute('id'),"
+ " lines: byClass(g, 'trace-log-line').map((l) => l.textContent),"
+ " reasons: byClass(g, 'trace-log-reason').map((r) => r.textContent),"
+ " rejects: byClass(g, 'trace-log-reject').map((r) => r.textContent)}))};"
+ "})()"
+ )
+ assert result["summary"] == "4 orders · 3 filled · 1 rejected"
+ newest, oldest = result["groups"]
+ assert newest["id"] == "trace-orders-d2"
+ assert newest["lines"] == ["BUYNVDA1 × $237.05Filled", "BUYAMZN1 × $251.58Filled"]
+ assert newest["reasons"] == ["equal weight"]
+ assert oldest["lines"] == ["SELLMSFT5 × $300.00Filled", "BUYAAPL10 shRejected"]
+ assert oldest["reasons"] == ["trim", "momentum"]
+ assert oldest["rejects"] == ["Rejected: Insufficient Cash"]
+
+
+def test_timeline_rows_describe_the_process_and_never_the_orders():
+ """The Trading log owns symbols and reasoning; a trade row says how the step
+ ran, and its body links to the orders instead of repeating them."""
+ result = _eval(
+ "(() => {" + _TWO_ORDERS +
+ " let shown = null;"
+ " const list = window.AdminTraceTimeline.render(events, {}, {onShowOrders: (id) => { shown = id; }});"
+ " const rows = byClass(list, 'trace-row');"
+ " byClass(rows[0], 'trace-row-orders')[0].listeners.click[0]();"
+ " const visible = rows.map((row) => byClass(row, 'trace-row-summary')[0].textContent).join(' | ');"
+ " return {visible, shown, kinds: byClass(list, 'trace-kind').map((k) => k.textContent),"
+ " eventLines: byClass(list, 'trace-event-line').map((l) => l.textContent).join(' | ')};"
+ "})()"
+ )
+ assert result["kinds"] == ["Trade", "Trade"]
+ assert "2 orders (1 filled, 1 rejected)" in result["visible"]
+ for leaked in ("AAPL", "MSFT", "NVDA", "momentum", "equal weight"):
+ assert leaked not in result["visible"], leaked
+ assert leaked not in result["eventLines"], leaked
+ assert result["shown"] == "d1"
+
+
+def test_quiet_steps_fold_and_the_errors_filter_keeps_a_failed_trade():
result = _eval(
"(() => {"
- " const events = ["
- " {sequence_no: 1, event_type: 'decision_recorded', decision_id: 'd1', payload: {"
- " actions: [{symbol: 'AAPL', action: 'BUY', shares: 10, reason: 'momentum'}, {symbol: 'MSFT', action: 'SELL', shares: 5, reason: 'trim'}],"
- " reasoning_summaries: ['momentum', 'trim']}},"
- " {sequence_no: 2, event_type: 'execution_result', decision_id: 'd1', payload: {fills: ["
- " {timestamp: '2026-05-04T14:00:00Z', symbol: 'MSFT', side: 'SELL', shares: 5, price: 300},"
- " {timestamp: '2026-05-04T14:00:00Z', symbol: 'AAPL', side: 'BUY', shares: 10, price: 150}]}},"
- " ];"
- " const log = byClass(window.AdminTraces.renderTraceDetail({trace_id: 't', status: 'completed'}, events), 'trace-trading-log')[0];"
- " return byClass(log, 'trace-log-item').map((item) => ["
- " byClass(item, 'trace-log-order')[0].textContent, (byClass(item, 'trace-reason')[0] || {}).textContent]);"
+ " const hold = (n) => [{sequence_no: n, event_type: 'decision_recorded', decision_id: `h${n}`, payload: {decision_at: '2026-05-04T14:00:00Z'}},"
+ " {sequence_no: n + 1, event_type: 'execution_result', decision_id: `h${n}`, payload: {fills: []}}];"
+ " const events = [...hold(1), ...hold(3), ...hold(5),"
+ " {sequence_no: 7, event_type: 'tool_result', step_id: 's', decision_id: 't', payload: {tool_name: 'news_digest', outcome: 'error', error_code: 'provider_timeout'}},"
+ " {sequence_no: 8, event_type: 'decision_recorded', step_id: 's', decision_id: 't', payload: {decision_at: '2026-05-04T17:00:00Z'}},"
+ " {sequence_no: 9, event_type: 'execution_result', decision_id: 't', payload: {fills: [{side: 'SELL', symbol: 'X', shares: 1, price: 1}]}}];"
+ " const kinds = (list) => byClass(list, 'trace-kind').map((k) => k.textContent);"
+ " return {all: kinds(window.AdminTraceTimeline.render(events)), errors: kinds(window.AdminTraceTimeline.render(events, {}, {filter: 'errors'})),"
+ " flag: byClass(window.AdminTraceTimeline.render(events), 'trace-row-flag').map((f) => f.textContent)};"
"})()"
)
- # Newest first.
- assert result == [["BUY AAPL", "momentum"], ["SELL MSFT", "trim"]]
+ assert result["all"] == ["Hold", "Trade"]
+ assert result["errors"] == ["Trade"]
+ assert result["flag"] == ["news_digest failed"]
-def test_trading_log_shows_the_latest_fills_and_the_true_count():
+def test_back_follows_the_entry_point_and_escape_uses_it():
result = _eval(
"(() => {"
- " const fills = Array.from({length: 60}, (_, i) => ({symbol: `S${i}`, side: 'BUY', shares: 1, price: 1}));"
- " const log = byClass(window.AdminTraces.renderTraceDetail({trace_id: 't', status: 'running'}, [{sequence_no: 1, event_type: 'execution_result', payload: {fills}}]), 'trace-trading-log')[0];"
- " const items = byClass(log, 'trace-log-item');"
- " return {head: byClass(log, 'eyebrow')[0].textContent, count: items.length, first: byClass(items[0], 'trace-log-order')[0].textContent};"
+ " const href = (ctx) => byClass(window.AdminTraces.renderTraceDetail({trace_id: 't', status: 'completed'}, [], ctx), 'trace-back')[0];"
+ " const fromUser = href({user_id: 42});"
+ " return {list: href({}).getAttribute('href'), user: fromUser.getAttribute('href'), label: fromUser.textContent};"
"})()"
)
- assert result == {"head": "Latest 50 of 60 fills", "count": 50, "first": "BUY S59"}
+ assert result == {"list": "#traces", "user": "#users/42", "label": "← User analytics"}
def test_trace_detail_tolerates_missing_trace_or_events():
@@ -201,3 +278,61 @@ def test_trace_detail_tolerates_missing_trace_or_events():
)
for texts in result:
assert "No events recorded yet." in texts
+
+
+def test_chart_ranges_count_trading_days_not_bars():
+ """A 5-minute run has 78 bars a session: 1D must still be one session and
+ 1W five, rather than the last 7 or 35 bars of an hourly assumption."""
+ result = _eval(
+ "(() => {"
+ " const days = ['2026-05-04', '2026-05-05', '2026-05-06', '2026-05-07', '2026-05-08', '2026-05-11'];"
+ " const ts = days.flatMap((d) => Array.from({length: 78}, (_, i) => `${d}T${String(13 + Math.floor((30 + i * 5) / 60)).padStart(2, '0')}:${String((30 + i * 5) % 60).padStart(2, '0')}:00+00:00`));"
+ " const start = (range) => window.AdminTracePerformance.rangeStart(ts, range);"
+ " const day = (i) => ts[i].slice(0, 10);"
+ " return {total: ts.length, d1: [start('1D'), day(start('1D'))], w1: [start('1W'), day(start('1W'))], all: start('ALL')};"
+ "})()"
+ )
+ assert result["total"] == 468
+ assert result["d1"] == [390, "2026-05-11"]
+ assert result["w1"] == [78, "2026-05-05"]
+ assert result["all"] == 0
+
+
+def test_a_stale_poll_does_not_write_its_chart_into_the_next_trace():
+ """Trace t finishes while the admin opens trace u. t's poll awaits its own
+ chart read; when that resolves after u loaded, u must keep its chart."""
+ result = _eval(
+ "(async () => {"
+ " const settle = () => new Promise((resolve) => setTimeout(resolve, 0));"
+ " const host = register('tracesView', document.createElement('div'));"
+ " let release; const gate = new Promise((resolve) => { release = resolve; });"
+ " let tStatus = 'running';"
+ " const ok = (body) => ({ok: true, status: 200, json: async () => body});"
+ " globalThis.fetch = async (url) => {"
+ " fetchCalls.push([url]);"
+ " if (url === '/api/admin/traces/t') return ok({trace_id: 't', run_id: 'r-t', status: tStatus});"
+ " if (url === '/api/admin/traces/u') return ok({trace_id: 'u', run_id: 'r-u', status: 'completed'});"
+ " if (url.includes('/events?')) return ok({items: [], has_more: false});"
+ " if (url.endsWith('/performance')) return ok({points: [], point_count: 0, metrics: {}});"
+ " if (url === '/api/admin/traces/u/chart-data') return ok({available: true, agent_run_id: 'r-u', timestamps: [], series: []});"
+ " if (url === '/api/admin/traces/t/chart-data') {"
+ " if (tStatus === 'running') return ok({available: false, run_id: 'r-t'});"
+ " await gate; return ok({available: true, agent_run_id: 'r-t', timestamps: [], series: []});"
+ " }"
+ " return ok({});"
+ " };"
+ " document.dispatchEvent(new CustomEvent('admin:route', {detail: {route: 'traces', id: 't'}}));"
+ " for (let i = 0; i < 10; i += 1) await settle();"
+ " window.AdminTraces.stopPolling();"
+ " tStatus = 'completed';"
+ " const polling = window.AdminTraces.pollEvents();"
+ " for (let i = 0; i < 10; i += 1) await settle();"
+ " document.dispatchEvent(new CustomEvent('admin:route', {detail: {route: 'traces', id: 'u'}}));"
+ " for (let i = 0; i < 10; i += 1) await settle();"
+ " release();"
+ " await polling;"
+ " window.AdminTraces.stopPolling();"
+ " return {chart: window.AdminTraces.currentChart()?.agent_run_id, askedT: fetchCalls.some(([u]) => u === '/api/admin/traces/t/chart-data')};"
+ "})()"
+ )
+ assert result == {"chart": "r-u", "askedT": True}
diff --git a/dashboard/backend/tests/test_app_composition.py b/dashboard/backend/tests/test_app_composition.py
index c804e530..53747a42 100644
--- a/dashboard/backend/tests/test_app_composition.py
+++ b/dashboard/backend/tests/test_app_composition.py
@@ -156,6 +156,7 @@
("GET", "/api/admin/traces/{trace_id}"),
("GET", "/api/admin/traces/{trace_id}/events"),
("GET", "/api/admin/traces/{trace_id}/performance"),
+ ("GET", "/api/admin/traces/{trace_id}/chart-data"),
("GET", "/api/admin/traces/{trace_id}/export"),
("GET", "/api/admin/traces/{trace_id}/backtest"),
("POST", "/api/admin/bootstrap"),
diff --git a/dashboard/frontend/admin.css b/dashboard/frontend/admin.css
index d6756015..8300578e 100644
--- a/dashboard/frontend/admin.css
+++ b/dashboard/frontend/admin.css
@@ -287,7 +287,6 @@ th:first-child,td:first-child{padding-left:0}
.trace-metrics{display:grid;grid-template-columns:repeat(4,1fr);margin:26px 0 8px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.trace-metrics .detail-metric{min-width:0}
.trace-section-title{margin:30px 0 12px;font-size:18px}
-.trace-timeline{display:grid;gap:12px;margin:0;padding:0;list-style:none}
.trace-event{padding:16px 18px;border:1px solid var(--line);border-radius:4px;background:var(--surface)}
.trace-event-payload{overflow:auto;margin:12px 0 0;padding:10px;background:var(--bg);color:var(--text);font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
@media(max-width:680px){.trace-metrics{grid-template-columns:repeat(2,1fr)}.trace-metrics .detail-metric{padding:14px 10px}}
@@ -494,9 +493,155 @@ dialog::backdrop{background:rgba(0,0,0,.64)}
.credits-chart-swatch.credits-chart-byok{background:var(--violet)}
.credits-chart-swatch.credits-chart-purchased{background:var(--green)}
-.trace-performance{margin:24px 0;padding:20px;border:1px solid var(--line);border-radius:8px}.trace-performance svg{width:100%;display:block;max-height:360px}.trace-performance-notice{color:var(--amber)}.trace-performance-links{display:flex;flex-wrap:wrap;gap:8px;max-height:180px;overflow:auto}.trace-event:focus{outline:2px solid var(--accent)}
-
-.trace-group{border:1px solid var(--line);border-radius:8px;margin:12px 0}.trace-group-summary{padding:16px;cursor:pointer}.trace-group-summary span{display:block;margin-top:6px}.trace-event-group{list-style:none}.trace-group .trace-event{margin:8px 16px}.trace-technical summary,.trace-run-overview summary{cursor:pointer;padding:12px 0}.trace-downloads{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:20px 0}.trace-reason{border-left:2px solid var(--accent);padding-left:12px}
-
-/* Trace analysis workspace: configuration, evidence, and executions stay aligned. */
-.trace-three-column{display:grid;grid-template-columns:220px minmax(0,1fr) 280px;gap:12px;align-items:start;margin-top:14px}.trace-context-rail,.trace-trading-log{position:sticky;top:16px;max-height:calc(100vh - 32px);overflow:auto;background:var(--bg-card);border:1px solid var(--border-color);border-radius:8px;padding:14px}.trace-analysis-column{min-width:0}.trace-context-rail .trace-run-overview,.trace-context-rail .trace-backtest-report{border:0;border-bottom:1px solid var(--border-color);border-radius:0;padding-bottom:10px;margin-bottom:10px}.trace-context-rail .trace-downloads{display:grid;gap:8px;margin:10px 0}.trace-context-rail .trace-downloads .muted{font-size:11px}.trace-log-head{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-color);padding-bottom:11px;margin-bottom:2px}.trace-log-head h2{font-size:16px;margin:0}.trace-log-item{padding:14px 0;border-bottom:1px solid var(--border-color)}.trace-log-item:last-child{border-bottom:0}.trace-log-order{font-weight:800;color:var(--green)}.trace-log-item p{margin:6px 0 0;font-size:12px}@media(max-width:900px){.trace-three-column{display:block}.trace-context-rail,.trace-trading-log{position:static;max-height:none;overflow:visible;margin-bottom:12px}}
+/* Trace detail, in the Backtest panel's shape: run config | chart, comparison
+ and timeline | trading log. The side columns and the timeline each scroll
+ inside one viewport, so no list pushes the page into a long scroll. */
+.trace-detail-panel{--trace-pane-h:calc(100vh - 32px)}
+.trace-topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:16px;margin:0 0 18px;padding:10px 0;background:rgba(10,14,39,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border-color)}
+.trace-topbar .breadcrumb{margin:0}
+.trace-back{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--text);font-size:13px;font-weight:600;text-decoration:none}
+.trace-back:hover{border-color:var(--accent);color:var(--accent)}
+.trace-back:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.trace-back-hint{margin-left:auto;color:var(--muted);font-size:11px}
+.trace-three-column{display:grid;grid-template-columns:220px minmax(0,1fr) 300px;gap:12px;align-items:start;margin-top:14px}
+.trace-analysis-column{min-width:0}
+.trace-context-rail,.trace-trading-log{position:sticky;top:64px;max-height:var(--trace-pane-h);background:var(--bg-card);border:1px solid var(--border-color);border-radius:8px}
+.trace-context-rail{overflow:auto;padding:14px}
+.trace-rail-title{margin:0 0 12px;font-size:13px;font-weight:700}
+.trace-config-list{margin:0}
+.trace-config-row{display:grid;grid-template-columns:76px minmax(0,1fr);gap:12px;padding:11px 0;border-top:1px solid var(--border-color)}
+.trace-config-row:first-child{padding-top:0;border-top:0}
+.trace-config-row dt{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
+.trace-config-row dd{margin:0;min-width:0;font-size:13px;font-weight:600;overflow-wrap:anywhere}
+.trace-config-secondary{display:block;margin-top:3px;color:var(--muted);font-size:11px;font-weight:500}
+.trace-config-details,.trace-context-rail .trace-backtest-report{border-top:1px solid var(--border-color)}
+.trace-config-details summary,.trace-backtest-report summary,.trace-technical summary{cursor:pointer;padding:11px 0;font-size:12px;font-weight:700;color:var(--muted)}
+.trace-config-details .trace-event-payload{margin:0 0 10px;max-height:200px}
+.trace-config-meta{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-top:1px solid var(--border-color);color:var(--muted);font-size:10px}
+.trace-config-meta strong{display:block;margin-top:2px;color:var(--text);font-size:10px;overflow-wrap:anywhere}
+.trace-downloads{display:grid;gap:8px;margin:4px 0 0}
+.trace-downloads .muted{font-size:11px}
+.trace-performance-notice{color:var(--amber)}
+.trace-event:focus{outline:2px solid var(--accent)}
+.trace-reason{border-left:2px solid var(--accent);padding-left:12px}
+/* Chart card: holds the comparison table, as on Backtest. */
+.trace-performance{margin:0 0 12px;padding:16px 16px 0;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-card);overflow:hidden}
+.trace-perf-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px}
+.trace-perf-head h2{margin:0 0 6px}
+.trace-perf-head p{margin:0;font-size:12px}
+.trace-range{display:flex;gap:6px}
+.trace-range .time-btn{padding:6px 10px;border:1px solid var(--border-color);border-radius:3px;background:transparent;color:var(--muted);font-size:11px;font-weight:700;cursor:pointer}
+.trace-range .time-btn.active{background:var(--accent);border-color:var(--accent);color:#fff}
+.trace-range .time-btn:focus-visible,.trace-legend-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.trace-legend{display:flex;justify-content:center;flex-wrap:wrap;gap:6px;margin-bottom:4px}
+.trace-legend-btn{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:4px 8px;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--text);font-size:11px;font-weight:700;cursor:pointer}
+.trace-legend-btn:hover{border-color:var(--border-color);background:var(--bg-hover)}
+.trace-legend-btn[aria-pressed="false"]{opacity:.5}
+.trace-swatch{display:inline-block;width:14px;height:3px;border-radius:2px;vertical-align:middle;margin-right:4px}
+.trace-legend-btn .trace-swatch{margin-right:0}
+.trace-swatch--dash{background-image:linear-gradient(90deg,transparent 50%,var(--bg-card) 50%);background-size:6px 3px}
+.trace-chart{width:100%;display:block;color:var(--muted)}
+.trace-marker{cursor:pointer}
+.trace-marker:focus-visible{outline:none;stroke:#fff;stroke-width:2.5}
+.trace-chart-hint{margin:4px 0 12px;color:var(--muted);font-size:11px}
+.trace-comparison{margin:0 -16px;border-top:1px solid var(--border-color);background:rgba(8,18,40,.28)}
+.trace-comparison-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:12px 16px 9px}
+.trace-comparison-head h3{margin:0;font-size:12px}
+.trace-comparison-head p{margin:0;color:var(--muted);font-size:10px}
+.trace-comparison-scroll{overflow-x:auto}
+.trace-comparison-scroll:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.trace-comparison table{width:100%;min-width:480px;border-collapse:collapse;table-layout:fixed;font-variant-numeric:tabular-nums}
+.trace-comparison th,.trace-comparison td{padding:9px 10px;border-top:1px solid var(--border-color);text-align:right;vertical-align:top}
+.trace-comparison thead th{color:var(--muted);font-size:10px;font-weight:700;white-space:nowrap}
+.trace-comparison th:first-child{width:104px;text-align:left;padding-left:16px}
+.trace-comparison tbody th{color:var(--muted);font-size:11px;font-weight:600}
+.trace-comparison td{font:11px var(--font-mono)}
+.trace-comparison [data-series-key="agent"]{background:rgba(0,191,255,.06)}
+.trace-best{display:block;margin-top:3px;color:var(--green);font:700 9px var(--font-base);letter-spacing:.04em;text-transform:uppercase}
+.trace-delta{display:block;margin-top:3px;font-size:10px}
+.trace-delta.is-up{color:var(--green)}
+.trace-delta.is-down{color:var(--red)}
+/* Timeline: process only, one 36px row per step, scrolling inside itself. */
+.trace-timeline-panel{display:flex;flex-direction:column;max-height:var(--trace-pane-h);border:1px solid var(--border-color);border-radius:8px;background:var(--bg-card)}
+.trace-timeline-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:10px 14px;border-bottom:1px solid var(--border-color)}
+.trace-timeline-head .trace-section-title{margin:0;font-size:16px}
+.trace-timeline-head .eyebrow{margin-left:auto;color:var(--muted);font-size:11px;letter-spacing:.04em;text-transform:uppercase}
+.trace-timeline-sub{margin:2px 0 0;color:var(--muted);font-size:11px}
+.trace-filter{display:inline-flex;flex:0 0 auto;border:1px solid var(--line);border-radius:6px;overflow:hidden}
+.trace-filter button{padding:4px 10px;border:0;background:transparent;color:var(--muted);font:600 12px var(--font-base);cursor:pointer}
+.trace-filter button+button{border-left:1px solid var(--line)}
+.trace-filter button[aria-pressed="true"]{background:rgba(0,191,255,.14);color:var(--accent)}
+.trace-filter button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.trace-timeline-host{overflow:auto;min-height:0}
+.trace-timeline{display:block;margin:0;padding:0;list-style:none}
+.trace-row{border-bottom:1px solid var(--border-color)}
+.trace-row:last-child{border-bottom:0}
+.trace-group{border:0;margin:0}
+.trace-row-summary{display:grid;grid-template-columns:104px 52px minmax(0,1fr) 28px;align-items:center;gap:10px;padding:8px 14px;cursor:pointer;list-style:none;font-size:13px}
+.trace-row-summary::-webkit-details-marker{display:none}
+.trace-row-summary:hover,.trace-group[open] .trace-row-summary{background:var(--bg-hover)}
+.trace-row-summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.trace-row-time{color:var(--muted);font:12px var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.trace-row-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.trace-row-count{color:var(--muted);font:11px var(--font-mono);text-align:right}
+.trace-row--hold .trace-row-text,.trace-row--setup .trace-row-text{color:var(--muted)}
+.trace-row-flag{color:var(--red);font-size:11px;font-weight:700}
+.trace-row-flag::before{content:"⚠ "}
+.trace-kind{justify-self:start;padding:1px 6px;border-radius:3px;font-size:11px;font-weight:700;background:#252b36;color:var(--muted)}
+.trace-kind--trade{color:var(--green);background:rgba(66,204,139,.12)}
+.trace-kind--error{color:var(--red);background:rgba(240,128,128,.14)}
+.trace-kind--run{color:var(--accent);background:rgba(0,191,255,.12)}
+.trace-row-body{display:grid;gap:6px;padding:4px 14px 12px 128px}
+.trace-row-body .trace-event{padding:8px 10px;border-radius:4px}
+.trace-event-line{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;font-size:13px}
+.trace-event-seq{color:var(--muted);font:11px var(--font-mono)}
+.trace-event-type{font-weight:600}
+.trace-event-status{padding:0 5px;border-radius:3px;background:#252b36;color:var(--muted);font-size:11px}
+.trace-event-status.is-failed{background:rgba(240,128,128,.14);color:var(--red)}
+.trace-row-orders{justify-self:start;padding:4px 8px;border:1px solid var(--line);border-radius:4px;background:transparent;color:var(--accent);font-size:12px;font-weight:600;cursor:pointer}
+.trace-row-orders:hover{border-color:var(--accent)}
+.trace-row-orders:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+/* Trading log: Backtest's header and filter; one block per decision. */
+.trace-trading-log{display:flex;flex-direction:column;overflow:hidden}
+.trace-log-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:10px;padding:14px;border-bottom:1px solid var(--border-color)}
+.trace-log-head>div{min-width:0}
+.trace-log-head h2{margin:0;font-size:16px}
+.trace-log-summary{margin:5px 0 0;color:var(--muted);font-size:10px}
+.trace-log-filter{flex:0 0 auto;max-width:130px;padding:6px 8px;border:1px solid var(--border-color);border-radius:4px;background:var(--bg);color:var(--text);font-size:12px}
+.trace-log-filter:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
+.trace-log-scroll{overflow:auto;min-height:0}
+.trace-log-scroll:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
+.trace-log-group{padding:11px 14px;border-bottom:1px solid var(--border-color)}
+.trace-log-group:last-child{border-bottom:0}
+.trace-log-when{padding:0;border:0;background:none;color:var(--accent);font:600 12px var(--font-mono);cursor:pointer}
+.trace-log-when:hover{text-decoration:underline}
+.trace-log-when:disabled{color:var(--muted);cursor:default;text-decoration:none}
+.trace-log-when:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.trace-log-reason{margin:6px 0;color:var(--muted);font-size:11px;line-height:1.4;overflow-wrap:anywhere}
+.trace-log-reason--order,.trace-log-reject{margin:0 0 6px 46px}
+.trace-log-reject{color:#fca5a5;font-size:11px}
+.trace-log-line{display:grid;grid-template-columns:38px minmax(0,1fr) auto 62px;align-items:center;gap:8px;padding:4px 0;font-size:12px}
+.trace-side-badge{padding:2px 0;border:1px solid;border-radius:3px;text-align:center;font-size:9px;font-weight:800}
+.trace-side-badge--buy{color:#6ee7a0;border-color:rgba(34,197,94,.35)}
+.trace-side-badge--sell{color:#fca5a5;border-color:rgba(239,68,68,.35)}
+.trace-sym{font-weight:700}
+.trace-log-amount{color:var(--muted);font:11px var(--font-mono);text-align:right}
+.trace-status{justify-self:end;padding:2px 5px;border:1px solid transparent;border-radius:3px;font-size:9px;font-weight:700;text-transform:uppercase}
+.trace-status--filled{color:#6ee7a0;background:rgba(34,197,94,.1);border-color:rgba(34,197,94,.22)}
+.trace-status--rejected{color:#fca5a5;background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.22)}
+.trace-log-line--rejected .trace-sym{color:var(--muted);text-decoration:line-through}
+.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+
+/* Cross-link highlight, both directions. */
+@keyframes trace-flash{0%{box-shadow:inset 3px 0 0 var(--accent);background:rgba(0,191,255,.16)}100%{box-shadow:inset 3px 0 0 transparent;background:transparent}}
+.is-flash{animation:trace-flash 1.6s ease-out}
+@media (prefers-reduced-motion:reduce){.is-flash{animation:none;box-shadow:inset 3px 0 0 var(--accent)}}
+
+@media(max-width:900px){
+ .trace-three-column{display:block}
+ .trace-context-rail,.trace-trading-log{position:static;max-height:none;overflow:visible;margin-bottom:12px}
+ .trace-timeline-panel{max-height:70vh}
+ .trace-row-summary{grid-template-columns:96px 52px minmax(0,1fr) 24px;gap:8px}
+ .trace-row-body{padding-left:14px}
+ .trace-back-hint{display:none}
+}
diff --git a/dashboard/frontend/admin.html b/dashboard/frontend/admin.html
index 25f4fae0..55ad9fd4 100644
--- a/dashboard/frontend/admin.html
+++ b/dashboard/frontend/admin.html
@@ -5,7 +5,7 @@
ATL Admin
-
+
@@ -22,9 +22,12 @@
-
-
-
+
+
+
+
+