feat: Add dashboard charts with interactivity and basic authentication support
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user