feat: Enhance dashboard with live overview panel and control features

This commit is contained in:
2026-06-01 12:20:28 +02:00
parent 0c232b7aee
commit cde181f343
8 changed files with 612 additions and 2 deletions
+2
View File
@@ -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.
+5
View File
@@ -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
+16
View File
@@ -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)
+243
View File
@@ -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"})
+102 -2
View File
@@ -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
+72
View File
@@ -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;
}
</style>
</head>
<body>
@@ -113,6 +156,26 @@
{% include "partials/metrics.html" %}
</section>
<section
id="overview-shell"
hx-get="{{ overview_endpoint }}"
hx-target="this"
hx-trigger="load, every 10s"
hx-swap="outerHTML"
>
{% include "partials/overview.html" %}
</section>
<section
id="controls-shell"
hx-get="{{ controls_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/controls.html" %}
</section>
<script>
const stream = new EventSource("{{ stream_endpoint }}");
stream.addEventListener("metrics", (event) => {
@@ -121,6 +184,15 @@
panel.outerHTML = JSON.parse(event.data);
}
});
const overviewStream = new EventSource(
"{{ overview_stream_endpoint }}",
);
overviewStream.addEventListener("overview", (event) => {
const panel = document.getElementById("overview-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
</script>
</main>
</body>
+105
View File
@@ -0,0 +1,105 @@
<div id="controls-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Runtime Status</div>
<div class="value">{{ execution_status }}</div>
<div class="meta">Updated {{ updated_at }}</div>
</article>
<article class="card">
<div class="label">Kill Switch</div>
<div class="value">{{ kill_switch_status }}</div>
<div class="meta">Reason {{ kill_switch_reason }}</div>
</article>
<article class="card">
<div class="label">Config Snapshot</div>
<div class="meta">Paper trading: {{ paper_trading_mode }}</div>
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Execution Controls</div>
<div class="control-actions">
<form
hx-post="{{ start_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button">Start</button>
</form>
<form
hx-post="{{ stop_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button secondary">Stop</button>
</form>
<form
hx-post="{{ kill_switch_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="reason" value="manual" />
<button type="submit" class="button danger">
Trigger Kill Switch
</button>
</form>
</div>
</article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div>
</div>
+67
View File
@@ -0,0 +1,67 @@
<div id="overview-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Status</div>
<div class="value">{{ status }}</div>
</article>
<article class="card">
<div class="label">Balances</div>
<div class="value">{{ balances }}</div>
</article>
<article class="card">
<div class="label">Open Trades</div>
<div class="value">{{ open_trade_count }}</div>
</article>
<article class="card">
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl_total }}</div>
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Open Trades</div>
<ul>
{% for trade in open_trades %}
<li>
{{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{
trade.started_at }}
</li>
{% else %}
<li>No open trades.</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="label">Balances Snapshot</div>
<div
class="value"
style="font-size: 1rem; font-weight: 500; word-break: break-word"
>
{{ balances }}
</div>
<div class="meta">Total value {{ total_value }}</div>
</article>
<article class="card">
<div class="label">Opportunity Feed</div>
<ul>
{% for opp in opportunities %}
<li>
{{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{
opp.detected_at }}
</li>
{% else %}
<li>No opportunities.</li>
{% endfor %}
</ul>
</article>
</div>
<div class="meta">Updated {{ generated_at }}</div>
</div>