feat: Add dashboard charts with interactivity and basic authentication support

This commit is contained in:
2026-06-01 12:28:02 +02:00
parent cde181f343
commit 24f2b2ed88
8 changed files with 323 additions and 26 deletions
+2
View File
@@ -25,3 +25,5 @@
- Added dashboard page plus HTMX metrics fragment and SSE metrics stream. - 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 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. - Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms.
- Added Alpine.js interactivity and a Chart.js opportunity trend panel to the dashboard.
- Added optional HTTP Basic authentication for dashboard routes, fragments, streams, and control endpoints.
+5 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState from arbitrade.api.control_state import DashboardControlState
from arbitrade.api.routes import router from arbitrade.api.routes import public_router, router
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.logging_setup import configure_logging from arbitrade.logging_setup import configure_logging
from arbitrade.metrics import MetricsCalculator from arbitrade.metrics import MetricsCalculator
@@ -13,6 +13,9 @@ from arbitrade.storage.db import DuckDBStore
def create_app(settings: Settings) -> FastAPI: def create_app(settings: Settings) -> FastAPI:
configure_logging(settings.log_level, settings.log_json) configure_logging(settings.log_level, settings.log_json)
if bool(settings.dashboard_auth_username) ^ bool(settings.dashboard_auth_password):
raise ValueError("dashboard auth requires both username and password")
db = DuckDBStore(settings) db = DuckDBStore(settings)
db.migrate() db.migrate()
@@ -23,5 +26,6 @@ def create_app(settings: Settings) -> FastAPI:
app.state.dashboard_controls = DashboardControlState( app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active, is_running=not settings.kill_switch_active,
) )
app.include_router(public_router)
app.include_router(router) app.include_router(router)
return app return app
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from secrets import compare_digest
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
dashboard_basic_auth = HTTPBasic(auto_error=False)
def require_dashboard_auth(
request: Request,
credentials: Annotated[HTTPBasicCredentials | None, Depends(dashboard_basic_auth)],
) -> None:
settings = request.app.state.settings
username = settings.dashboard_auth_username
password = settings.dashboard_auth_password
if username is None and password is None:
return
if username is None or password is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Dashboard auth misconfigured",
)
if (
credentials is None
or not compare_digest(credentials.username, username)
or not compare_digest(credentials.password, password)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": 'Basic realm="Arbitrade Dashboard"'},
)
+53 -8
View File
@@ -7,13 +7,16 @@ from pathlib import Path
from typing import cast from typing import cast
from urllib.parse import parse_qs from urllib.parse import parse_qs
from fastapi import APIRouter, Request import duckdb
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from arbitrade.api.auth import require_dashboard_auth
from arbitrade.api.control_state import DashboardControlState from arbitrade.api.control_state import DashboardControlState
router = APIRouter() router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
public_router = APIRouter()
templates = Jinja2Templates( templates = Jinja2Templates(
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates") directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
) )
@@ -52,7 +55,7 @@ def _dashboard_metrics(request: Request) -> dict[str, str]:
} }
def _table_columns(conn, table_name: str) -> set[str]: def _table_columns(conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]:
rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall() rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall()
return {str(row[1]) for row in rows} return {str(row[1]) for row in rows}
@@ -74,15 +77,13 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
ORDER BY snapshot_at DESC ORDER BY snapshot_at DESC
LIMIT 1 LIMIT 1
""").fetchone() """).fetchone()
open_trades = conn.execute( open_trades = conn.execute(f"""
f"""
SELECT {trade_ref_expr}, status, started_at, {cycle_expr} SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
FROM trades FROM trades
WHERE {open_trade_filter} WHERE {open_trade_filter}
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 5 LIMIT 5
""" """).fetchall()
).fetchall()
pnl_total_row = conn.execute(""" pnl_total_row = conn.execute("""
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
FROM trades FROM trades
@@ -133,6 +134,38 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
} }
def _dashboard_charts(request: Request) -> dict[str, object]:
store = request.app.state.store
with store.connect() as conn:
opportunity_rows = conn.execute("""
SELECT detected_at, cycle, net_pct, est_profit
FROM opportunities
ORDER BY detected_at DESC
LIMIT 10
""").fetchall()
chart_rows = list(reversed(opportunity_rows))
labels = [
row[0].isoformat() if isinstance(
row[0], datetime) else f"opportunity-{index + 1}"
for index, row in enumerate(chart_rows)
]
net_pct_values = [float(row[2]) if row[2]
is not None else 0.0 for row in chart_rows]
est_profit_values = [float(row[3]) if row[3]
is not None else 0.0 for row in chart_rows]
cycles = [str(row[1]) for row in chart_rows]
return {
"labels": labels,
"net_pct_values": net_pct_values,
"est_profit_values": est_profit_values,
"cycles": cycles,
"has_chart_data": bool(chart_rows),
"generated_at": datetime.now(UTC).isoformat(),
}
def _dashboard_controls_state(request: Request) -> DashboardControlState: def _dashboard_controls_state(request: Request) -> DashboardControlState:
return cast(DashboardControlState, request.app.state.dashboard_controls) return cast(DashboardControlState, request.app.state.dashboard_controls)
@@ -170,6 +203,7 @@ def _dashboard_controls(request: Request) -> dict[str, object]:
"stop_endpoint": "/dashboard/control/stop", "stop_endpoint": "/dashboard/control/stop",
"kill_switch_endpoint": "/dashboard/control/kill-switch", "kill_switch_endpoint": "/dashboard/control/kill-switch",
"config_endpoint": "/dashboard/control/config", "config_endpoint": "/dashboard/control/config",
"chart_endpoint": "/dashboard/fragment/charts",
} }
@@ -195,6 +229,7 @@ async def home(request: Request) -> HTMLResponse:
"metrics_endpoint": "/dashboard/fragment/metrics", "metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview", "overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls", "controls_endpoint": "/dashboard/fragment/controls",
"charts_endpoint": "/dashboard/fragment/charts",
"stream_endpoint": "/dashboard/stream/metrics", "stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview", "overview_stream_endpoint": "/dashboard/stream/overview",
}, },
@@ -212,6 +247,7 @@ async def dashboard(request: Request) -> HTMLResponse:
"metrics_endpoint": "/dashboard/fragment/metrics", "metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview", "overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls", "controls_endpoint": "/dashboard/fragment/controls",
"charts_endpoint": "/dashboard/fragment/charts",
"stream_endpoint": "/dashboard/stream/metrics", "stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview", "overview_stream_endpoint": "/dashboard/stream/overview",
}, },
@@ -245,6 +281,15 @@ async def dashboard_controls(request: Request) -> HTMLResponse:
) )
@router.get("/dashboard/fragment/charts", response_class=HTMLResponse)
async def dashboard_charts(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/charts.html",
context={"request": request, **_dashboard_charts(request)},
)
@router.post("/dashboard/control/start", response_class=HTMLResponse) @router.post("/dashboard/control/start", response_class=HTMLResponse)
async def dashboard_control_start(request: Request) -> HTMLResponse: async def dashboard_control_start(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request) controls = _dashboard_controls_state(request)
@@ -347,6 +392,6 @@ async def dashboard_overview_stream(request: Request) -> StreamingResponse:
return StreamingResponse(_event_stream(), media_type="text/event-stream") return StreamingResponse(_event_stream(), media_type="text/event-stream")
@router.get("/health", response_class=JSONResponse) @public_router.get("/health", response_class=JSONResponse)
async def health() -> JSONResponse: async def health() -> JSONResponse:
return JSONResponse({"status": "ok", "service": "arbitrade"}) return JSONResponse({"status": "ok", "service": "arbitrade"})
+43 -17
View File
@@ -22,38 +22,64 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", alias="LOG_LEVEL") log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_json: bool = Field(default=True, alias="LOG_JSON") log_json: bool = Field(default=True, alias="LOG_JSON")
duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH") dashboard_auth_username: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_USERNAME",
)
dashboard_auth_password: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_PASSWORD",
)
kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL") duckdb_path: Path = Field(default=Path(
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL") "./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
kraken_rest_url: str = Field(
default="https://api.kraken.com", alias="KRAKEN_REST_URL")
kraken_ws_url: str = Field(
default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
kraken_private_rate_limit_seconds: float = Field( kraken_private_rate_limit_seconds: float = Field(
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS" default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
) )
kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS") kraken_http_timeout_seconds: float = Field(
kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS") default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
kraken_retry_attempts: int = Field(
default=3, alias="KRAKEN_RETRY_ATTEMPTS")
kraken_retry_base_delay_seconds: float = Field( kraken_retry_base_delay_seconds: float = Field(
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS" default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
) )
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY") kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET") kraken_api_secret: str | None = Field(
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS") default=None, alias="KRAKEN_API_SECRET")
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS") ws_heartbeat_timeout_seconds: float = Field(
default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
ws_max_staleness_seconds: float = Field(
default=5.0, alias="WS_MAX_STALENESS_SECONDS")
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE") paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD") trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD") max_trade_capital_usd: float = Field(
max_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES") default=100.0, alias="MAX_TRADE_CAPITAL_USD")
max_concurrent_trades: int | None = Field(
default=None, alias="MAX_CONCURRENT_TRADES")
max_exposure_per_asset_usd: float | None = Field( max_exposure_per_asset_usd: float | None = Field(
default=None, default=None,
alias="MAX_EXPOSURE_PER_ASSET_USD", alias="MAX_EXPOSURE_PER_ASSET_USD",
) )
quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET") quote_balance_asset: str = Field(
min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD") default="USD", alias="QUOTE_BALANCE_ASSET")
min_order_size_usd: float | None = Field(
default=None, alias="MIN_ORDER_SIZE_USD")
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE") kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD") daily_loss_limit_usd: float | None = Field(
cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD") default=None, alias="DAILY_LOSS_LIMIT_USD")
max_source_latency_ms: float | None = Field(default=None, alias="MAX_SOURCE_LATENCY_MS") cumulative_loss_limit_usd: float | None = Field(
max_apply_latency_ms: float | None = Field(default=None, alias="MAX_APPLY_LATENCY_MS") default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
max_consecutive_failures: int | None = Field(default=None, alias="MAX_CONSECUTIVE_FAILURES") max_source_latency_ms: float | None = Field(
default=None, alias="MAX_SOURCE_LATENCY_MS")
max_apply_latency_ms: float | None = Field(
default=None, alias="MAX_APPLY_LATENCY_MS")
max_consecutive_failures: int | None = Field(
default=None, alias="MAX_CONSECUTIVE_FAILURES")
fernet_key: str | None = Field(default=None, alias="FERNET_KEY") fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
+33
View File
@@ -134,11 +134,15 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
overview = await client.get("/dashboard/fragment/overview") overview = await client.get("/dashboard/fragment/overview")
overview_stream = await client.get("/dashboard/stream/overview") overview_stream = await client.get("/dashboard/stream/overview")
controls = await client.get("/dashboard/fragment/controls") controls = await client.get("/dashboard/fragment/controls")
charts = await client.get("/dashboard/fragment/charts")
assert page.status_code == 200 assert page.status_code == 200
assert "EventSource" in page.text assert "EventSource" in page.text
assert "alpinejs" in page.text.lower()
assert "Chart.js" in page.text or "chart.umd.min.js" in page.text
assert 'hx-get="/dashboard/fragment/metrics"' in page.text assert 'hx-get="/dashboard/fragment/metrics"' in page.text
assert 'hx-get="/dashboard/fragment/controls"' in page.text assert 'hx-get="/dashboard/fragment/controls"' in page.text
assert 'hx-get="/dashboard/fragment/charts"' in page.text
assert fragment.status_code == 200 assert fragment.status_code == 200
assert "Realized P&L" in fragment.text assert "Realized P&L" in fragment.text
@@ -170,6 +174,11 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert "Paper trading mode" in controls.text assert "Paper trading mode" in controls.text
assert "Trade capital USD" in controls.text assert "Trade capital USD" in controls.text
assert charts.status_code == 200
assert "Opportunity Trend" in charts.text
assert "opportunity-chart" in charts.text
assert "Hide chart" in charts.text or "Show chart" in charts.text
async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None: async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb")) app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb"))
@@ -210,3 +219,27 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
assert app.state.settings.max_trade_capital_usd == 300.0 assert app.state.settings.max_trade_capital_usd == 300.0
assert app.state.settings.max_concurrent_trades == 4 assert app.state.settings.max_concurrent_trades == 4
assert app.state.settings.paper_trading_mode is True assert app.state.settings.paper_trading_mode is True
async def test_dashboard_requires_basic_auth_when_configured(tmp_path) -> None:
app = create_app(
Settings(
DUCKDB_PATH=tmp_path / "auth.duckdb",
DASHBOARD_AUTH_USERNAME="admin",
DASHBOARD_AUTH_PASSWORD="secret",
)
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
unauthenticated = await client.get("/dashboard/fragment/overview")
authenticated = await client.get(
"/dashboard/fragment/overview",
auth=("admin", "secret"),
)
health = await client.get("/health")
assert unauthenticated.status_code == 401
assert unauthenticated.headers["www-authenticate"] == 'Basic realm="Arbitrade Dashboard"'
assert authenticated.status_code == 200
assert health.status_code == 200
+112
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }}</title> <title>{{ title }}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script> <script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style> <style>
body { body {
margin: 0; margin: 0;
@@ -124,6 +126,17 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.chart-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-canvas {
width: 100%;
min-height: 260px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -176,7 +189,106 @@
{% include "partials/controls.html" %} {% include "partials/controls.html" %}
</section> </section>
<section
id="charts-shell"
hx-get="{{ charts_endpoint }}"
hx-target="this"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
{% include "partials/charts.html" %}
</section>
<script> <script>
window.arbitradeRenderCharts = (payload) => {
const chartHost = document.getElementById("opportunity-chart");
if (!chartHost || typeof Chart === "undefined") {
return;
}
const existing = Chart.getChart(chartHost);
if (existing) {
existing.destroy();
}
const data = JSON.parse(payload);
if (!data.has_chart_data) {
return;
}
new Chart(chartHost, {
type: "line",
data: {
labels: data.labels,
datasets: [
{
label: "Net %",
data: data.net_pct_values,
borderColor: "#2d6cdf",
backgroundColor: "rgba(45, 108, 223, 0.18)",
tension: 0.3,
fill: true,
yAxisID: "y",
},
{
label: "Est profit USD",
data: data.est_profit_values,
borderColor: "#52c41a",
backgroundColor: "rgba(82, 196, 26, 0.12)",
tension: 0.3,
fill: false,
yAxisID: "y1",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
plugins: {
legend: {
labels: {
color: "#e5eefb",
},
},
},
scales: {
x: {
ticks: {
color: "#9fb2d0",
maxRotation: 0,
autoSkip: true,
},
grid: {
color: "rgba(255, 255, 255, 0.06)",
},
},
y: {
position: "left",
ticks: {
color: "#9fb2d0",
},
grid: {
color: "rgba(255, 255, 255, 0.06)",
},
},
y1: {
position: "right",
ticks: {
color: "#9fb2d0",
},
grid: {
drawOnChartArea: false,
},
},
},
},
});
};
const stream = new EventSource("{{ stream_endpoint }}"); const stream = new EventSource("{{ stream_endpoint }}");
stream.addEventListener("metrics", (event) => { stream.addEventListener("metrics", (event) => {
const panel = document.getElementById("metrics-panel"); const panel = document.getElementById("metrics-panel");
+37
View File
@@ -0,0 +1,37 @@
<div
id="charts-panel"
class="panel"
style="margin-top: 16px"
x-data="{ expanded: true }"
>
<div class="chart-head">
<div>
<div class="label">Opportunity Trend</div>
<div class="meta">Recent opportunities from DuckDB. Updated {{ generated_at }}</div>
</div>
<button type="button" class="button secondary" x-on:click="expanded = !expanded">
<span x-text="expanded ? 'Hide chart' : 'Show chart'"></span>
</button>
</div>
<div x-show="expanded" x-transition style="margin-top: 16px">
<div class="card" style="padding: 12px">
{% if has_chart_data %}
<canvas id="opportunity-chart" class="chart-canvas"></canvas>
<script>
window.arbitradeRenderCharts(
{{ {
"has_chart_data": has_chart_data,
"labels": labels,
"net_pct_values": net_pct_values,
"est_profit_values": est_profit_values,
"cycles": cycles,
} | tojson }}
);
</script>
{% else %}
<div class="meta">No opportunity data yet.</div>
{% endif %}
</div>
</div>
</div>