Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s
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:
@@ -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
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user