From fbcd016e1a48bf2eee0eabe1e46af82d28c5601c Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 02:15:34 +0900 Subject: [PATCH] feat: improve dashboard UI with P&L chart and decisions log (#159) - Add /api/pnl/history endpoint to app.py for daily P&L history charting - Rewrite index.html as full SPA with Chart.js bar chart, summary cards, and decisions log table with market filter tabs and 30s auto-refresh - Add test_pnl_history_all_markets and test_pnl_history_market_filter tests Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/app.py | 44 ++++ src/dashboard/static/index.html | 398 +++++++++++++++++++++++++++++--- tests/test_dashboard.py | 20 ++ 3 files changed, 433 insertions(+), 29 deletions(-) diff --git a/src/dashboard/app.py b/src/dashboard/app.py index e25b584..b77ae93 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -259,6 +259,50 @@ def create_dashboard_app(db_path: str) -> FastAPI: ) return {"market": market, "count": len(decisions), "decisions": decisions} + @app.get("/api/pnl/history") + def get_pnl_history( + days: int = Query(default=30, ge=1, le=365), + market: str = Query("all"), + ) -> dict[str, Any]: + """Return daily P&L history for charting.""" + with _connect(db_path) as conn: + if market == "all": + rows = conn.execute( + """ + SELECT DATE(timestamp) AS date, + SUM(pnl) AS daily_pnl, + COUNT(*) AS trade_count + FROM trades + WHERE pnl IS NOT NULL + AND DATE(timestamp) >= DATE('now', ?) + GROUP BY DATE(timestamp) + ORDER BY DATE(timestamp) + """, + (f"-{days} days",), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT DATE(timestamp) AS date, + SUM(pnl) AS daily_pnl, + COUNT(*) AS trade_count + FROM trades + WHERE pnl IS NOT NULL + AND market = ? + AND DATE(timestamp) >= DATE('now', ?) + GROUP BY DATE(timestamp) + ORDER BY DATE(timestamp) + """, + (market, f"-{days} days"), + ).fetchall() + return { + "days": days, + "market": market, + "labels": [row["date"] for row in rows], + "pnl": [round(float(row["daily_pnl"]), 2) for row in rows], + "trades": [int(row["trade_count"]) for row in rows], + } + @app.get("/api/scenarios/active") def get_active_scenarios( market: str = Query("US"), diff --git a/src/dashboard/static/index.html b/src/dashboard/static/index.html index b46f0c8..04cd6ea 100644 --- a/src/dashboard/static/index.html +++ b/src/dashboard/static/index.html @@ -1,9 +1,10 @@ - + The Ouroboros Dashboard +
-
-

The Ouroboros Dashboard API

-

Use the following endpoints:

-
    -
  • /api/status
  • -
  • /api/playbook/{date}?market=KR
  • -
  • /api/scorecard/{date}?market=KR
  • -
  • /api/performance?market=all
  • -
  • /api/context/{layer}
  • -
  • /api/decisions?market=KR
  • -
  • /api/scenarios/active?market=US
  • -
+ +
+

🐍 The Ouroboros

+
+ -- + +
+
+ + +
+
+
오늘 거래
+
--
+
거래 건수
+
+
+
오늘 P&L
+
--
+
실현 손익
+
+
+
승률
+
--
+
전체 누적
+
+
+
누적 거래
+
--
+
전체 기간
+
+
+ + +
+
+ P&L 추이 +
+ + + +
+
+
+ + +
+
+ + +
+
+ 최근 결정 로그 +
+ + + + + +
+
+ + + + + + + + + + + + + +
시각종목액션신뢰도사유
+ + diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 8c18d4a..f9e5040 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -296,3 +296,23 @@ def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None: get_active_scenarios = _endpoint(app, "/api/scenarios/active") body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50) assert body["count"] == 0 + + +def test_pnl_history_all_markets(tmp_path: Path) -> None: + app = _app(tmp_path) + get_pnl_history = _endpoint(app, "/api/pnl/history") + body = get_pnl_history(days=30, market="all") + assert body["market"] == "all" + assert isinstance(body["labels"], list) + assert isinstance(body["pnl"], list) + assert len(body["labels"]) == len(body["pnl"]) + + +def test_pnl_history_market_filter(tmp_path: Path) -> None: + app = _app(tmp_path) + get_pnl_history = _endpoint(app, "/api/pnl/history") + body = get_pnl_history(days=30, market="KR") + assert body["market"] == "KR" + # KR has 1 trade with pnl=2.0 + assert len(body["labels"]) >= 1 + assert body["pnl"][0] == 2.0