diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de3080..cff5699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,3 +25,5 @@ - 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. +- 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. diff --git a/src/arbitrade/api/app.py b/src/arbitrade/api/app.py index a36d01e..5b5fba8 100644 --- a/src/arbitrade/api/app.py +++ b/src/arbitrade/api/app.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import FastAPI 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.logging_setup import configure_logging from arbitrade.metrics import MetricsCalculator @@ -13,6 +13,9 @@ from arbitrade.storage.db import DuckDBStore def create_app(settings: Settings) -> FastAPI: 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.migrate() @@ -23,5 +26,6 @@ def create_app(settings: Settings) -> FastAPI: app.state.dashboard_controls = DashboardControlState( is_running=not settings.kill_switch_active, ) + app.include_router(public_router) app.include_router(router) return app diff --git a/src/arbitrade/api/auth.py b/src/arbitrade/api/auth.py new file mode 100644 index 0000000..450036a --- /dev/null +++ b/src/arbitrade/api/auth.py @@ -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"'}, + ) diff --git a/src/arbitrade/api/routes.py b/src/arbitrade/api/routes.py index 708199f..1303218 100644 --- a/src/arbitrade/api/routes.py +++ b/src/arbitrade/api/routes.py @@ -7,13 +7,16 @@ from pathlib import Path from typing import cast 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.templating import Jinja2Templates +from arbitrade.api.auth import require_dashboard_auth from arbitrade.api.control_state import DashboardControlState -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_dashboard_auth)]) +public_router = APIRouter() templates = Jinja2Templates( 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() 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 LIMIT 1 """).fetchone() - open_trades = conn.execute( - f""" + 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() + """).fetchall() pnl_total_row = conn.execute(""" SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) 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: 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", "kill_switch_endpoint": "/dashboard/control/kill-switch", "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", "overview_endpoint": "/dashboard/fragment/overview", "controls_endpoint": "/dashboard/fragment/controls", + "charts_endpoint": "/dashboard/fragment/charts", "stream_endpoint": "/dashboard/stream/metrics", "overview_stream_endpoint": "/dashboard/stream/overview", }, @@ -212,6 +247,7 @@ async def dashboard(request: Request) -> HTMLResponse: "metrics_endpoint": "/dashboard/fragment/metrics", "overview_endpoint": "/dashboard/fragment/overview", "controls_endpoint": "/dashboard/fragment/controls", + "charts_endpoint": "/dashboard/fragment/charts", "stream_endpoint": "/dashboard/stream/metrics", "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) async def dashboard_control_start(request: Request) -> HTMLResponse: 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") -@router.get("/health", response_class=JSONResponse) +@public_router.get("/health", response_class=JSONResponse) async def health() -> JSONResponse: return JSONResponse({"status": "ok", "service": "arbitrade"}) diff --git a/src/arbitrade/config/settings.py b/src/arbitrade/config/settings.py index 5bd2801..0bd450d 100644 --- a/src/arbitrade/config/settings.py +++ b/src/arbitrade/config/settings.py @@ -22,38 +22,64 @@ class Settings(BaseSettings): log_level: str = Field(default="INFO", alias="LOG_LEVEL") 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") - kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL") + duckdb_path: Path = Field(default=Path( + "./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( default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS" ) - kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS") - kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS") + kraken_http_timeout_seconds: float = Field( + 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( default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS" ) kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY") - kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET") - 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") + kraken_api_secret: str | None = Field( + default=None, alias="KRAKEN_API_SECRET") + 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") 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_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES") + max_trade_capital_usd: float = Field( + 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( default=None, alias="MAX_EXPOSURE_PER_ASSET_USD", ) - quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET") - min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD") + quote_balance_asset: str = Field( + 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") - daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD") - cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD") - 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") + daily_loss_limit_usd: float | None = Field( + default=None, alias="DAILY_LOSS_LIMIT_USD") + cumulative_loss_limit_usd: float | None = Field( + default=None, alias="CUMULATIVE_LOSS_LIMIT_USD") + 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") diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 090bcc5..337e7ac 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -134,11 +134,15 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None: overview = await client.get("/dashboard/fragment/overview") overview_stream = await client.get("/dashboard/stream/overview") controls = await client.get("/dashboard/fragment/controls") + charts = await client.get("/dashboard/fragment/charts") assert page.status_code == 200 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/controls"' in page.text + assert 'hx-get="/dashboard/fragment/charts"' in page.text assert fragment.status_code == 200 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 "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: 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_concurrent_trades == 4 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 diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 6b68429..d35a9ea 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -5,6 +5,8 @@ {{ title }} + + @@ -176,7 +189,106 @@ {% include "partials/controls.html" %} +
+ {% include "partials/charts.html" %} +
+ + {% else %} +
No opportunity data yet.
+ {% endif %} + + + \ No newline at end of file