From 2817038ddce1f3c8ad612a40519505499412919f Mon Sep 17 00:00:00 2001 From: Haoxiang Cheng <2739441541@qq.com> Date: Fri, 16 Oct 2026 19:57:03 +0800 Subject: [PATCH 1/2] feat(admin): trace detail in the Backtest panel's shape The trace page now reads like the Backtest panel: run config on the left, the chart with its benchmarks and comparison table in the middle, the trading log on the right, and the timeline below the table. - New GET /api/admin/traces/{id}/chart-data: the same chart payload the Backtest panel uses (agent, DJIA, Nasdaq-100, stored Buy & Hold), looked up by the trace's exact run across owners. A run with no stored equity answers available: false, and the chart falls back to decision snapshots. - Chart and comparison reuse BacktestComparison for columns and Best. Sharpe stays out until the engine computes it (2026-10-14 direction). - Timeline shows the process only: one row per step, holds and setup folded, All / Errors filters, tool and failure status per event. Symbols and reasoning live in the trading log alone; a trade row links to its orders. - Trading log groups orders by decision, prints a shared reason once, lists rejected orders, and filters by side or rejection. - Two-way jumps with a highlight between timeline rows, log groups and chart markers; a sticky Back button that follows the entry point, and Esc. Co-Authored-By: Claude Opus 5.5 --- dashboard/backend/api/routers/admin_traces.py | 51 +++ .../tests/test_admin_analytics_frontend.py | 2 +- .../backend/tests/test_admin_page_modules.py | 2 +- .../backend/tests/test_admin_page_shell.py | 10 +- .../tests/test_admin_trace_chart_data.py | 78 +++++ .../tests/test_admin_traces_frontend.py | 135 +++++-- .../backend/tests/test_app_composition.py | 1 + dashboard/frontend/admin.css | 159 ++++++++- dashboard/frontend/admin.html | 11 +- .../frontend/js/admin-trace-performance.js | 253 +++++++++++--- dashboard/frontend/js/admin-trace-timeline.js | 203 ++++++++--- dashboard/frontend/js/admin-traces.js | 330 +++++++++++++++--- 12 files changed, 1037 insertions(+), 198 deletions(-) create mode 100644 dashboard/backend/tests/test_admin_trace_chart_data.py 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..ce57a879 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=3", + "js/admin-traces.js?v=4", ] # 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..31ea70cf 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(): 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..0c34c972 100644 --- a/dashboard/frontend/admin.html +++ b/dashboard/frontend/admin.html @@ -5,7 +5,7 @@ ATL Admin - + @@ -22,9 +22,12 @@ - - - + + + + + - - + +