From 340e7e4561369d1e1c9a235f45c4a73da4ec5071 Mon Sep 17 00:00:00 2001 From: Haoxiang Cheng <2739441541@qq.com> Date: Fri, 9 Oct 2026 21:44:02 +0800 Subject: [PATCH 1/4] feat(admin): link trace equity charts to decisions --- dashboard/backend/api/routers/admin_traces.py | 40 ++++++++++ .../tests/test_admin_analytics_frontend.py | 2 +- .../backend/tests/test_admin_page_modules.py | 3 +- .../backend/tests/test_admin_page_shell.py | 5 +- .../tests/test_admin_trace_performance.py | 53 ++++++++++++ .../tests/test_admin_traces_frontend.py | 16 ++++ .../backend/tests/test_app_composition.py | 1 + dashboard/frontend/admin.css | 2 + dashboard/frontend/admin.html | 5 +- .../frontend/js/admin-trace-performance.js | 80 +++++++++++++++++++ dashboard/frontend/js/admin-traces.js | 40 +++++++--- .../2026-10-09-admin-trace-performance.md | 7 ++ 12 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 dashboard/backend/tests/test_admin_trace_performance.py create mode 100644 dashboard/frontend/js/admin-trace-performance.js create mode 100644 docs/superpowers/plans/2026-10-09-admin-trace-performance.md diff --git a/dashboard/backend/api/routers/admin_traces.py b/dashboard/backend/api/routers/admin_traces.py index 1a770aa3..7b8e9c5e 100644 --- a/dashboard/backend/api/routers/admin_traces.py +++ b/dashboard/backend/api/routers/admin_traces.py @@ -77,3 +77,43 @@ def list_trace_events( return trace_store.list_events( trace_id, after_sequence=after_sequence, limit=limit ) + + +@router.get("/{trace_id}/performance") +def get_trace_performance(trace_id: str): + """Resolve equity by the exact run ID, never by agent or nearest date.""" + import math + from dashboard.backend.database import db + + 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 + points = [] + if run: + for row in db.get_equity_curve(run_id): + equity = row.get("equity") + if isinstance(equity, (int, float)) and not isinstance(equity, bool) and math.isfinite(equity): + points.append({"timestamp": row["timestamp"], "equity": equity}) + # Metrics use the full series; drawing uses a bounded sample including endpoints. + peak = None + drawdown = 0.0 + for point in points: + peak = point["equity"] if peak is None else max(peak, point["equity"]) + if peak > 0: + drawdown = max(drawdown, (peak - point["equity"]) / peak) + count = len(points) + initial = (run or {}).get("initial_equity") + baseline = initial if isinstance(initial, (int, float)) and math.isfinite(initial) and initial >= 0 else (points[0]["equity"] if points else 0) + metrics = { + "return_pct": ((points[-1]["equity"] / baseline - 1) * 100) if points and baseline > 0 else None, + "max_drawdown_pct": drawdown * 100 if points else None, + } + if count > 1000: + points = [points[round(i * (count - 1) / 999)] for i in range(1000)] + return { + "run_id": run_id, "source": "run_equity" if points else "unavailable", + "points": points, "point_count": count, "sampled": count > 1000, + "metrics": metrics, "partial": trace.get("status") != "completed", + } diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py index de1d4a7d..81c97933 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=158' in APP_HTML assert 'js/admin-tabs.js?v=12' in APP_HTML for tag in ( - 'href="admin.css?v=11"', + 'href="admin.css?v=12"', '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 e715b6c2..e8a60aaf 100644 --- a/dashboard/backend/tests/test_admin_page_modules.py +++ b/dashboard/backend/tests/test_admin_page_modules.py @@ -24,7 +24,7 @@ # exact, not a subset, so adding a verb is a test failure rather than a silent # pass. Deleting the verb list and dropping api_key from the prohibited names # would have left two tests that run, pass, and protect nothing. -READ_MODULES = ("admin-shell.js", "admin-live.js", "admin-overview.js", "admin-users.js", "admin-traces.js") +READ_MODULES = ("admin-shell.js", "admin-live.js", "admin-overview.js", "admin-users.js", "admin-traces.js", "admin-trace-performance.js") WRITE_MODULES = { "admin-providers.js": {"PUT", "POST", "DELETE"}, # The absorbed credits console mutates groups, roles, grants and the pool. @@ -48,6 +48,7 @@ "admin-providers.js": "AdminProviders", "admin-credits.js": "AdminCredits", "admin-traces.js": "AdminTraces", + "admin-trace-performance.js": "AdminTracePerformance", } CREDENTIAL = re.compile(r"\w*(?:api_key|secret|credential|token|password)\w*", re.I) diff --git a/dashboard/backend/tests/test_admin_page_shell.py b/dashboard/backend/tests/test_admin_page_shell.py index bd144084..d82cb959 100644 --- a/dashboard/backend/tests/test_admin_page_shell.py +++ b/dashboard/backend/tests/test_admin_page_shell.py @@ -27,7 +27,8 @@ "js/admin-providers.js?v=1", # The absorbed credits console (design N2/PR2). "js/admin-credits.js?v=4", - "js/admin-traces.js?v=1", + "js/admin-trace-performance.js?v=1", + "js/admin-traces.js?v=2", ] # This guard's whole job is "no inline script anywhere in this page", so a @@ -92,7 +93,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_performance.py b/dashboard/backend/tests/test_admin_trace_performance.py new file mode 100644 index 00000000..afd4ae4f --- /dev/null +++ b/dashboard/backend/tests/test_admin_trace_performance.py @@ -0,0 +1,53 @@ +import pytest +from fastapi import HTTPException +from dashboard.backend.api.routers import admin_traces as api +from dashboard.backend.database import db + + +def test_exact_run_and_bounded_points(monkeypatch): + monkeypatch.setattr(api.trace_store, 'get_trace', lambda _: {'run_id': 'exact', 'status': 'failed'}) + calls = [] + def run(run_id): + calls.append(run_id) + return {'initial_equity': 100} + monkeypatch.setattr(db, 'get_run', run) + monkeypatch.setattr(db, 'get_equity_curve', lambda _: [{'timestamp': str(i), 'equity': 100 + i} for i in range(2001)]) + result = api.get_trace_performance('trace') + assert calls == ['exact'] + assert len(result['points']) == 1000 + assert result['points'][0]['equity'] == 100 + assert result['points'][-1]['equity'] == 2100 + assert result['partial'] and result['sampled'] + assert result['metrics']['return_pct'] == 2000 + + +def test_missing_result_is_not_a_flat_curve(monkeypatch): + monkeypatch.setattr(api.trace_store, 'get_trace', lambda _: {'run_id': 'gone', 'status': 'completed'}) + monkeypatch.setattr(db, 'get_run', lambda _: None) + result = api.get_trace_performance('trace') + assert result['source'] == 'unavailable' + assert result['points'] == [] + assert result['metrics']['return_pct'] is None + + +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_performance('missing') + assert exc.value.status_code == 404 + + +def test_invalid_equity_is_excluded_and_drawdown_uses_full_series(monkeypatch): + monkeypatch.setattr(api.trace_store, 'get_trace', lambda _: {'run_id': 'r', 'status': 'completed'}) + # A real metadata row is nonempty. + monkeypatch.setattr(db, 'get_run', lambda _: {'run_id': 'r'}) + monkeypatch.setattr(db, 'get_equity_curve', lambda _: [{'timestamp': str(i), 'equity': v} for i, v in enumerate([100, None, float('nan'), 80, 110])]) + result = api.get_trace_performance('t') + assert len(result['points']) == 3 + assert result['metrics']['max_drawdown_pct'] == 20 + + +def test_performance_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('/performance')) + 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 1a16c8d6..c71f7f71 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("admin-trace-performance.js"), TRACES, f"Promise.resolve({expression}).then((result) => console.log(JSON.stringify(result)));", ) @@ -50,3 +51,18 @@ def test_timeline_keeps_order_and_serializes_payload_as_text(): def test_empty_trace_states_are_explicit(): assert _eval("window.AdminTraces.renderTraceRows({items: []}).children[0].textContent") == "No traces yet." assert _eval("window.AdminTraces.renderEventTimeline([]).children[0].textContent") == "No events recorded yet." + + +def test_performance_filters_invalid_snapshots_and_uses_market_time(): + result = _eval("window.AdminTracePerformance.series([{event_type:'decision_recorded', occurred_at:'2026-10-09', payload:{decision_at:'2026-05-04T10:00:00Z',state:{equity:100}}}, {event_type:'decision_recorded',payload:{decision_at:'bad',state:{equity:99}}}], null)") + assert result['snapshots'] is True + assert len(result['points']) == 1 + 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)}; })()") + assert 'Run failed' in result['text'] + assert 'Snapshot change: -10.00%' in result['text'] + assert result['circles'] == 1 + assert result['buttons'][1] == 'Execution' diff --git a/dashboard/backend/tests/test_app_composition.py b/dashboard/backend/tests/test_app_composition.py index 4a75e345..8f64885f 100644 --- a/dashboard/backend/tests/test_app_composition.py +++ b/dashboard/backend/tests/test_app_composition.py @@ -155,6 +155,7 @@ ("GET", "/api/admin/traces"), ("GET", "/api/admin/traces/{trace_id}"), ("GET", "/api/admin/traces/{trace_id}/events"), + ("GET", "/api/admin/traces/{trace_id}/performance"), ("POST", "/api/admin/bootstrap"), ("POST", "/api/algo/chat"), ("GET", "/api/algo/defaults"), diff --git a/dashboard/frontend/admin.css b/dashboard/frontend/admin.css index 32ca8e9e..5faf3f3a 100644 --- a/dashboard/frontend/admin.css +++ b/dashboard/frontend/admin.css @@ -497,3 +497,5 @@ dialog::backdrop{background:rgba(0,0,0,.64)} .credits-chart-swatch.credits-chart-platform{background:var(--accent)} .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 #334155;border-radius:8px}.trace-performance svg{width:100%;display:block;max-height:360px}.trace-performance-notice{color:#fbbf24}.trace-performance-links{display:flex;flex-wrap:wrap;gap:8px;max-height:180px;overflow:auto}.trace-event:focus{outline:2px solid #38bdf8} diff --git a/dashboard/frontend/admin.html b/dashboard/frontend/admin.html index 66fa89b6..d84f2315 100644 --- a/dashboard/frontend/admin.html +++ b/dashboard/frontend/admin.html @@ -5,7 +5,7 @@