Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s

- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities.
- Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity.
- Updated the Jinja2 template resolution logic to support different deployment environments.
- Added a health check template to display the service status.
- Included a test suite to verify the template resolution logic.
- Updated `pyproject.toml` to include new HTML templates in the package data.
This commit is contained in:
2026-06-02 14:16:42 +02:00
parent 38e1d64437
commit 1df4b11aef
80 changed files with 8604 additions and 3 deletions
View File
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from arbitrade.alerting.notifier import build_notifier_from_settings
from arbitrade.api.control_state import DashboardControlState
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
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository
def create_app(settings: Settings) -> FastAPI:
configure_logging(settings.log_level, settings.log_json)
db = DuckDBStore(settings)
db.migrate()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await restore_runtime_state(app)
yield
await graceful_shutdown(app)
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings
app.state.store = db
app.state.metrics = MetricsCalculator(db)
app.state.audit_repository = AuditRepository(db)
app.state.runtime_state_repository = RuntimeStateRepository(db)
app.state.alert_notifier = build_notifier_from_settings(settings)
app.state.backtest_recent_reports = []
app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active,
)
app.include_router(public_router)
app.include_router(router)
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"'},
)
+20
View File
@@ -0,0 +1,20 @@
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)
tradable_pairs: list[str] = field(default_factory=list)
strategy_mode: str = "incremental"
strategy_profit_threshold: float = 0.0005
strategy_max_depth_levels: int = 10
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def mark_updated(self) -> None:
self.updated_at = datetime.now(UTC)
+944
View File
@@ -0,0 +1,944 @@
from __future__ import annotations
import json
from asyncio import Lock
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from importlib import resources
from pathlib import Path
from typing import cast
from urllib.parse import parse_qs
import duckdb
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus
from arbitrade.api.auth import require_dashboard_auth
from arbitrade.api.control_state import DashboardControlState
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
from arbitrade.storage.repositories import AuditRecord, AuditRepository
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
public_router = APIRouter()
def _resolve_templates_directory() -> str:
# Support source layout, Docker runtime (/app), and installed package data.
source_layout_path = Path(
__file__).resolve().parents[3] / "web" / "templates"
if source_layout_path.is_dir():
return str(source_layout_path)
docker_runtime_path = Path.cwd() / "web" / "templates"
if docker_runtime_path.is_dir():
return str(docker_runtime_path)
try:
package_path = resources.files(
"arbitrade").joinpath("web", "templates")
if package_path.is_dir():
return str(package_path)
except (ModuleNotFoundError, AttributeError):
pass
return str(source_layout_path)
templates = Jinja2Templates(directory=_resolve_templates_directory())
_BACKTEST_ROOT = Path(__file__).resolve().parents[3]
_BACKTEST_RUN_LOCK = Lock()
def _format_metric(value: float | None, *, precision: int = 2, suffix: str = "") -> str:
if value is None:
return ""
return f"{value:.{precision}f}{suffix}"
def _dashboard_metrics(request: Request) -> dict[str, str]:
metrics = request.app.state.metrics.compute()
return {
"realized_pnl": _format_metric(metrics.realized_pnl_usd, precision=2, suffix=" USD"),
"win_rate": _format_metric(
metrics.win_rate * 100.0 if metrics.win_rate is not None else None,
precision=1,
suffix="%",
),
"avg_trade_duration": _format_metric(
metrics.avg_trade_duration_seconds, precision=1, suffix=" s"
),
"opportunities_per_minute": _format_metric(
metrics.opportunities_per_minute, precision=1, suffix=" /min"
),
"fill_rate": _format_metric(
metrics.fill_rate * 100.0 if metrics.fill_rate is not None else None,
precision=1,
suffix="%",
),
"latency_p50": _format_metric(metrics.latency_p50_seconds, precision=3, suffix=" s"),
"latency_p95": _format_metric(metrics.latency_p95_seconds, precision=3, suffix=" s"),
"latency_p99": _format_metric(metrics.latency_p99_seconds, precision=3, suffix=" s"),
"generated_at": datetime.now(UTC).isoformat(),
}
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}
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()
rpnl = 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(rpnl[0]):.2f} USD" if rpnl else "",
"opportunities": opportunity_rows,
}
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()
cr = list(reversed(opportunity_rows))
labels = []
for index, row in enumerate(cr):
if isinstance(row[0], datetime):
labels.append(row[0].isoformat())
else:
labels.append(f"opportunity-{index + 1}")
np = [float(row[2]) if row[2] is not None else 0.0 for row in cr]
ep = [float(row[3]) if row[3] is not None else 0.0 for row in cr]
cycles = [str(row[1]) for row in cr]
return {
"labels": labels,
"net_pct_values": np,
"est_profit_values": ep,
"cycles": cycles,
"has_chart_data": bool(cr),
"generated_at": datetime.now(UTC).isoformat(),
}
def _dashboard_controls_state(request: Request) -> DashboardControlState:
return cast(DashboardControlState, request.app.state.dashboard_controls)
def _audit_repository(request: Request) -> AuditRepository | None:
repository = getattr(request.app.state, "audit_repository", None)
return cast(AuditRepository | None, repository)
def _record_audit(
request: Request,
*,
actor: str,
event_type: str,
decision: str,
payload: dict[str, object] | None = None,
) -> None:
repository = _audit_repository(request)
if repository is None:
return
correlation_id = request.headers.get("x-request-id")
if payload is not None:
ret_pl = {str(key): payload[key] for key in payload}
else:
ret_pl = None
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor=actor,
event_type=event_type,
decision=decision,
payload=ret_pl,
correlation_id=correlation_id,
)
)
def _dashboard_audit(request: Request, *, limit: int = 15) -> dict[str, object]:
repository = _audit_repository(request)
if repository is None:
return {
"entries": [],
"generated_at": datetime.now(UTC).isoformat(),
}
records = repository.list_recent(limit=limit)
entries: list[dict[str, str]] = []
for record in records:
payload_text = ""
if record.payload:
payload_text = json.dumps(record.payload)
entries.append(
{
"occurred_at": record.occurred_at.isoformat(),
"actor": record.actor,
"event_type": record.event_type,
"decision": record.decision,
"payload": payload_text,
"correlation_id": record.correlation_id or "",
}
)
return {
"entries": entries,
"generated_at": datetime.now(UTC).isoformat(),
}
def _alert_notifier(request: Request) -> SupportsAlerts | None:
notifier = getattr(request.app.state, "alert_notifier", None)
return cast(SupportsAlerts | None, notifier)
def _alert_status_snapshot(request: Request) -> dict[str, object]:
notifier = getattr(request.app.state, "alert_notifier", None)
if isinstance(notifier, SupportsAlertStatus):
return notifier.status_snapshot()
return {
"enabled": False,
"has_channels": False,
"configured_channels": [],
"min_severity": "",
"dedup_seconds": 0.0,
"last_result": "unavailable",
"last_attempted_at": None,
"last_success_at": None,
"last_error": None,
"last_event": None,
"last_channel_results": [],
}
def _dashboard_controls(request: Request) -> dict[str, object]:
ctl = _dashboard_controls_state(request)
rs = request.app.state.settings
alert_status = _alert_status_snapshot(request)
last_event = alert_status.get("last_event")
last_event_title = ""
if isinstance(last_event, dict):
title_value = last_event.get("title")
if isinstance(title_value, str):
last_event_title = title_value
cc = alert_status.get("configured_channels")
cd = ""
if isinstance(cc, list) and cc:
cd = ", ".join(str(channel) for channel in cc)
ddsr = alert_status.get("dedup_seconds", 0.0)
dds = float(ddsr) if isinstance(ddsr, int | float) else 0.0
tpd = ", ".join(ctl.tradable_pairs) if ctl.tradable_pairs else "All"
max_trade_capital_usd = (
f"{float(rs.max_trade_capital_usd):.2f} USD"
if rs.max_trade_capital_usd is not None
else ""
)
max_trade_capital_usd_value = (
f"{float(rs.max_trade_capital_usd):.2f}" if rs.max_trade_capital_usd is not None else ""
)
max_concurrent_trades = (
str(rs.max_concurrent_trades) if rs.max_concurrent_trades is not None else ""
)
max_concurrent_trades_value = (
str(rs.max_concurrent_trades) if rs.max_concurrent_trades is not None else ""
)
alerts_last_channel_results = [
str(item) for item in cast(list[object], alert_status.get("last_channel_results", []))
]
strategy_stat_arb_enabled = bool(
getattr(rs, "strategy_enable_stat_arb_experiment", False))
return {
"execution_status": "running" if ctl.is_running else "stopped",
"kill_switch_status": "active" if ctl.kill_switch.is_active else "inactive",
"kill_switch_reason": ctl.kill_switch.reason or "",
"paper_trading_mode": "enabled" if rs.paper_trading_mode else "disabled",
"trade_capital_usd": f"{float(rs.trade_capital_usd):.2f} USD",
"trade_capital_usd_value": f"{float(rs.trade_capital_usd):.2f}",
"max_trade_capital_usd": max_trade_capital_usd,
"max_trade_capital_usd_value": max_trade_capital_usd_value,
"max_concurrent_trades": max_concurrent_trades,
"max_concurrent_trades_value": max_concurrent_trades_value,
"alerts_enabled": "enabled" if bool(alert_status.get("enabled", False)) else "disabled",
"alerts_channels": cd,
"alerts_min_severity": str(alert_status.get("min_severity", "")),
"alerts_dedup_seconds": f"{dds:.0f}",
"alerts_last_result": str(alert_status.get("last_result", "unavailable")),
"alerts_last_attempted_at": str(alert_status.get("last_attempted_at") or ""),
"alerts_last_success_at": str(alert_status.get("last_success_at") or ""),
"alerts_last_event_title": last_event_title,
"alerts_last_error": str(alert_status.get("last_error") or ""),
"alerts_last_channel_results": alerts_last_channel_results,
"tradable_pairs_display": tpd,
"tradable_pairs_value": ", ".join(ctl.tradable_pairs),
"strategy_mode": ctl.strategy_mode,
"strategy_stat_arb_enabled": strategy_stat_arb_enabled,
"strategy_profit_threshold": f"{ctl.strategy_profit_threshold:.6f}",
"strategy_max_depth_levels": str(ctl.strategy_max_depth_levels),
"updated_at": ctl.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",
"chart_endpoint": "/dashboard/fragment/charts",
}
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"}
def _parse_comma_separated_list(value: str | None) -> list[str]:
if value is None:
return []
items: list[str] = []
for raw_item in value.split(","):
item = raw_item.strip().upper()
if item and item not in items:
items.append(item)
return items
def _normalize_fee_profile(profile: str) -> str:
return profile.strip().lower().replace("-", "_")
def _fee_rate_for_profile(profile: str, custom_fee_rate: float | None) -> float:
normalized = _normalize_fee_profile(profile)
profile_map = {
"standard": 0.0026,
"maker_heavy": 0.0016,
"taker_heavy": 0.0035,
}
if normalized == "custom":
if custom_fee_rate is None:
raise ValueError("custom fee profile requires custom_fee_rate")
if custom_fee_rate < 0.0:
raise ValueError("custom_fee_rate must be >= 0")
return custom_fee_rate
if normalized not in profile_map:
valid = ", ".join(sorted(list(profile_map.keys()) + ["custom"]))
raise ValueError(f"fee_profile must be one of: {valid}")
return profile_map[normalized]
def _parse_balances(raw: str) -> dict[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
stripped = entry.strip()
if not stripped:
continue
if "=" not in stripped:
raise ValueError("starting_balances must be in ASSET=value format")
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
if not balances:
raise ValueError("starting_balances must include at least one balance")
return balances
def _resolve_workspace_path(raw: str) -> Path:
candidate = Path(raw.strip())
if not candidate.is_absolute():
candidate = (_BACKTEST_ROOT / candidate).resolve()
else:
candidate = candidate.resolve()
return candidate
def _display_path(path: Path) -> str:
try:
return str(path.relative_to(_BACKTEST_ROOT))
except ValueError:
return str(path)
def _build_cycles_from_events(
symbols: set[str],
) -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph()
for symbol in sorted(symbols):
if "/" not in symbol:
continue
base, quote = symbol.upper().split("/", 1)
graph.add_pair(base, quote, f"{base}/{quote}")
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles), sorted(symbols)
def _recent_backtest_reports(request: Request) -> list[dict[str, object]]:
reports = getattr(request.app.state, "backtest_recent_reports", [])
if isinstance(reports, list):
return cast(list[dict[str, object]], reports)
return []
def _backtesting_panel_context(
request: Request,
*,
status: str = "idle",
message: str = "Configure a replay run and execute backtest.",
latest_report: dict[str, object] | None = None,
defaults: dict[str, str] | None = None,
) -> dict[str, object]:
default_values = {
"events_path": "",
"starting_balances": "USD=1000.0",
"trade_capital": "100.0",
"min_profit_threshold": "0.0005",
"fee_profile": "standard",
"custom_fee_rate": "",
"slippage_bps": "4.0",
"execution_latency_ms": "20.0",
}
if defaults is not None:
default_values.update(defaults)
reports = _recent_backtest_reports(request)
latest = latest_report or (reports[0] if reports else None)
return {
"status": status,
"message": message,
"latest_report": latest,
"recent_reports": reports,
"run_endpoint": "/dashboard/backtesting/run",
"reports_endpoint": "/dashboard/api/backtesting/reports",
**default_values,
}
async def _dashboard_response(
request: Request, template_name: str = "dashboard.html"
) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name=template_name,
context={
"title": "Arbitrade Dashboard",
"request": request,
"metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls",
"charts_endpoint": "/dashboard/fragment/charts",
"audit_endpoint": "/dashboard/fragment/audit",
"stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview",
},
)
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="backtesting.html",
context={
"title": "Arbitrade Backtesting",
"request": request,
"panel_endpoint": "/dashboard/fragment/backtesting",
"dashboard_endpoint": "/dashboard",
},
)
@router.get("/dashboard/fragment/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **_backtesting_panel_context(request)},
)
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
async def dashboard_metrics(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/metrics.html",
context={"request": request, **_dashboard_metrics(request)},
)
@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.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.get("/dashboard/fragment/audit", response_class=HTMLResponse)
async def dashboard_audit(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/audit.html",
context={"request": request, **_dashboard_audit(request)},
)
@router.get("/dashboard/api/alerts/status", response_class=JSONResponse)
async def dashboard_alert_status(request: Request) -> JSONResponse:
return JSONResponse(_alert_status_snapshot(request))
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
async def dashboard_audit_recent(request: Request) -> JSONResponse:
return JSONResponse(_dashboard_audit(request, limit=25))
@router.get("/dashboard/api/backtesting/reports", response_class=JSONResponse)
async def dashboard_backtesting_reports(request: Request) -> JSONResponse:
return JSONResponse(
{
"generated_at": datetime.now(UTC).isoformat(),
"reports": _recent_backtest_reports(request),
}
)
@router.post("/dashboard/backtesting/run", response_class=HTMLResponse)
async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
form = _parse_form_body(await request.body())
defaults = {
"events_path": form.get("events_path", ""),
"starting_balances": form.get("starting_balances", "USD=1000.0"),
"trade_capital": form.get("trade_capital", "100.0"),
"min_profit_threshold": form.get("min_profit_threshold", "0.0005"),
"fee_profile": _normalize_fee_profile(form.get("fee_profile", "standard")),
"custom_fee_rate": form.get("custom_fee_rate", ""),
"slippage_bps": form.get("slippage_bps", "4.0"),
"execution_latency_ms": form.get("execution_latency_ms", "20.0"),
}
try:
events_path = _resolve_workspace_path(defaults["events_path"])
if not events_path.exists() or not events_path.is_file():
raise ValueError(
"events_path must reference an existing JSONL file")
events = load_replay_events(events_path)
if not events:
raise ValueError("events file contains no replay events")
custom_fee_rate = (
float(defaults["custom_fee_rate"]
) if defaults["custom_fee_rate"].strip() else None
)
fee_rate = _fee_rate_for_profile(
defaults["fee_profile"], custom_fee_rate)
starting_balances = _parse_balances(defaults["starting_balances"])
trade_capital = float(defaults["trade_capital"])
min_profit_threshold = float(defaults["min_profit_threshold"])
slippage_bps = float(defaults["slippage_bps"])
execution_latency_ms = float(defaults["execution_latency_ms"])
cycles_by_pair, available_pairs = _build_cycles_from_events(
{event.symbol.upper() for event in events}
)
if not cycles_by_pair:
raise ValueError(
"unable to derive triangular cycles from provided events")
config = BacktestConfig(
fee_rate=fee_rate,
min_profit_threshold=min_profit_threshold,
trade_capital=trade_capital,
slippage_bps=slippage_bps,
execution_latency_ms=execution_latency_ms,
)
async with _BACKTEST_RUN_LOCK:
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=config,
started_at=events[0].occurred_at,
)
report = await engine.run(events, starting_balances=starting_balances)
report_item: dict[str, object] = {
"run_at": datetime.now(UTC).isoformat(),
"events_path": _display_path(events_path),
"status": "completed",
"config": {
"trade_capital": trade_capital,
"min_profit_threshold": min_profit_threshold,
"fee_profile": defaults["fee_profile"],
"fee_rate": fee_rate,
"slippage_bps": slippage_bps,
"execution_latency_ms": execution_latency_ms,
},
"report": {
"processed_events": report.processed_events,
"opportunities_seen": report.opportunities_seen,
"trades_executed": report.trades_executed,
"win_rate": report.win_rate,
"fill_rate": report.fill_rate,
"realized_pnl_usd": report.realized_pnl_usd,
"max_drawdown_usd": report.max_drawdown_usd,
"miss_reasons": dict(report.miss_reasons),
"execution_latency_p50_ms": report.execution_latency_p50_ms,
"execution_latency_p95_ms": report.execution_latency_p95_ms,
"execution_latency_p99_ms": report.execution_latency_p99_ms,
},
}
reports = _recent_backtest_reports(request)
reports.insert(0, report_item)
del reports[20:]
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.backtesting.run",
decision="completed",
payload={
"events_path": report_item["events_path"],
"processed_events": report.processed_events,
"trades_executed": report.trades_executed,
"realized_pnl_usd": report.realized_pnl_usd,
},
)
context = _backtesting_panel_context(
request,
status="completed",
message="Backtest run completed successfully.",
latest_report=report_item,
defaults=defaults,
)
except ValueError as exc:
context = _backtesting_panel_context(
request,
status="failed",
message=str(exc),
defaults=defaults,
)
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **context},
)
@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()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="info",
title="Execution started",
message="Dashboard control started execution.",
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
)
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()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="warning",
title="Execution stopped",
message="Dashboard control stopped execution.",
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.stop",
decision="approved",
payload={"execution_status": "stopped"},
)
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()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="threshold",
severity="critical",
title="Kill switch activated",
message="Kill switch triggered from dashboard control.",
details={"reason": reason},
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.kill_switch",
decision="approved",
payload={"reason": reason},
)
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:
ctl = _dashboard_controls_state(request)
rs = request.app.state.settings
form = _parse_form_body(await request.body())
if "trade_capital_usd" in form and form["trade_capital_usd"]:
rs.trade_capital_usd = float(form["trade_capital_usd"])
if "max_trade_capital_usd" in form:
mtcv = form["max_trade_capital_usd"].strip()
rs.max_trade_capital_usd = float(mtcv) if mtcv else None
if "max_concurrent_trades" in form:
mcv = form["max_concurrent_trades"].strip()
rs.max_concurrent_trades = int(mcv) if mcv else None
form_pairs = form.get("tradable_pairs")
ctl.tradable_pairs = _parse_comma_separated_list(form_pairs)
if "strategy_mode" in form and form["strategy_mode"].strip():
strategy_mode = form["strategy_mode"].strip().lower()
allowed_strategy_modes = {"incremental", "paper", "live"}
if bool(getattr(rs, "strategy_enable_stat_arb_experiment", False)):
allowed_strategy_modes.add("stat_arb_experiment")
if strategy_mode not in allowed_strategy_modes:
e = f"strategy_mode must be one of: {', '.join(sorted(allowed_strategy_modes))}"
raise ValueError(e)
ctl.strategy_mode = strategy_mode
if "strategy_profit_threshold" in form:
if form["strategy_profit_threshold"].strip():
spt = float(form["strategy_profit_threshold"])
ctl.strategy_profit_threshold = spt
if "strategy_max_depth_levels" in form:
if form["strategy_max_depth_levels"].strip():
smdl = int(form["strategy_max_depth_levels"])
ctl.strategy_max_depth_levels = smdl
rs.paper_trading_mode = _form_bool(form.get("paper_trading_mode"))
ctl.mark_updated()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="info",
title="Runtime config updated",
message="Dashboard control updated runtime risk and execution settings.",
details={
"trade_capital_usd": f"{rs.trade_capital_usd}",
"max_trade_capital_usd": (
"none" if rs.max_trade_capital_usd is None else f"{rs.max_trade_capital_usd}"
),
"max_concurrent_trades": (
"none" if rs.max_concurrent_trades is None else f"{rs.max_concurrent_trades}"
),
"paper_trading_mode": "true" if rs.paper_trading_mode else "false",
},
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.config",
decision="approved",
payload={
"trade_capital_usd": rs.trade_capital_usd,
"max_trade_capital_usd": rs.max_trade_capital_usd,
"max_concurrent_trades": rs.max_concurrent_trades,
"paper_trading_mode": rs.paper_trading_mode,
"tradable_pairs": ctl.tradable_pairs,
"strategy_mode": ctl.strategy_mode,
"strategy_profit_threshold": ctl.strategy_profit_threshold,
"strategy_max_depth_levels": ctl.strategy_max_depth_levels,
},
)
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 = (
templates.get_template("partials/metrics.html")
.render(
request=request,
**_dashboard_metrics(request),
)
.strip()
.replace("\n", "")
)
async def _event_stream() -> AsyncIterator[bytes]:
payload = json.dumps(fragment)
yield f"event: metrics\ndata: {payload}\n\n".encode()
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")
@public_router.get("/health", response_class=JSONResponse)
async def health() -> JSONResponse:
return JSONResponse({"status": "ok", "service": "arbitrade"})