From cde181f343aca7fffbc27922533d5e20636227e4 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Mon, 1 Jun 2026 12:20:28 +0200 Subject: [PATCH] feat: Enhance dashboard with live overview panel and control features --- CHANGELOG.md | 2 + src/arbitrade/api/app.py | 5 + src/arbitrade/api/control_state.py | 16 ++ src/arbitrade/api/routes.py | 243 +++++++++++++++++++++++++++ tests/test_dashboard.py | 104 +++++++++++- web/templates/dashboard.html | 72 ++++++++ web/templates/partials/controls.html | 105 ++++++++++++ web/templates/partials/overview.html | 67 ++++++++ 8 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 src/arbitrade/api/control_state.py create mode 100644 web/templates/partials/controls.html create mode 100644 web/templates/partials/overview.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce4e79..6de3080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,5 @@ - Added a mocked execution integration test that drives the triangular sequencer through the execution journal and DuckDB persistence. - Added a DuckDB-backed performance metrics calculator for realized P&L, win rate, trade duration, opportunities/min, fill rate, and latency percentiles. - Added dashboard page plus HTMX metrics fragment and SSE metrics stream. +- Added dashboard live overview panel for status, balances, open trades, realized P&L, and opportunity feed with HTMX refresh and SSE updates. +- Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms. diff --git a/src/arbitrade/api/app.py b/src/arbitrade/api/app.py index 2fffbca..a36d01e 100644 --- a/src/arbitrade/api/app.py +++ b/src/arbitrade/api/app.py @@ -2,6 +2,7 @@ from __future__ import annotations from fastapi import FastAPI +from arbitrade.api.control_state import DashboardControlState from arbitrade.api.routes import router from arbitrade.config.settings import Settings from arbitrade.logging_setup import configure_logging @@ -16,7 +17,11 @@ def create_app(settings: Settings) -> FastAPI: db.migrate() app = FastAPI(title="arbitrade", version="0.1.0") + app.state.settings = settings app.state.store = db app.state.metrics = MetricsCalculator(db) + app.state.dashboard_controls = DashboardControlState( + is_running=not settings.kill_switch_active, + ) app.include_router(router) return app diff --git a/src/arbitrade/api/control_state.py b/src/arbitrade/api/control_state.py new file mode 100644 index 0000000..9c6d875 --- /dev/null +++ b/src/arbitrade/api/control_state.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from arbitrade.risk.kill_switch import KillSwitch + + +@dataclass(slots=True) +class DashboardControlState: + is_running: bool = True + kill_switch: KillSwitch = field(default_factory=KillSwitch) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def mark_updated(self) -> None: + self.updated_at = datetime.now(UTC) diff --git a/src/arbitrade/api/routes.py b/src/arbitrade/api/routes.py index 29ab4c8..708199f 100644 --- a/src/arbitrade/api/routes.py +++ b/src/arbitrade/api/routes.py @@ -4,11 +4,15 @@ import json from collections.abc import AsyncIterator from datetime import UTC, datetime from pathlib import Path +from typing import cast +from urllib.parse import parse_qs from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.templating import Jinja2Templates +from arbitrade.api.control_state import DashboardControlState + router = APIRouter() templates = Jinja2Templates( directory=str(Path(__file__).resolve().parents[3] / "web" / "templates") @@ -48,6 +52,138 @@ def _dashboard_metrics(request: Request) -> dict[str, str]: } +def _table_columns(conn, table_name: str) -> set[str]: + rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall() + return {str(row[1]) for row in rows} + + +def _dashboard_overview(request: Request) -> dict[str, object]: + store = request.app.state.store + with store.connect() as conn: + trade_columns = _table_columns(conn, "trades") + trade_ref_expr = "trade_ref" if "trade_ref" in trade_columns else "CAST(id AS VARCHAR)" + cycle_expr = "cycle" if "cycle" in trade_columns else "NULL" + if "finished_at" in trade_columns: + open_trade_filter = "finished_at IS NULL" + else: + open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')" + + portfolio_row = conn.execute(""" + SELECT balances, total_value_usd + FROM portfolio_snapshots + ORDER BY snapshot_at DESC + LIMIT 1 + """).fetchone() + open_trades = conn.execute( + f""" + SELECT {trade_ref_expr}, status, started_at, {cycle_expr} + FROM trades + WHERE {open_trade_filter} + ORDER BY started_at DESC + LIMIT 5 + """ + ).fetchall() + pnl_total_row = conn.execute(""" + SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) + FROM trades + """).fetchone() + latest_opportunities = conn.execute(""" + SELECT cycle, net_pct, est_profit, detected_at + FROM opportunities + ORDER BY detected_at DESC + LIMIT 5 + """).fetchall() + + balances_value = "—" + total_value = "—" + if portfolio_row is not None: + balances_raw, total_value_raw = portfolio_row + balances_value = str(balances_raw) if balances_raw is not None else "—" + if total_value_raw is not None: + total_value = f"{float(total_value_raw):.2f} USD" + + open_trade_rows = [ + { + "trade_ref": str(row[0]), + "status": str(row[1]), + "started_at": row[2].isoformat() if isinstance(row[2], datetime) else "—", + "cycle": str(row[3]) if row[3] is not None else "—", + } + for row in open_trades + ] + opportunity_rows = [ + { + "cycle": str(row[0]), + "net_pct": f"{float(row[1]):.2f}%" if row[1] is not None else "—", + "est_profit": f"{float(row[2]):.2f} USD" if row[2] is not None else "—", + "detected_at": row[3].isoformat() if isinstance(row[3], datetime) else "—", + } + for row in latest_opportunities + ] + + return { + "status": "live", + "generated_at": datetime.now(UTC).isoformat(), + "balances": balances_value, + "total_value": total_value, + "open_trade_count": len(open_trade_rows), + "open_trades": open_trade_rows, + "realized_pnl_total": f"{float(pnl_total_row[0]):.2f} USD" if pnl_total_row else "—", + "opportunities": opportunity_rows, + } + + +def _dashboard_controls_state(request: Request) -> DashboardControlState: + return cast(DashboardControlState, request.app.state.dashboard_controls) + + +def _dashboard_controls(request: Request) -> dict[str, object]: + controls = _dashboard_controls_state(request) + settings = request.app.state.settings + return { + "execution_status": "running" if controls.is_running else "stopped", + "kill_switch_status": "active" if controls.kill_switch.is_active else "inactive", + "kill_switch_reason": controls.kill_switch.reason or "—", + "paper_trading_mode": "enabled" if settings.paper_trading_mode else "disabled", + "trade_capital_usd": f"{float(settings.trade_capital_usd):.2f} USD", + "trade_capital_usd_value": f"{float(settings.trade_capital_usd):.2f}", + "max_trade_capital_usd": ( + "—" + if settings.max_trade_capital_usd is None + else f"{float(settings.max_trade_capital_usd):.2f} USD" + ), + "max_trade_capital_usd_value": ( + "" + if settings.max_trade_capital_usd is None + else f"{float(settings.max_trade_capital_usd):.2f}" + ), + "max_concurrent_trades": ( + "—" if settings.max_concurrent_trades is None else str( + settings.max_concurrent_trades) + ), + "max_concurrent_trades_value": ( + "" if settings.max_concurrent_trades is None else str( + settings.max_concurrent_trades) + ), + "updated_at": controls.updated_at.isoformat(), + "start_endpoint": "/dashboard/control/start", + "stop_endpoint": "/dashboard/control/stop", + "kill_switch_endpoint": "/dashboard/control/kill-switch", + "config_endpoint": "/dashboard/control/config", + } + + +def _parse_form_body(body: bytes) -> dict[str, str]: + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + return {key: values[-1] for key, values in parsed.items() if values} + + +def _form_bool(value: str | None) -> bool: + if value is None: + return False + return value.lower() in {"1", "true", "yes", "on"} + + @router.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse( @@ -57,7 +193,10 @@ async def home(request: Request) -> HTMLResponse: "title": "Arbitrade Dashboard", "request": request, "metrics_endpoint": "/dashboard/fragment/metrics", + "overview_endpoint": "/dashboard/fragment/overview", + "controls_endpoint": "/dashboard/fragment/controls", "stream_endpoint": "/dashboard/stream/metrics", + "overview_stream_endpoint": "/dashboard/stream/overview", }, ) @@ -71,7 +210,10 @@ async def dashboard(request: Request) -> HTMLResponse: "title": "Arbitrade Dashboard", "request": request, "metrics_endpoint": "/dashboard/fragment/metrics", + "overview_endpoint": "/dashboard/fragment/overview", + "controls_endpoint": "/dashboard/fragment/controls", "stream_endpoint": "/dashboard/stream/metrics", + "overview_stream_endpoint": "/dashboard/stream/overview", }, ) @@ -85,6 +227,91 @@ async def dashboard_metrics(request: Request) -> HTMLResponse: ) +@router.get("/dashboard/fragment/overview", response_class=HTMLResponse) +async def dashboard_overview(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/overview.html", + context={"request": request, **_dashboard_overview(request)}, + ) + + +@router.get("/dashboard/fragment/controls", response_class=HTMLResponse) +async def dashboard_controls(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/start", response_class=HTMLResponse) +async def dashboard_control_start(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + controls.is_running = True + controls.mark_updated() + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/stop", response_class=HTMLResponse) +async def dashboard_control_stop(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + controls.is_running = False + controls.mark_updated() + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse) +async def dashboard_control_kill_switch(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + form = _parse_form_body(await request.body()) + reason = form.get("reason") or "manual" + controls.kill_switch.activate(reason=reason) + controls.is_running = False + controls.mark_updated() + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/config", response_class=HTMLResponse) +async def dashboard_control_config(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + settings = request.app.state.settings + form = _parse_form_body(await request.body()) + + if "trade_capital_usd" in form and form["trade_capital_usd"]: + settings.trade_capital_usd = float(form["trade_capital_usd"]) + if "max_trade_capital_usd" in form: + max_trade_capital_value = form["max_trade_capital_usd"].strip() + settings.max_trade_capital_usd = ( + float(max_trade_capital_value) if max_trade_capital_value else None + ) + if "max_concurrent_trades" in form: + max_concurrent_value = form["max_concurrent_trades"].strip() + settings.max_concurrent_trades = int( + max_concurrent_value) if max_concurrent_value else None + + settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode")) + controls.mark_updated() + + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + @router.get("/dashboard/stream/metrics") async def dashboard_metrics_stream(request: Request) -> StreamingResponse: fragment = ( @@ -104,6 +331,22 @@ async def dashboard_metrics_stream(request: Request) -> StreamingResponse: return StreamingResponse(_event_stream(), media_type="text/event-stream") +@router.get("/dashboard/stream/overview") +async def dashboard_overview_stream(request: Request) -> StreamingResponse: + fragment = ( + templates.get_template("partials/overview.html") + .render(request=request, **_dashboard_overview(request)) + .strip() + .replace("\n", "") + ) + + async def _event_stream() -> AsyncIterator[bytes]: + payload = json.dumps(fragment) + yield f"event: overview\ndata: {payload}\n\n".encode() + + return StreamingResponse(_event_stream(), media_type="text/event-stream") + + @router.get("/health", response_class=JSONResponse) async def health() -> JSONResponse: return JSONResponse({"status": "ok", "service": "arbitrade"}) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3b37c1f..090bcc5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -84,11 +84,46 @@ def _seed_metrics_data(app) -> None: started, ], ) + conn.execute( + """ + INSERT INTO portfolio_snapshots ( + snapshot_at, + balances, + total_value_usd + ) VALUES (?, ?, ?) + """, + [started, '{"USD": 1000.0, "BTC": 0.25}', 1250.0], + ) + conn.execute( + """ + INSERT INTO trades ( + trade_ref, + started_at, + finished_at, + status, + realized_pnl, + estimated_pnl, + capital_used, + cycle, + leg_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + "trade-open", + started, + None, + "open", + None, + 5.0, + 50.0, + "USD->BTC->ETH->USD", + 3, + ], + ) async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None: - app = create_app( - Settings(_env_file=None, DUCKDB_PATH=tmp_path / "dash.duckdb")) + app = create_app(Settings(DUCKDB_PATH=tmp_path / "dash.duckdb")) _seed_metrics_data(app) transport = httpx.ASGITransport(app=app) @@ -96,10 +131,14 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None: page = await client.get("/") fragment = await client.get("/dashboard/fragment/metrics") stream = await client.get("/dashboard/stream/metrics") + overview = await client.get("/dashboard/fragment/overview") + overview_stream = await client.get("/dashboard/stream/overview") + controls = await client.get("/dashboard/fragment/controls") assert page.status_code == 200 assert "EventSource" in page.text assert 'hx-get="/dashboard/fragment/metrics"' in page.text + assert 'hx-get="/dashboard/fragment/controls"' in page.text assert fragment.status_code == 200 assert "Realized P&L" in fragment.text @@ -110,3 +149,64 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None: assert stream.headers["content-type"].startswith("text/event-stream") assert "event: metrics" in stream.text assert "Realized P&L" in stream.text + + assert overview.status_code == 200 + assert "live" in overview.text + assert "Balances Snapshot" in overview.text + assert "Open Trades" in overview.text + assert "Opportunity Feed" in overview.text + assert "1250.00 USD" in overview.text + assert "trade-open" in overview.text + + assert overview_stream.status_code == 200 + assert overview_stream.headers["content-type"].startswith( + "text/event-stream") + assert "event: overview" in overview_stream.text + assert "trade-open" in overview_stream.text + + assert controls.status_code == 200 + assert "Runtime Status" in controls.text + assert ">running<" in controls.text + assert "Paper trading mode" in controls.text + assert "Trade capital USD" in controls.text + + +async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None: + app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb")) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + stop_response = await client.post("/dashboard/control/stop") + start_response = await client.post("/dashboard/control/start") + kill_response = await client.post( + "/dashboard/control/kill-switch", + data={"reason": "manual"}, + ) + config_response = await client.post( + "/dashboard/control/config", + data={ + "trade_capital_usd": "250.50", + "max_trade_capital_usd": "300.00", + "max_concurrent_trades": "4", + "paper_trading_mode": "on", + }, + ) + + assert stop_response.status_code == 200 + assert ">stopped<" in stop_response.text + + assert start_response.status_code == 200 + assert ">running<" in start_response.text + + assert kill_response.status_code == 200 + assert ">active<" in kill_response.text + assert "manual" in kill_response.text + + assert config_response.status_code == 200 + assert "250.50 USD" in config_response.text + assert "300.00 USD" in config_response.text + assert "4" in config_response.text + assert app.state.settings.trade_capital_usd == 250.5 + assert app.state.settings.max_trade_capital_usd == 300.0 + assert app.state.settings.max_concurrent_trades == 4 + assert app.state.settings.paper_trading_mode is True diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 8a0dcce..6b68429 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -68,19 +68,62 @@ gap: 12px; flex-wrap: wrap; } + .toolbar form { + margin: 0; + } .button { display: inline-flex; align-items: center; + justify-content: center; + border: 0; + cursor: pointer; padding: 10px 14px; border-radius: 999px; background: #2d6cdf; color: white; text-decoration: none; + font: inherit; } .button.secondary { background: transparent; border: 1px solid rgba(255, 255, 255, 0.14); } + .button.danger { + background: #ba3d4f; + } + .form-grid { + display: grid; + gap: 12px; + } + .field { + display: grid; + gap: 6px; + color: #9fb2d0; + font-size: 0.9rem; + } + .field input { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: #e5eefb; + font: inherit; + } + .field.checkbox { + display: flex; + align-items: center; + gap: 8px; + } + .field.checkbox input { + width: auto; + } + .control-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + } @@ -113,6 +156,26 @@ {% include "partials/metrics.html" %} +
+ {% include "partials/overview.html" %} +
+ +
+ {% include "partials/controls.html" %} +
+ diff --git a/web/templates/partials/controls.html b/web/templates/partials/controls.html new file mode 100644 index 0000000..897228a --- /dev/null +++ b/web/templates/partials/controls.html @@ -0,0 +1,105 @@ +
+
+
+
Runtime Status
+
{{ execution_status }}
+
Updated {{ updated_at }}
+
+
+
Kill Switch
+
{{ kill_switch_status }}
+
Reason {{ kill_switch_reason }}
+
+
+
Config Snapshot
+
Paper trading: {{ paper_trading_mode }}
+
Trade capital: {{ trade_capital_usd }}
+
Max trade capital: {{ max_trade_capital_usd }}
+
Max concurrent trades: {{ max_concurrent_trades }}
+
+
+ +
+
+
Execution Controls
+
+
+ +
+
+ +
+
+ + +
+
+
+
+
Edit Config
+
+ + + + + +
+
+
+
diff --git a/web/templates/partials/overview.html b/web/templates/partials/overview.html new file mode 100644 index 0000000..6787b51 --- /dev/null +++ b/web/templates/partials/overview.html @@ -0,0 +1,67 @@ +
+
+
+
Status
+
{{ status }}
+
+
+
Balances
+
{{ balances }}
+
+
+
Open Trades
+
{{ open_trade_count }}
+
+
+
Realized P&L
+
{{ realized_pnl_total }}
+
+
+ +
+
+
Open Trades
+
    + {% for trade in open_trades %} +
  • + {{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{ + trade.started_at }} +
  • + {% else %} +
  • No open trades.
  • + {% endfor %} +
+
+
+
Balances Snapshot
+
+ {{ balances }} +
+
Total value {{ total_value }}
+
+
+
Opportunity Feed
+
    + {% for opp in opportunities %} +
  • + {{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{ + opp.detected_at }} +
  • + {% else %} +
  • No opportunities.
  • + {% endfor %} +
+
+
+ +
Updated {{ generated_at }}
+