Compare commits

...

12 Commits

Author SHA1 Message Date
zwitschi 7728f9a8cd feat: enhance backtesting functionality with database integration and UI updates
CI / lint-test-build (push) Failing after 1m20s
2026-06-04 18:39:17 +02:00
zwitschi a83d231d06 feat: refactor health endpoint handling and improve job ID display in backtesting 2026-06-04 17:59:41 +02:00
zwitschi 1c2558cfb3 feat: refactor fee management by removing deprecated pair fee handling and updating dashboard to display equity 2026-06-04 17:48:41 +02:00
zwitschi a0366f06ff feat: add health check endpoint and refactor templates for consistent header usage 2026-06-04 17:13:40 +02:00
zwitschi 86d1046862 feat: update documentation structure and add deployment guide 2026-06-04 15:55:07 +02:00
zwitschi 6acd6bbbc9 feat: implement backtesting job management with database integration and UI updates 2026-06-03 21:34:19 +02:00
zwitschi ff71fc5feb feat: enhance fee management with API integration and audit trail support 2026-06-03 19:27:32 +02:00
zwitschi 587c9afc3b feat: implement fee synchronization and dashboard updates for Kraken account fees 2026-06-03 18:59:39 +02:00
zwitschi 5f2f968721 feat: update .gitignore and .dockerignore to include build artifacts 2026-06-03 18:48:11 +02:00
zwitschi 87dd655f08 feat: add pair fees configuration panel and related functionality
- Introduced a new HTML template for configuring pair fees, allowing users to add, edit, and delete fees for trading pairs.
- Implemented a responsive fee table displaying existing fees with options for editing and deleting.
- Added a form for adding new fees, including fields for base asset, quote asset, market type, and fee rates.
- Removed outdated templates related to backtesting, dashboard, health, and various partials to streamline the codebase.
- Ensured the new fee configuration panel integrates seamlessly with existing endpoints and uses htmx for dynamic updates.
2026-06-03 18:43:36 +02:00
zwitschi ccca9ef62a feat: add dashboard configuration management endpoints and services for pairing and fee management 2026-06-03 18:30:31 +02:00
zwitschi 57df3a4361 remove build dir 2026-06-03 18:30:08 +02:00
101 changed files with 2148 additions and 8915 deletions
+1
View File
@@ -5,6 +5,7 @@ __pycache__
.pytest_cache
.mypy_cache
.ruff_cache
build
data
logs
*.pyc
+3
View File
@@ -31,6 +31,9 @@ Thumbs.db
!.env.example
secrets/
# Local build artifacts
build/
# Local database / runtime data
data/*.duckdb
data/*.duckdb.wal
+33 -3
View File
@@ -12,7 +12,7 @@ Current stack:
Project plan lives in [PLAN.md](PLAN.md).
Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md).
Coolify deployment runbooks live in [DEPLOYMENT.md](DEPLOYMENT.md).
Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
## Current Status
@@ -49,6 +49,36 @@ Key features include:
- Backtesting parameter configuration
- Fee configuration by pairing and market type
## Templates
Full page templates (`src/arbitrade/web/templates/`):
| Template | Route | Purpose |
| ------------------ | ------------------------ | ------------------------------------------------------- |
| `base.html` | — (root layout) | Dark theme, `.shell` container, HTMX, CSS variables |
| `dashboard.html` | `/`, `/dashboard` | Main dashboard: metrics, overview, controls, charts |
| `config.html` | `/dashboard/config` | Full configuration: fees, runtime, alerts, Kraken, risk |
| `audit.html` | `/dashboard/audit` | Audit trail with auto-refresh via HTMX |
| `backtesting.html` | `/dashboard/backtesting` | Backtesting panel with replay/sweep forms |
| `health.html` | `/health` | System health check |
Dashboard partials (`src/arbitrade/web/templates/partials/`):
| Partial | In page | Content |
| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------- |
| `metrics.html` | Dashboard | 6 KPI cards: P&L, win rate, avg duration, trade count, success %, profit factor |
| `overview.html` | Dashboard | Status, balances, fee tier, open trades list, opportunity feed |
| `controls.html` | Dashboard | Runtime status, kill switch, config snapshot, alerting status, execution controls (Start/Stop/Kill) |
| `charts.html` | Dashboard | Opportunity trend chart (Chart.js, Alpine toggle) |
| `config.html` | Config page | Config form: Runtime, Alerts, Kraken, Risk, Strategy sections |
| `config_fees.html` | Config page | Pair fee table + add/edit form |
| `backtesting_panel.html` | Backtesting page | Run status, replay/sweep forms, recent runs |
| `audit.html` | Audit page | Audit trail table: time, actor, event, decision, payload |
Legacy templates (`src/arbitrade/web/templates/dashboard/`):
- `config_settings.html`, `config_pairs.html`, `config_fees.html` — superseded by config page; retained for reference
## Prerequisites
- Python 3.12+
@@ -366,9 +396,9 @@ git.allucanget.biz/allucanget/arbitrade:latest
## Architecture Docs
Implementation detail moved into arc42 docs:
Implementation detail moved into docs:
- [arc42 overview](docs/architecture/arc42.md) - system context, building blocks, runtime, deployment, quality goals, risks.
- [architecture overview](docs/architecture/README.md) - system context, building blocks, runtime, deployment, quality goals, risks.
- [current implementation snapshot](docs/architecture/current-implementation.md) - codebase state, active routes, backtesting, strategy flags, deployment flow.
For navigation from README, use the docs above instead of this file for deep architecture detail.
-3
View File
@@ -1,3 +0,0 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
-25
View File
@@ -1,25 +0,0 @@
"""Alerting primitives and channel clients."""
from arbitrade.alerting.notifier import (
AlertEvent,
AlertNotifier,
AlertSeverity,
DiscordWebhookChannel,
EmailSmtpChannel,
SupportsAlertStatus,
TelegramChannel,
build_channels_from_settings,
dispatch_alert_nowait,
)
__all__ = [
"AlertEvent",
"AlertNotifier",
"AlertSeverity",
"DiscordWebhookChannel",
"EmailSmtpChannel",
"SupportsAlertStatus",
"TelegramChannel",
"build_channels_from_settings",
"dispatch_alert_nowait",
]
-400
View File
@@ -1,400 +0,0 @@
from __future__ import annotations
import asyncio
import smtplib
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from typing import Literal, Protocol, runtime_checkable
import httpx
AlertSeverity = Literal["info", "warning", "error", "critical"]
_SEVERITY_RANK: dict[AlertSeverity, int] = {
"info": 10,
"warning": 20,
"error": 30,
"critical": 40,
}
@dataclass(frozen=True, slots=True)
class AlertEvent:
category: str
severity: AlertSeverity
title: str
message: str
occurred_at: datetime
details: dict[str, str]
class AlertChannel(Protocol):
async def send(self, event: AlertEvent) -> None: ...
class SupportsAlerts(Protocol):
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool: ...
@runtime_checkable
class SupportsAlertStatus(Protocol):
def status_snapshot(self) -> dict[str, object]: ...
class AlertNotifier:
def __init__(
self,
channels: list[AlertChannel],
*,
enabled: bool = True,
min_severity: AlertSeverity = "info",
dedup_seconds: float = 0.0,
category_flags: dict[str, bool] | None = None,
) -> None:
if dedup_seconds < 0.0:
raise ValueError("dedup_seconds must be >= 0.0")
self._channels = channels
self._enabled = enabled
self._min_severity: AlertSeverity = min_severity
self._dedup_seconds = dedup_seconds
self._category_flags = {key.lower(): value for key, value in (category_flags or {}).items()}
self._last_sent_at: dict[str, datetime] = {}
self._last_result: str = "never"
self._last_attempted_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error: str | None = None
self._last_event_title: str | None = None
self._last_event_category: str | None = None
self._last_event_severity: AlertSeverity | None = None
self._last_channel_results: list[str] = []
@property
def has_channels(self) -> bool:
return bool(self._channels)
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool:
if not self._enabled or not self._channels:
self._last_result = "skipped_disabled" if not self._enabled else "skipped_no_channels"
return False
normalized_category = category.strip().lower()
if self._category_flags and not self._category_flags.get(normalized_category, True):
self._last_result = "skipped_category"
return False
if _SEVERITY_RANK[severity] < _SEVERITY_RANK[self._min_severity]:
self._last_result = "skipped_severity"
return False
dedup_key = f"{normalized_category}|{severity}|{title}|{message}"
now = datetime.now(UTC)
if self._dedup_seconds > 0.0:
previous = self._last_sent_at.get(dedup_key)
if previous is not None:
elapsed = (now - previous).total_seconds()
if elapsed < self._dedup_seconds:
self._last_result = "skipped_dedup"
return False
event = AlertEvent(
category=normalized_category,
severity=severity,
title=title,
message=message,
occurred_at=now,
details=details or {},
)
results = await asyncio.gather(
*(channel.send(event) for channel in self._channels),
return_exceptions=True,
)
self._last_attempted_at = now
self._last_event_title = title
self._last_event_category = normalized_category
self._last_event_severity = severity
self._last_channel_results = []
for channel, result in zip(self._channels, results, strict=False):
channel_name = type(channel).__name__
if isinstance(result, Exception):
self._last_channel_results.append(f"{channel_name}: error")
else:
self._last_channel_results.append(f"{channel_name}: ok")
if all(isinstance(result, Exception) for result in results):
self._last_result = "failed"
self._last_error = "all channels failed"
return False
self._last_result = (
"partial_success"
if any(isinstance(result, Exception) for result in results)
else "success"
)
self._last_error = None
self._last_success_at = now
self._last_sent_at[dedup_key] = now
return True
def status_snapshot(self) -> dict[str, object]:
return {
"enabled": self._enabled,
"has_channels": self.has_channels,
"configured_channels": [type(channel).__name__ for channel in self._channels],
"min_severity": self._min_severity,
"dedup_seconds": self._dedup_seconds,
"last_result": self._last_result,
"last_attempted_at": (
self._last_attempted_at.isoformat() if self._last_attempted_at is not None else None
),
"last_success_at": (
self._last_success_at.isoformat() if self._last_success_at is not None else None
),
"last_error": self._last_error,
"last_event": (
None
if self._last_event_title is None
else {
"title": self._last_event_title,
"category": self._last_event_category,
"severity": self._last_event_severity,
}
),
"last_channel_results": self._last_channel_results,
}
def dispatch_alert_nowait(
notifier: SupportsAlerts | None,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> None:
if notifier is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(
notifier.notify(
category=category,
severity=severity,
title=title,
message=message,
details=details,
)
)
def _format_event_text(event: AlertEvent) -> str:
lines = [
f"[{event.severity.upper()}] {event.title}",
f"Category: {event.category}",
f"Time: {event.occurred_at.isoformat()}",
event.message,
]
if event.details:
lines.append("Details:")
for key, value in sorted(event.details.items()):
lines.append(f"- {key}: {value}")
return "\n".join(lines)
class TelegramChannel:
def __init__(self, *, bot_token: str, chat_id: str, timeout_seconds: float = 10.0) -> None:
self._bot_token = bot_token
self._chat_id = chat_id
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
payload = {
"chat_id": self._chat_id,
"text": _format_event_text(event),
"disable_web_page_preview": True,
}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
class DiscordWebhookChannel:
def __init__(self, *, webhook_url: str, timeout_seconds: float = 10.0) -> None:
self._webhook_url = webhook_url
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
payload = {"content": _format_event_text(event)}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(self._webhook_url, json=payload)
response.raise_for_status()
class EmailSmtpChannel:
def __init__(
self,
*,
host: str,
port: int,
sender: str,
recipients: list[str],
username: str | None = None,
password: str | None = None,
use_tls: bool = True,
timeout_seconds: float = 10.0,
) -> None:
if not recipients:
raise ValueError("recipients must not be empty")
self._host = host
self._port = port
self._sender = sender
self._recipients = recipients
self._username = username
self._password = password
self._use_tls = use_tls
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
message = EmailMessage()
message["From"] = self._sender
message["To"] = ", ".join(self._recipients)
message["Subject"] = f"[{event.severity.upper()}] {event.title}"
message.set_content(_format_event_text(event))
await asyncio.to_thread(self._send_sync, message)
def _send_sync(self, message: EmailMessage) -> None:
with smtplib.SMTP(self._host, self._port, timeout=self._timeout_seconds) as client:
if self._use_tls:
client.starttls()
if self._username and self._password:
client.login(self._username, self._password)
client.send_message(message)
class _AlertSettings(Protocol):
alerts_enabled: bool
alert_min_severity: str
alert_dedup_seconds: float
alert_on_trade_events: bool
alert_on_error_events: bool
alert_on_threshold_events: bool
alert_on_system_events: bool
telegram_alerts_enabled: bool
telegram_bot_token: str | None
telegram_chat_id: str | None
discord_alerts_enabled: bool
discord_webhook_url: str | None
email_alerts_enabled: bool
email_smtp_host: str | None
email_smtp_port: int
email_smtp_username: str | None
email_smtp_password: str | None
email_alert_from: str | None
email_alert_to: str | None
email_smtp_use_tls: bool
def _as_alert_severity(value: str) -> AlertSeverity:
normalized = value.strip().lower()
if normalized == "info":
return "info"
if normalized == "warning":
return "warning"
if normalized == "error":
return "error"
if normalized == "critical":
return "critical"
else:
raise ValueError("alert_min_severity must be one of: info, warning, error, critical")
def build_channels_from_settings(settings: _AlertSettings) -> list[AlertChannel]:
channels: list[AlertChannel] = []
if settings.telegram_alerts_enabled:
if not settings.telegram_bot_token or not settings.telegram_chat_id:
raise ValueError("telegram alerts require bot token and chat id")
channels.append(
TelegramChannel(
bot_token=settings.telegram_bot_token,
chat_id=settings.telegram_chat_id,
)
)
if settings.discord_alerts_enabled:
if not settings.discord_webhook_url:
raise ValueError("discord alerts require webhook url")
channels.append(DiscordWebhookChannel(webhook_url=settings.discord_webhook_url))
if settings.email_alerts_enabled:
if not settings.email_smtp_host:
raise ValueError("email alerts require SMTP host")
if not settings.email_alert_from:
raise ValueError("email alerts require sender address")
if not settings.email_alert_to:
raise ValueError("email alerts require recipient list")
recipients = [
address.strip() for address in settings.email_alert_to.split(",") if address.strip()
]
channels.append(
EmailSmtpChannel(
host=settings.email_smtp_host,
port=settings.email_smtp_port,
sender=settings.email_alert_from,
recipients=recipients,
username=settings.email_smtp_username,
password=settings.email_smtp_password,
use_tls=settings.email_smtp_use_tls,
)
)
return channels
def build_notifier_from_settings(settings: _AlertSettings) -> AlertNotifier:
severity = _as_alert_severity(settings.alert_min_severity)
channels = build_channels_from_settings(settings)
category_flags = {
"trade": settings.alert_on_trade_events,
"error": settings.alert_on_error_events,
"threshold": settings.alert_on_threshold_events,
"system": settings.alert_on_system_events,
}
return AlertNotifier(
channels,
enabled=settings.alerts_enabled,
min_severity=severity,
dedup_seconds=settings.alert_dedup_seconds,
category_flags=category_flags,
)
View File
-44
View File
@@ -1,44 +0,0 @@
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
@@ -1,38 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,944 +0,0 @@
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"})
@@ -1,35 +0,0 @@
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
BacktestReport,
ReplayBookEvent,
ReplayClock,
load_replay_events,
)
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepArtifacts,
SweepParameters,
SweepResult,
build_parameter_grid,
persist_sweep_results,
run_parameter_search,
split_events_time_windows,
)
__all__ = [
"ReplayClock",
"ReplayBookEvent",
"BacktestConfig",
"BacktestReport",
"BacktestReplayEngine",
"load_replay_events",
"SweepParameters",
"SweepResult",
"SweepArtifacts",
"PromotionCriteria",
"split_events_time_windows",
"build_parameter_grid",
"run_parameter_search",
"persist_sweep_results",
]
-326
View File
@@ -1,326 +0,0 @@
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import orjson
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
from arbitrade.detection.graph import TriangularCycle
from arbitrade.exchange.models import BookLevel
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.trade_limits import TradeLimitsGuard
@dataclass(slots=True)
class ReplayClock:
_current: datetime
@classmethod
def at(cls, started_at: datetime) -> ReplayClock:
return cls(_current=started_at.astimezone(UTC))
@property
def now(self) -> datetime:
return self._current
def advance_to(self, next_time: datetime) -> None:
normalized = next_time.astimezone(UTC)
if normalized < self._current:
raise ValueError("Replay events must be monotonic by timestamp")
self._current = normalized
def advance_ms(self, milliseconds: float) -> None:
if milliseconds < 0.0:
raise ValueError("milliseconds must be >= 0")
self._current = self._current.fromtimestamp(
self._current.timestamp() + (milliseconds / 1000.0),
tz=UTC,
)
@dataclass(frozen=True, slots=True)
class ReplayBookEvent:
occurred_at: datetime
symbol: str
bids: tuple[BookLevel, ...]
asks: tuple[BookLevel, ...]
@dataclass(frozen=True, slots=True)
class BacktestConfig:
fee_rate: float = 0.0026
min_profit_threshold: float = 0.0005
trade_capital: float = 100.0
quote_asset: str = "USD"
slippage_bps: float = 4.0
execution_latency_ms: float = 20.0
max_depth_levels: int = 10
max_concurrent_trades: int = 1
min_order_size_by_pair: Mapping[str, float] | None = None
@dataclass(frozen=True, slots=True)
class BacktestReport:
started_at: datetime
finished_at: datetime
processed_events: int
opportunities_seen: int
trades_executed: int
win_rate: float | None
fill_rate: float | None
realized_pnl_usd: float
max_drawdown_usd: float
miss_reasons: Mapping[str, int]
execution_latency_p50_ms: float | None
execution_latency_p95_ms: float | None
execution_latency_p99_ms: float | None
class _SimulatedRestClient:
def __init__(
self, clock: ReplayClock, *, slippage_bps: float, execution_latency_ms: float
) -> None:
self._clock = clock
self._slippage_bps = slippage_bps
self._execution_latency_ms = execution_latency_ms
self._sequence = 0
self._last_fill_ratio = 1.0
self._last_trade_latency_ms = execution_latency_ms
@property
def last_fill_ratio(self) -> float:
return self._last_fill_ratio
@property
def last_trade_latency_ms(self) -> float:
return self._last_trade_latency_ms
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, Any]:
self._sequence += 1
self._clock.advance_ms(self._execution_latency_ms)
await asyncio.sleep(0)
normalized_fill = max(0.85, 1.0 - (self._slippage_bps / 10000.0) * 8.0)
self._last_fill_ratio = normalized_fill
self._last_trade_latency_ms = self._execution_latency_ms
return {
"txid": [f"sim-{self._sequence}"],
"status": "closed",
"pair": pair,
"side": side,
"requested_volume": volume,
"filled_volume": volume * normalized_fill,
"simulated_at": self._clock.now.isoformat(),
}
def _percentile(values: Sequence[float], percentile: float) -> float | None:
if not values:
return None
ordered = sorted(values)
if percentile <= 0.0:
return ordered[0]
if percentile >= 100.0:
return ordered[-1]
rank = (len(ordered) - 1) * (percentile / 100.0)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
if not isinstance(raw_levels, list):
raise ValueError("Book levels must be a list")
levels: list[BookLevel] = []
for raw_level in raw_levels:
if (
not isinstance(raw_level, list)
or len(raw_level) != 2
or not isinstance(raw_level[0], int | float)
or not isinstance(raw_level[1], int | float)
):
raise ValueError("Each level must be [price, volume]")
levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
return tuple(levels)
def load_replay_events(path: Path) -> list[ReplayBookEvent]:
events: list[ReplayBookEvent] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parsed = orjson.loads(line)
if not isinstance(parsed, dict):
raise ValueError("Each JSONL row must be an object")
timestamp_raw = parsed.get("timestamp")
symbol_raw = parsed.get("symbol")
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
raise ValueError("Each event must include timestamp and symbol")
occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
events.append(
ReplayBookEvent(
occurred_at=occurred_at,
symbol=symbol_raw,
bids=_parse_book_levels(parsed.get("bids")),
asks=_parse_book_levels(parsed.get("asks")),
)
)
return sorted(events, key=lambda event: event.occurred_at)
class BacktestReplayEngine:
def __init__(
self,
*,
cycles_by_pair: Mapping[str, list[TriangularCycle]],
available_pairs: Sequence[str],
config: BacktestConfig,
started_at: datetime,
) -> None:
self._config = config
self._clock = ReplayClock.at(started_at)
self._books: dict[str, OrderBook] = {}
self._detector = IncrementalCycleDetector(
cycles_by_pair,
fee_rate=config.fee_rate,
max_depth_levels=config.max_depth_levels,
min_profit_threshold=config.min_profit_threshold,
min_order_size_by_pair=config.min_order_size_by_pair,
)
self._pre_trade = PreTradeValidator()
self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
self._simulated_rest = _SimulatedRestClient(
self._clock,
slippage_bps=config.slippage_bps,
execution_latency_ms=config.execution_latency_ms,
)
self._sequencer = TriangularExecutionSequencer(
self._simulated_rest,
available_pairs=available_pairs,
)
@staticmethod
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
currencies = [part for part in event.cycle.split("->") if part]
if len(currencies) < 2:
return {}
origin = currencies[0]
return {
currency: event.allocated_capital for currency in currencies[1:] if currency != origin
}
async def run(
self,
events: Sequence[ReplayBookEvent],
*,
starting_balances: Mapping[str, float],
) -> BacktestReport:
miss_reasons: Counter[str] = Counter()
processed_events = 0
opportunities_seen = 0
trades_executed = 0
realized_pnl = 0.0
equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
peak_equity = equity
max_drawdown = 0.0
fill_samples: list[float] = []
realized_samples: list[float] = []
execution_latencies: list[float] = []
for event in events:
self._clock.advance_to(event.occurred_at)
processed_events += 1
book = self._books.setdefault(event.symbol.upper(), OrderBook())
book.apply_bids(event.bids)
book.apply_asks(event.asks)
opportunities = self._detector.opportunities_for_updated_pair(
event.symbol,
self._books,
base_capital=self._config.trade_capital,
)
opportunities_seen += len(opportunities)
for opportunity in opportunities:
required_by_asset = {
self._config.quote_asset.upper(): opportunity.allocated_capital
}
if not self._pre_trade.validate(
balances_by_asset=starting_balances,
required_by_asset=required_by_asset,
):
miss_reasons["insufficient_balance"] += 1
continue
exposure = self._exposure_for_event(opportunity)
if not self._trade_limits.is_trade_allowed(exposure):
miss_reasons["trade_limit"] += 1
continue
self._trade_limits.open_trade(exposure)
result = await self._sequencer.execute(opportunity)
self._trade_limits.close_trade(exposure)
execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
fill_samples.append(self._simulated_rest.last_fill_ratio)
if not result.success:
miss_reasons["execution_failed"] += 1
continue
slippage_cost = (
opportunity.allocated_capital
* (self._config.slippage_bps / 10000.0)
* max(result.completed_legs, 1)
)
realized_trade_pnl = opportunity.est_profit - slippage_cost
realized_samples.append(realized_trade_pnl)
realized_pnl += realized_trade_pnl
equity += realized_trade_pnl
peak_equity = max(peak_equity, equity)
max_drawdown = max(max_drawdown, peak_equity - equity)
trades_executed += 1
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
win_rate = (wins / len(realized_samples)) if realized_samples else None
fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
return BacktestReport(
started_at=events[0].occurred_at if events else self._clock.now,
finished_at=events[-1].occurred_at if events else self._clock.now,
processed_events=processed_events,
opportunities_seen=opportunities_seen,
trades_executed=trades_executed,
win_rate=win_rate,
fill_rate=fill_rate,
realized_pnl_usd=realized_pnl,
max_drawdown_usd=max_drawdown,
miss_reasons=dict(miss_reasons),
execution_latency_p50_ms=_percentile(execution_latencies, 50.0),
execution_latency_p95_ms=_percentile(execution_latencies, 95.0),
execution_latency_p99_ms=_percentile(execution_latencies, 99.0),
)
-396
View File
@@ -1,396 +0,0 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import orjson
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
BacktestReport,
ReplayBookEvent,
)
from arbitrade.detection.graph import TriangularCycle
@dataclass(frozen=True, slots=True)
class SweepParameters:
min_profit_threshold: float
trade_capital: float
pair_universe: tuple[str, ...]
staleness_threshold_seconds: float
@dataclass(frozen=True, slots=True)
class PromotionCriteria:
min_test_realized_pnl_usd: float = 0.0
min_test_win_rate: float = 0.5
min_test_fill_rate: float = 0.9
max_test_drawdown_usd: float = 25.0
max_generalization_gap_ratio: float = 0.5
@dataclass(frozen=True, slots=True)
class SweepResult:
parameters: SweepParameters
train_report: BacktestReport
test_report: BacktestReport
train_score: float
test_score: float
generalization_gap_ratio: float
overfit_detected: bool
promotion_ready: bool
promotion_reasons: tuple[str, ...]
train_event_count: int
test_event_count: int
@dataclass(frozen=True, slots=True)
class SweepArtifacts:
results: tuple[SweepResult, ...]
promoted: tuple[SweepResult, ...]
train_window: tuple[datetime, datetime] | None
test_window: tuple[datetime, datetime] | None
def split_events_time_windows(
events: Sequence[ReplayBookEvent],
*,
train_ratio: float,
) -> tuple[list[ReplayBookEvent], list[ReplayBookEvent]]:
if train_ratio <= 0.0 or train_ratio >= 1.0:
raise ValueError("train_ratio must be between 0 and 1")
if len(events) < 2:
raise ValueError("at least two events are required for time split")
split_index = max(1, min(len(events) - 1, int(len(events) * train_ratio)))
return list(events[:split_index]), list(events[split_index:])
def build_parameter_grid(
*,
theta_values: Sequence[float],
trade_capital_values: Sequence[float],
pair_universes: Sequence[Sequence[str]],
staleness_threshold_values: Sequence[float],
) -> list[SweepParameters]:
if not theta_values:
raise ValueError("theta_values must not be empty")
if not trade_capital_values:
raise ValueError("trade_capital_values must not be empty")
if not pair_universes:
raise ValueError("pair_universes must not be empty")
if not staleness_threshold_values:
raise ValueError("staleness_threshold_values must not be empty")
grid: list[SweepParameters] = []
for theta in theta_values:
for trade_capital in trade_capital_values:
for pair_universe in pair_universes:
normalized_universe = tuple(
sorted({pair.upper() for pair in pair_universe}))
for staleness_threshold in staleness_threshold_values:
grid.append(
SweepParameters(
min_profit_threshold=float(theta),
trade_capital=float(trade_capital),
pair_universe=normalized_universe,
staleness_threshold_seconds=float(
staleness_threshold),
)
)
return grid
def _filter_events_for_parameters(
events: Sequence[ReplayBookEvent],
*,
pair_universe: set[str],
staleness_threshold_seconds: float,
) -> list[ReplayBookEvent]:
if staleness_threshold_seconds <= 0.0:
raise ValueError("staleness_threshold_seconds must be > 0")
filtered: list[ReplayBookEvent] = []
last_seen_by_symbol: dict[str, datetime] = {}
for event in events:
symbol = event.symbol.upper()
if symbol not in pair_universe:
continue
previous = last_seen_by_symbol.get(symbol)
last_seen_by_symbol[symbol] = event.occurred_at
if previous is None:
filtered.append(event)
continue
gap_seconds = (event.occurred_at - previous).total_seconds()
if gap_seconds <= staleness_threshold_seconds:
filtered.append(event)
return filtered
def _restrict_cycles_by_pair(
cycles_by_pair: Mapping[str, list[TriangularCycle]],
*,
pair_universe: set[str],
) -> dict[str, list[TriangularCycle]]:
restricted: dict[str, list[TriangularCycle]] = {}
for pair_symbol, cycles in cycles_by_pair.items():
normalized_pair = pair_symbol.upper()
if normalized_pair not in pair_universe:
continue
kept = [cycle for cycle in cycles if all(
pair.upper() in pair_universe for pair in cycle.pairs)]
if kept:
restricted[normalized_pair] = kept
return restricted
def _score_report(report: BacktestReport) -> float:
win_rate_bonus = (report.win_rate or 0.0) * 100.0
fill_rate_bonus = (report.fill_rate or 0.0) * 50.0
return report.realized_pnl_usd + win_rate_bonus + fill_rate_bonus - report.max_drawdown_usd
def _safe_ratio(numerator: float, denominator: float) -> float:
if denominator <= 0.0:
return 0.0 if numerator <= 0.0 else 1.0
return max(0.0, numerator / denominator)
def _evaluate_promotion(
*,
result: SweepResult,
criteria: PromotionCriteria,
) -> tuple[bool, tuple[str, ...]]:
reasons: list[str] = []
test = result.test_report
if test.realized_pnl_usd < criteria.min_test_realized_pnl_usd:
reasons.append(
"test_realized_pnl_below_threshold"
)
if (test.win_rate or 0.0) < criteria.min_test_win_rate:
reasons.append("test_win_rate_below_threshold")
if (test.fill_rate or 0.0) < criteria.min_test_fill_rate:
reasons.append("test_fill_rate_below_threshold")
if test.max_drawdown_usd > criteria.max_test_drawdown_usd:
reasons.append("test_drawdown_above_threshold")
if result.generalization_gap_ratio > criteria.max_generalization_gap_ratio:
reasons.append("generalization_gap_above_threshold")
return (not reasons), tuple(reasons)
def _run_backtest(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
available_pairs: Sequence[str],
config: BacktestConfig,
starting_balances: Mapping[str, float],
) -> BacktestReport:
started_at = events[0].occurred_at if events else datetime.now(UTC)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=config,
started_at=started_at,
)
return asyncio.run(engine.run(events, starting_balances=starting_balances))
def run_parameter_search(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
parameter_grid: Sequence[SweepParameters],
starting_balances: Mapping[str, float],
train_ratio: float,
promotion_criteria: PromotionCriteria | None = None,
max_concurrent_trades: int = 1,
max_depth_levels: int = 10,
quote_asset: str = "USD",
) -> SweepArtifacts:
criteria = promotion_criteria or PromotionCriteria()
train_events, test_events = split_events_time_windows(
events, train_ratio=train_ratio)
results: list[SweepResult] = []
promoted: list[SweepResult] = []
for parameters in parameter_grid:
allowed_pairs = set(parameters.pair_universe)
filtered_train = _filter_events_for_parameters(
train_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
filtered_test = _filter_events_for_parameters(
test_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
if not filtered_train or not filtered_test:
continue
restricted_cycles = _restrict_cycles_by_pair(
cycles_by_pair,
pair_universe=allowed_pairs,
)
if not restricted_cycles:
continue
config = BacktestConfig(
min_profit_threshold=parameters.min_profit_threshold,
trade_capital=parameters.trade_capital,
max_concurrent_trades=max_concurrent_trades,
max_depth_levels=max_depth_levels,
quote_asset=quote_asset,
)
train_report = _run_backtest(
events=filtered_train,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
test_report = _run_backtest(
events=filtered_test,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
train_score = _score_report(train_report)
test_score = _score_report(test_report)
score_drop = max(0.0, train_score - test_score)
generalization_gap_ratio = _safe_ratio(score_drop, abs(train_score))
overfit_detected = generalization_gap_ratio > criteria.max_generalization_gap_ratio
base_result = SweepResult(
parameters=parameters,
train_report=train_report,
test_report=test_report,
train_score=train_score,
test_score=test_score,
generalization_gap_ratio=generalization_gap_ratio,
overfit_detected=overfit_detected,
promotion_ready=False,
promotion_reasons=(),
train_event_count=len(filtered_train),
test_event_count=len(filtered_test),
)
promotion_ready, promotion_reasons = _evaluate_promotion(
result=base_result, criteria=criteria)
completed_result = SweepResult(
parameters=base_result.parameters,
train_report=base_result.train_report,
test_report=base_result.test_report,
train_score=base_result.train_score,
test_score=base_result.test_score,
generalization_gap_ratio=base_result.generalization_gap_ratio,
overfit_detected=base_result.overfit_detected,
promotion_ready=promotion_ready,
promotion_reasons=promotion_reasons,
train_event_count=base_result.train_event_count,
test_event_count=base_result.test_event_count,
)
results.append(completed_result)
if completed_result.promotion_ready:
promoted.append(completed_result)
results.sort(key=lambda item: item.test_score, reverse=True)
promoted.sort(key=lambda item: item.test_score, reverse=True)
train_window: tuple[datetime, datetime] | None = None
test_window: tuple[datetime, datetime] | None = None
if train_events:
train_window = (train_events[0].occurred_at,
train_events[-1].occurred_at)
if test_events:
test_window = (test_events[0].occurred_at, test_events[-1].occurred_at)
return SweepArtifacts(
results=tuple(results),
promoted=tuple(promoted),
train_window=train_window,
test_window=test_window,
)
def _report_to_dict(report: BacktestReport) -> dict[str, object]:
return {
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
"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,
}
def persist_sweep_results(path: Path, artifacts: SweepArtifacts) -> None:
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"train_window": (
{
"started_at": artifacts.train_window[0].isoformat(),
"finished_at": artifacts.train_window[1].isoformat(),
}
if artifacts.train_window is not None
else None
),
"test_window": (
{
"started_at": artifacts.test_window[0].isoformat(),
"finished_at": artifacts.test_window[1].isoformat(),
}
if artifacts.test_window is not None
else None
),
"results": [
{
"parameters": {
"min_profit_threshold": result.parameters.min_profit_threshold,
"trade_capital": result.parameters.trade_capital,
"pair_universe": list(result.parameters.pair_universe),
"staleness_threshold_seconds": result.parameters.staleness_threshold_seconds,
},
"train_report": _report_to_dict(result.train_report),
"test_report": _report_to_dict(result.test_report),
"train_score": result.train_score,
"test_score": result.test_score,
"generalization_gap_ratio": result.generalization_gap_ratio,
"overfit_detected": result.overfit_detected,
"promotion_ready": result.promotion_ready,
"promotion_reasons": list(result.promotion_reasons),
"train_event_count": result.train_event_count,
"test_event_count": result.test_event_count,
}
for result in artifacts.results
],
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(
payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
-39
View File
@@ -1,39 +0,0 @@
from __future__ import annotations
import base64
import os
from dataclasses import dataclass
import keyring
from cryptography.fernet import Fernet
@dataclass(slots=True)
class SecretStore:
service_name: str = "arbitrade"
def _load_or_create_key(self, key_env: str | None = None) -> bytes:
if key_env:
return key_env.encode("utf-8")
existing = keyring.get_password(self.service_name, "fernet_key")
if existing:
return existing.encode("utf-8")
generated = Fernet.generate_key()
keyring.set_password(self.service_name, "fernet_key", generated.decode("utf-8"))
return generated
def encrypt(self, plaintext: str, key_env: str | None = None) -> str:
key = self._load_or_create_key(key_env)
token = Fernet(key).encrypt(plaintext.encode("utf-8"))
return token.decode("utf-8")
def decrypt(self, ciphertext: str, key_env: str | None = None) -> str:
key = self._load_or_create_key(key_env)
value = Fernet(key).decrypt(ciphertext.encode("utf-8"))
return value.decode("utf-8")
@staticmethod
def generate_env_key() -> str:
return base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
-219
View File
@@ -1,219 +0,0 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_ignore_empty=True,
)
app_env: str = Field(default="dev", alias="APP_ENV")
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
app_port: int = Field(default=9090, alias="APP_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_json: bool = Field(default=True, alias="LOG_JSON")
dashboard_auth_username: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_USERNAME",
)
dashboard_auth_password: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_PASSWORD",
)
alerts_enabled: bool = Field(default=True, alias="ALERTS_ENABLED")
alert_min_severity: str = Field(
default="warning", alias="ALERT_MIN_SEVERITY")
alert_dedup_seconds: float = Field(
default=30.0, alias="ALERT_DEDUP_SECONDS")
alert_on_trade_events: bool = Field(
default=True, alias="ALERT_ON_TRADE_EVENTS")
alert_on_error_events: bool = Field(
default=True, alias="ALERT_ON_ERROR_EVENTS")
alert_on_threshold_events: bool = Field(
default=True, alias="ALERT_ON_THRESHOLD_EVENTS")
alert_on_system_events: bool = Field(
default=True, alias="ALERT_ON_SYSTEM_EVENTS")
telegram_alerts_enabled: bool = Field(
default=False, alias="TELEGRAM_ALERTS_ENABLED")
telegram_bot_token: str | None = Field(
default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(
default=None, alias="TELEGRAM_CHAT_ID")
discord_alerts_enabled: bool = Field(
default=False, alias="DISCORD_ALERTS_ENABLED")
discord_webhook_url: str | None = Field(
default=None, alias="DISCORD_WEBHOOK_URL")
email_alerts_enabled: bool = Field(
default=False, alias="EMAIL_ALERTS_ENABLED")
email_smtp_host: str | None = Field(default=None, alias="EMAIL_SMTP_HOST")
email_smtp_port: int = Field(default=587, alias="EMAIL_SMTP_PORT")
email_smtp_username: str | None = Field(
default=None, alias="EMAIL_SMTP_USERNAME")
email_smtp_password: str | None = Field(
default=None, alias="EMAIL_SMTP_PASSWORD")
email_alert_from: str | None = Field(
default=None, alias="EMAIL_ALERT_FROM")
email_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
duckdb_path: Path = Field(default=Path(
"./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
kraken_rest_url: str = Field(
default="https://api.kraken.com", alias="KRAKEN_REST_URL")
kraken_ws_url: str = Field(
default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
kraken_private_rate_limit_seconds: float = Field(
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
)
kraken_http_timeout_seconds: float = Field(
default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
kraken_retry_attempts: int = Field(
default=3, alias="KRAKEN_RETRY_ATTEMPTS")
kraken_retry_base_delay_seconds: float = Field(
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
)
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
kraken_api_secret: str | None = Field(
default=None, alias="KRAKEN_API_SECRET")
kraken_api_key_permissions: str = Field(
default="query,trade",
alias="KRAKEN_API_KEY_PERMISSIONS",
)
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")
strategy_enable_stat_arb_experiment: bool = Field(
default=False,
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
)
strategy_stat_arb_lookback_window: int = Field(
default=120,
alias="STRATEGY_STAT_ARB_LOOKBACK_WINDOW",
)
strategy_stat_arb_entry_zscore: float = Field(
default=2.0,
alias="STRATEGY_STAT_ARB_ENTRY_ZSCORE",
)
strategy_stat_arb_exit_zscore: float = Field(
default=0.5,
alias="STRATEGY_STAT_ARB_EXIT_ZSCORE",
)
strategy_stat_arb_max_holding_seconds: float = Field(
default=900.0,
alias="STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS",
)
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
max_trade_capital_usd: float = Field(
default=100.0, alias="MAX_TRADE_CAPITAL_USD")
max_concurrent_trades: int | None = Field(
default=None, alias="MAX_CONCURRENT_TRADES")
max_exposure_per_asset_usd: float | None = Field(
default=None,
alias="MAX_EXPOSURE_PER_ASSET_USD",
)
quote_balance_asset: str = Field(
default="USD", alias="QUOTE_BALANCE_ASSET")
min_order_size_usd: float | None = Field(
default=None, alias="MIN_ORDER_SIZE_USD")
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
daily_loss_limit_usd: float | None = Field(
default=None, alias="DAILY_LOSS_LIMIT_USD")
cumulative_loss_limit_usd: float | None = Field(
default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
max_source_latency_ms: float | None = Field(
default=None, alias="MAX_SOURCE_LATENCY_MS")
max_apply_latency_ms: float | None = Field(
default=None, alias="MAX_APPLY_LATENCY_MS")
max_consecutive_failures: int | None = Field(
default=None, alias="MAX_CONSECUTIVE_FAILURES")
fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
@field_validator("app_env")
@classmethod
def _validate_app_env(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"dev", "test", "prod"}:
raise ValueError("APP_ENV must be one of: dev, test, prod")
return normalized
@field_validator("log_level")
@classmethod
def _validate_log_level(cls, value: str) -> str:
normalized = value.strip().upper()
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(
"LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
return normalized
@field_validator("alert_min_severity")
@classmethod
def _validate_alert_severity(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"info", "warning", "error", "critical"}:
raise ValueError(
"ALERT_MIN_SEVERITY must be one of: info, warning, error, critical")
return normalized
@model_validator(mode="after")
def _validate_security_constraints(self) -> Settings:
if bool(self.dashboard_auth_username) ^ bool(self.dashboard_auth_password):
raise ValueError(
"dashboard auth requires both username and password")
if bool(self.kraken_api_key) ^ bool(self.kraken_api_secret):
raise ValueError(
"Kraken API auth requires both API key and secret")
permissions = {
token.strip().lower()
for token in self.kraken_api_key_permissions.split(",")
if token.strip()
}
if permissions and ("query" not in permissions or "trade" not in permissions):
raise ValueError(
"KRAKEN_API_KEY_PERMISSIONS must include query and trade")
if "withdraw" in permissions or "withdrawals" in permissions:
raise ValueError(
"KRAKEN_API_KEY_PERMISSIONS must not include withdrawal scope")
if self.alert_dedup_seconds < 0.0:
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
if self.strategy_stat_arb_lookback_window < 2:
raise ValueError("STRATEGY_STAT_ARB_LOOKBACK_WINDOW must be >= 2")
if self.strategy_stat_arb_entry_zscore <= 0.0:
raise ValueError("STRATEGY_STAT_ARB_ENTRY_ZSCORE must be > 0")
if self.strategy_stat_arb_exit_zscore < 0.0:
raise ValueError("STRATEGY_STAT_ARB_EXIT_ZSCORE must be >= 0")
if self.strategy_stat_arb_entry_zscore <= self.strategy_stat_arb_exit_zscore:
raise ValueError(
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
)
if self.strategy_stat_arb_max_holding_seconds <= 0.0:
raise ValueError(
"STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
-12
View File
@@ -1,12 +0,0 @@
"""Arbitrage detection package."""
from arbitrade.detection.engine import CycleScore, IncrementalCycleDetector, OpportunityEvent
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
__all__ = [
"CurrencyGraph",
"TriangularCycle",
"CycleScore",
"OpportunityEvent",
"IncrementalCycleDetector",
]
-113
View File
@@ -1,113 +0,0 @@
from __future__ import annotations
import argparse
import statistics
import time
from dataclasses import asdict, dataclass
import orjson
from arbitrade.detection.engine import IncrementalCycleDetector
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.models import BookLevel
from arbitrade.market_data.order_book import OrderBook
@dataclass(frozen=True, slots=True)
class DetectionBenchmarkResult:
iterations: int
total_ms: float
avg_ms: float
p50_ms: float
p95_ms: float
max_ms: float
target_ms: float
@property
def meets_target(self) -> bool:
return self.p95_ms <= self.target_ms
def _make_book(*, bid: float, ask: float) -> OrderBook:
book = OrderBook()
book.apply_bids([BookLevel(price=bid, volume=10.0)])
book.apply_asks([BookLevel(price=ask, volume=10.0)])
return book
def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, OrderBook]]:
asset_pairs = {
"XXBTZUSD": {"wsname": "BTC/USD"},
"XETHXXBT": {"wsname": "ETH/BTC"},
"XETHZUSD": {"wsname": "ETH/USD"},
}
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
cycles = graph.triangular_cycles()
index = graph.index_cycles_by_pair(cycles)
detector = IncrementalCycleDetector(
index,
fee_rate=0.001,
min_profit_threshold=0.001,
max_depth_levels=5,
max_book_age_seconds=10.0,
)
books = {
"BTC/USD": _make_book(bid=99.9, ask=100.0),
"ETH/BTC": _make_book(bid=0.049, ask=0.05),
"ETH/USD": _make_book(bid=5.2, ask=5.21),
}
return detector, books
def run_incremental_detection_benchmark(
*,
iterations: int = 50_000,
target_ms: float = 1.0,
) -> DetectionBenchmarkResult:
if iterations <= 0:
raise ValueError("iterations must be > 0")
detector, books = _build_detector_and_books()
samples_ms: list[float] = []
started_ns = time.perf_counter_ns()
for _ in range(iterations):
t0_ns = time.perf_counter_ns()
detector.score_updated_pair("ETH/BTC", books)
elapsed_ms = (time.perf_counter_ns() - t0_ns) / 1_000_000
samples_ms.append(elapsed_ms)
total_ms = (time.perf_counter_ns() - started_ns) / 1_000_000
return DetectionBenchmarkResult(
iterations=iterations,
total_ms=total_ms,
avg_ms=statistics.fmean(samples_ms),
p50_ms=statistics.quantiles(samples_ms, n=100)[49],
p95_ms=statistics.quantiles(samples_ms, n=100)[94],
max_ms=max(samples_ms),
target_ms=target_ms,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Benchmark incremental detection latency")
parser.add_argument("--iterations", type=int, default=50_000)
parser.add_argument("--target-ms", type=float, default=1.0)
args = parser.parse_args()
result = run_incremental_detection_benchmark(
iterations=args.iterations,
target_ms=args.target_ms,
)
payload = {
**asdict(result),
"meets_target": result.meets_target,
}
print(orjson.dumps(payload).decode("utf-8"))
if __name__ == "__main__":
main()
-295
View File
@@ -1,295 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from arbitrade.detection.graph import TriangularCycle
from arbitrade.exchange.models import BookLevel
from arbitrade.market_data.order_book import OrderBook
def _normalize_pair_symbol(symbol: str) -> str:
if "/" not in symbol:
return symbol.upper()
base, quote = symbol.split("/", 1)
return f"{base.upper()}/{quote.upper()}"
@dataclass(frozen=True, slots=True)
class CycleScore:
cycle: TriangularCycle
gross_rate: float
net_rate: float
min_profit_threshold: float
updated_pair: str
scored_at: datetime
@property
def is_profitable(self) -> bool:
return (self.net_rate - 1.0) >= self.min_profit_threshold
@dataclass(frozen=True, slots=True)
class OpportunityEvent:
detected_at: datetime
cycle: str
updated_pair: str
gross_rate: float
net_rate: float
gross_pct: float
net_pct: float
est_profit: float
allocated_capital: float = 1.0
@classmethod
def from_cycle_score(cls, score: CycleScore, base_capital: float = 1.0) -> OpportunityEvent:
gross_pct = (score.gross_rate - 1.0) * 100.0
net_pct = (score.net_rate - 1.0) * 100.0
est_profit = (score.net_rate - 1.0) * base_capital
a, b, c = score.cycle.currencies
cycle = f"{a}->{b}->{c}->{a}"
return cls(
detected_at=score.scored_at,
cycle=cycle,
updated_pair=score.updated_pair,
gross_rate=score.gross_rate,
net_rate=score.net_rate,
gross_pct=gross_pct,
net_pct=net_pct,
est_profit=est_profit,
allocated_capital=base_capital,
)
class IncrementalCycleDetector:
def __init__(
self,
cycles_by_pair: Mapping[str, list[TriangularCycle]],
*,
fee_rate: float = 0.0,
max_depth_levels: int = 10,
min_profit_threshold: float = 0.0,
min_order_size_by_pair: Mapping[str, float] | None = None,
max_book_age_seconds: float | None = None,
) -> None:
self._cycles_by_pair = {
_normalize_pair_symbol(pair): list(cycles) for pair, cycles in cycles_by_pair.items()
}
self._fee_multiplier = 1.0 - fee_rate
self._max_depth_levels = max_depth_levels
self._min_profit_threshold = min_profit_threshold
self._max_book_age_seconds = max_book_age_seconds
self._min_order_size_by_pair = {
_normalize_pair_symbol(pair): float(min_size)
for pair, min_size in (min_order_size_by_pair or {}).items()
}
if self._fee_multiplier < 0.0:
raise ValueError("fee_rate must be <= 1.0")
if self._max_depth_levels <= 0:
raise ValueError("max_depth_levels must be > 0")
if self._min_profit_threshold < 0.0:
raise ValueError("min_profit_threshold must be >= 0.0")
if self._max_book_age_seconds is not None and self._max_book_age_seconds <= 0.0:
raise ValueError("max_book_age_seconds must be > 0.0")
for min_size in self._min_order_size_by_pair.values():
if min_size <= 0.0:
raise ValueError("minimum order size must be > 0.0")
def score_updated_pair(
self,
updated_pair: str,
books: Mapping[str, OrderBook],
) -> list[CycleScore]:
normalized_pair = _normalize_pair_symbol(updated_pair)
impacted_cycles = self._cycles_by_pair.get(normalized_pair, [])
normalized_books = {_normalize_pair_symbol(symbol): book for symbol, book in books.items()}
scores: list[CycleScore] = []
scored_at = datetime.now(UTC)
for cycle in impacted_cycles:
rates = self._score_cycle(cycle, normalized_books, scored_at)
if rates is None:
continue
gross_rate, net_rate = rates
if (net_rate - 1.0) < self._min_profit_threshold:
continue
scores.append(
CycleScore(
cycle=cycle,
gross_rate=gross_rate,
net_rate=net_rate,
min_profit_threshold=self._min_profit_threshold,
updated_pair=normalized_pair,
scored_at=scored_at,
)
)
return scores
def opportunities_for_updated_pair(
self,
updated_pair: str,
books: Mapping[str, OrderBook],
*,
base_capital: float = 1.0,
) -> list[OpportunityEvent]:
if base_capital <= 0.0:
raise ValueError("base_capital must be > 0.0")
scores = self.score_updated_pair(updated_pair, books)
return [OpportunityEvent.from_cycle_score(score, base_capital) for score in scores]
def _score_cycle(
self,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
scored_at: datetime,
) -> tuple[float, float] | None:
if not self._is_cycle_fresh(cycle, books, scored_at):
return None
a, b, c = cycle.currencies
gross_amount = 1.0
gross_ab = self._convert(gross_amount, a, b, cycle, books)
if gross_ab is None:
return None
net_ab = gross_ab * self._fee_multiplier
gross_bc = self._convert(gross_ab, b, c, cycle, books)
if gross_bc is None:
return None
net_bc = self._convert(net_ab, b, c, cycle, books)
if net_bc is None:
return None
net_bc *= self._fee_multiplier
gross_ca = self._convert(gross_bc, c, a, cycle, books)
if gross_ca is None:
return None
net_ca = self._convert(net_bc, c, a, cycle, books)
if net_ca is None:
return None
net_ca *= self._fee_multiplier
return gross_ca, net_ca
def _is_cycle_fresh(
self,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
scored_at: datetime,
) -> bool:
if self._max_book_age_seconds is None:
return True
for pair in cycle.pairs:
normalized_pair = _normalize_pair_symbol(pair)
book = books.get(normalized_pair)
if book is None:
return False
age_seconds = (scored_at - book.updated_at).total_seconds()
if age_seconds > self._max_book_age_seconds:
return False
return True
@staticmethod
def _pair_for_edge(cycle: TriangularCycle, from_currency: str, to_currency: str) -> str | None:
for pair in cycle.pairs:
if "/" not in pair:
continue
base, quote = pair.split("/", 1)
base = base.upper()
quote = quote.upper()
if {base, quote} == {from_currency, to_currency}:
return f"{base}/{quote}"
return None
def _convert(
self,
amount: float,
from_currency: str,
to_currency: str,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
) -> float | None:
pair = self._pair_for_edge(cycle, from_currency, to_currency)
if pair is None:
return None
book = books.get(pair)
if book is None:
return None
bids, asks = book.top_levels(depth=self._max_depth_levels)
base, quote = pair.split("/", 1)
base = base.upper()
quote = quote.upper()
if from_currency == base and to_currency == quote:
quote_out = self._sell_base_for_quote(amount, bids)
if quote_out is None:
return None
if not self._is_min_order_size_satisfied(pair, amount):
return None
return quote_out
if from_currency == quote and to_currency == base:
base_out = self._buy_base_with_quote(amount, asks)
if base_out is None:
return None
if not self._is_min_order_size_satisfied(pair, base_out):
return None
return base_out
return None
def _is_min_order_size_satisfied(self, pair: str, base_amount: float) -> bool:
min_size = self._min_order_size_by_pair.get(pair)
if min_size is None:
return True
return base_amount >= min_size
@staticmethod
def _sell_base_for_quote(amount_base: float, bids: list[BookLevel]) -> float | None:
remaining = amount_base
quote_out = 0.0
for level in bids:
if remaining <= 0.0:
break
if level.price <= 0.0 or level.volume <= 0.0:
continue
executed = min(remaining, level.volume)
quote_out += executed * level.price
remaining -= executed
if remaining > 0.0:
return None
return quote_out
@staticmethod
def _buy_base_with_quote(amount_quote: float, asks: list[BookLevel]) -> float | None:
remaining_quote = amount_quote
base_out = 0.0
for level in asks:
if remaining_quote <= 0.0:
break
if level.price <= 0.0 or level.volume <= 0.0:
continue
level_quote_capacity = level.volume * level.price
spend = min(remaining_quote, level_quote_capacity)
base_out += spend / level.price
remaining_quote -= spend
if remaining_quote > 0.0:
return None
return base_out
-90
View File
@@ -1,90 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class TriangularCycle:
currencies: tuple[str, str, str]
pairs: tuple[str, str, str]
def _canonical_pair(base: str, quote: str) -> str:
return f"{base}/{quote}"
class CurrencyGraph:
def __init__(self) -> None:
self._adjacency: dict[str, set[str]] = {}
self._pair_by_direction: dict[tuple[str, str], str] = {}
@property
def adjacency(self) -> dict[str, set[str]]:
return self._adjacency
@property
def pair_by_direction(self) -> dict[tuple[str, str], str]:
return self._pair_by_direction
def add_pair(self, base: str, quote: str, pair_symbol: str | None = None) -> None:
normalized_base = base.upper()
normalized_quote = quote.upper()
symbol = pair_symbol or _canonical_pair(normalized_base, normalized_quote)
self._adjacency.setdefault(normalized_base, set()).add(normalized_quote)
self._adjacency.setdefault(normalized_quote, set()).add(normalized_base)
self._pair_by_direction[(normalized_base, normalized_quote)] = symbol
self._pair_by_direction[(normalized_quote, normalized_base)] = symbol
@classmethod
def from_kraken_asset_pairs(cls, asset_pairs: dict[str, Any]) -> CurrencyGraph:
graph = cls()
for value in asset_pairs.values():
if not isinstance(value, dict):
continue
wsname = value.get("wsname")
if isinstance(wsname, str) and "/" in wsname:
base, quote = wsname.split("/", 1)
graph.add_pair(base, quote, wsname)
continue
raw_base = value.get("base")
raw_quote = value.get("quote")
if isinstance(raw_base, str) and isinstance(raw_quote, str):
graph.add_pair(raw_base, raw_quote)
return graph
def triangular_cycles(self) -> list[TriangularCycle]:
found: dict[tuple[str, str, str], TriangularCycle] = {}
for a, neighbors_a in self._adjacency.items():
for b in neighbors_a:
if a >= b:
continue
neighbors_b = self._adjacency.get(b, set())
for c in neighbors_b:
if b >= c:
continue
if a not in self._adjacency.get(c, set()):
continue
p_ab = self._pair_by_direction[(a, b)]
p_bc = self._pair_by_direction[(b, c)]
p_ca = self._pair_by_direction[(c, a)]
key = (a, b, c)
found[key] = TriangularCycle(currencies=key, pairs=(p_ab, p_bc, p_ca))
return list(found.values())
@staticmethod
def index_cycles_by_pair(cycles: list[TriangularCycle]) -> dict[str, list[TriangularCycle]]:
index: dict[str, list[TriangularCycle]] = {}
for cycle in cycles:
for pair in cycle.pairs:
index.setdefault(pair, []).append(cycle)
return index
-1
View File
@@ -1 +0,0 @@
"""Kraken exchange integration package."""
-281
View File
@@ -1,281 +0,0 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from urllib.parse import urlencode
import httpx
import structlog
from arbitrade.config.settings import Settings
from arbitrade.exchange.models import KrakenApiResult, LatencySample
from arbitrade.exchange.signing import sign_kraken_private_path
_LOG = structlog.get_logger(__name__)
def _result_dict(payload: dict[str, Any]) -> dict[str, Any]:
result = payload.get("result", {})
if isinstance(result, dict):
return result
return {}
class KrakenRestClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = httpx.AsyncClient(
base_url=settings.kraken_rest_url,
timeout=settings.kraken_http_timeout_seconds,
limits=httpx.Limits(max_keepalive_connections=10, max_connections=50),
headers={"User-Agent": "arbitrade/0.1.0"},
)
self._private_lock = asyncio.Lock()
issues = self.validate_compliance()
if issues:
_LOG.warning("kraken_compliance_issues", issues=issues)
else:
_LOG.info("kraken_compliance_ok")
def validate_compliance(self) -> list[str]:
issues: list[str] = []
if not self._settings.kraken_rest_url.startswith("https://"):
issues.append("KRAKEN_REST_URL should use https://")
if self._settings.kraken_private_rate_limit_seconds < 1.0:
issues.append("KRAKEN_PRIVATE_RATE_LIMIT_SECONDS below 1.0 may violate Kraken limits")
if self._settings.kraken_retry_attempts < 1:
issues.append("KRAKEN_RETRY_ATTEMPTS must be >= 1")
if self._settings.kraken_retry_base_delay_seconds < 0:
issues.append("KRAKEN_RETRY_BASE_DELAY_SECONDS must be >= 0")
return issues
async def close(self) -> None:
await self._client.aclose()
async def warm_connection_pool(self) -> None:
await self.server_time()
async def _request_with_retry(
self,
endpoint: str,
params: dict[str, Any] | None = None,
) -> KrakenApiResult:
attempts = self._settings.kraken_retry_attempts
delay = self._settings.kraken_retry_base_delay_seconds
params = params or {}
for attempt in range(1, attempts + 1):
t0 = time.perf_counter()
try:
response = await self._client.get(endpoint, params=params)
response.raise_for_status()
payload = response.json()
if payload.get("error"):
raise RuntimeError(f"Kraken error: {payload['error']}")
latency = (time.perf_counter() - t0) * 1000
_LOG.info(
"kraken_rest_request_ok",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
sample=LatencySample.now("rest_request", latency_ms=latency).latency_ms,
)
return KrakenApiResult(endpoint=endpoint, payload=payload)
except Exception as exc:
latency = (time.perf_counter() - t0) * 1000
_LOG.warning(
"kraken_rest_request_failed",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
error=str(exc),
)
if attempt >= attempts:
raise
await asyncio.sleep(delay * (2 ** (attempt - 1)))
raise RuntimeError("unreachable retry loop")
async def _private_post_with_retry(
self,
endpoint: str,
data: dict[str, str] | None = None,
) -> KrakenApiResult:
api_key = self._settings.kraken_api_key
api_secret = self._settings.kraken_api_secret
if not api_key or not api_secret:
raise RuntimeError("Missing Kraken API credentials for private endpoint")
attempts = self._settings.kraken_retry_attempts
delay = self._settings.kraken_retry_base_delay_seconds
for attempt in range(1, attempts + 1):
t0 = time.perf_counter()
try:
nonce = str(int(time.time() * 1000))
payload = {"nonce": nonce}
if data is not None:
payload.update(data)
encoded = urlencode(payload)
signature = sign_kraken_private_path(endpoint, nonce, encoded, api_secret)
response = await self._client.post(
endpoint,
data=payload,
headers={
"API-Key": api_key,
"API-Sign": signature,
},
)
response.raise_for_status()
body = response.json()
if body.get("error"):
raise RuntimeError(f"Kraken error: {body['error']}")
latency = (time.perf_counter() - t0) * 1000
_LOG.info(
"kraken_private_rest_request_ok",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
sample=LatencySample.now("private_rest_request", latency_ms=latency).latency_ms,
)
return KrakenApiResult(endpoint=endpoint, payload=body)
except Exception as exc:
latency = (time.perf_counter() - t0) * 1000
_LOG.warning(
"kraken_private_rest_request_failed",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
error=str(exc),
)
if attempt >= attempts:
raise
await asyncio.sleep(delay * (2 ** (attempt - 1)))
raise RuntimeError("unreachable retry loop")
async def server_time(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/Time")
return _result_dict(result.payload)
async def assets(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/Assets")
return _result_dict(result.payload)
async def asset_pairs(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/AssetPairs")
return _result_dict(result.payload)
async def _throttled_private_call(
self,
endpoint: str,
data: dict[str, str] | None = None,
) -> dict[str, Any]:
async with self._private_lock:
result = await self._private_post_with_retry(endpoint, data=data)
await asyncio.sleep(self._settings.kraken_private_rate_limit_seconds)
return _result_dict(result.payload)
async def balances(self) -> dict[str, Any]:
return await self._throttled_private_call("/0/private/Balance")
async def place_market_order(
self,
*,
pair: str,
side: str,
volume: float,
user_ref: int | None = None,
) -> dict[str, Any]:
normalized_side = side.lower()
if normalized_side not in {"buy", "sell"}:
raise ValueError("side must be 'buy' or 'sell'")
if volume <= 0.0:
raise ValueError("volume must be > 0.0")
if user_ref is not None and user_ref < 0:
raise ValueError("user_ref must be >= 0")
data = {
"pair": pair,
"type": normalized_side,
"ordertype": "market",
"volume": str(volume),
}
if user_ref is not None:
data["userref"] = str(user_ref)
return await self._throttled_private_call(
"/0/private/AddOrder",
data=data,
)
async def place_limit_order(
self,
*,
pair: str,
side: str,
volume: float,
price: float,
user_ref: int | None = None,
) -> dict[str, Any]:
normalized_side = side.lower()
if normalized_side not in {"buy", "sell"}:
raise ValueError("side must be 'buy' or 'sell'")
if volume <= 0.0:
raise ValueError("volume must be > 0.0")
if price <= 0.0:
raise ValueError("price must be > 0.0")
if user_ref is not None and user_ref < 0:
raise ValueError("user_ref must be >= 0")
data = {
"pair": pair,
"type": normalized_side,
"ordertype": "limit",
"price": str(price),
"volume": str(volume),
}
if user_ref is not None:
data["userref"] = str(user_ref)
return await self._throttled_private_call(
"/0/private/AddOrder",
data=data,
)
async def query_order(
self,
*,
order_id: str,
include_trades: bool = True,
) -> dict[str, Any]:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
return await self._throttled_private_call(
"/0/private/QueryOrders",
data={
"txid": order_id,
"trades": "true" if include_trades else "false",
},
)
async def cancel_order(self, *, order_id: str) -> dict[str, Any]:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
return await self._throttled_private_call(
"/0/private/CancelOrder",
data={"txid": order_id},
)
-177
View File
@@ -1,177 +0,0 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import orjson
import structlog
import websockets
from arbitrade.alerting.notifier import AlertSeverity, SupportsAlerts
from arbitrade.config.settings import Settings
from arbitrade.exchange.models import BookDelta, BookLevel
_LOG = structlog.get_logger(__name__)
@dataclass(slots=True)
class WsMessage:
received_at: datetime
payload: dict[str, Any]
class KrakenWsClient:
def __init__(self, settings: Settings, *, alert_notifier: SupportsAlerts | None = None) -> None:
self._settings = settings
self._last_message_at: datetime | None = None
self._stop = asyncio.Event()
self._alert_notifier = alert_notifier
self._has_connected_once = False
self._was_disconnected = False
@property
def is_stale(self) -> bool:
if self._last_message_at is None:
return True
return (
datetime.now(UTC) - self._last_message_at
).total_seconds() > self._settings.ws_max_staleness_seconds
async def stop(self) -> None:
self._stop.set()
async def connect_stream(self) -> AsyncIterator[WsMessage]:
delay = 1.0
while not self._stop.is_set():
try:
async with websockets.connect(
self._settings.kraken_ws_url, max_size=2_000_000
) as ws:
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
if self._has_connected_once and self._was_disconnected:
await self._notify(
category="system",
severity="info",
title="WebSocket reconnected",
message="Kraken WebSocket connection restored.",
details={"url": self._settings.kraken_ws_url},
)
self._has_connected_once = True
self._was_disconnected = False
delay = 1.0
async for raw in self._recv_loop(ws):
yield raw
except Exception as exc:
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay)
self._was_disconnected = True
await self._notify(
category="system",
severity="warning",
title="WebSocket disconnected",
message="Kraken WebSocket disconnected, reconnect scheduled.",
details={
"error": str(exc),
"reconnect_in_seconds": f"{delay}",
},
)
await asyncio.sleep(delay)
delay = min(delay * 2, 30.0)
async def _recv_loop(self, ws: Any) -> AsyncIterator[WsMessage]:
while not self._stop.is_set():
t0 = time.perf_counter()
try:
raw = await asyncio.wait_for(
ws.recv(), timeout=self._settings.ws_heartbeat_timeout_seconds
)
except TimeoutError:
await self._notify(
category="system",
severity="critical",
title="WebSocket staleness abort",
message="No WebSocket heartbeat within configured timeout; reconnecting.",
details={
"heartbeat_timeout_seconds": (
f"{self._settings.ws_heartbeat_timeout_seconds}"
),
},
)
raise
parse_start = time.perf_counter()
payload = orjson.loads(raw)
self._last_message_at = datetime.now(UTC)
_LOG.debug(
"kraken_ws_message",
recv_latency_ms=(parse_start - t0) * 1000,
parse_latency_ms=(time.perf_counter() - parse_start) * 1000,
)
if isinstance(payload, dict):
yield WsMessage(received_at=self._last_message_at, payload=payload)
async def _notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> None:
if self._alert_notifier is None:
return
await self._alert_notifier.notify(
category=category,
severity=severity,
title=title,
message=message,
details=details,
)
@staticmethod
def parse_book_delta(message: dict[str, Any]) -> BookDelta | None:
# Kraken v2 book update shape can vary by channel; keep parser defensive.
channel = str(message.get("channel", ""))
if "book" not in channel:
return None
symbol = str(message.get("symbol", ""))
data = message.get("data")
if not isinstance(data, list) or not data:
return None
first = data[0]
if not isinstance(first, dict):
return None
bids = [
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
for level in first.get("bids", [])
if isinstance(level, dict) and "price" in level and "qty" in level
]
asks = [
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
for level in first.get("asks", [])
if isinstance(level, dict) and "price" in level and "qty" in level
]
checksum: int | None = None
raw_checksum = first.get("checksum")
if isinstance(raw_checksum, int):
checksum = raw_checksum
source_timestamp_ms: int | None = None
if isinstance(first.get("timestamp"), int):
source_timestamp_ms = first["timestamp"]
return BookDelta(
symbol=symbol,
bids=bids,
asks=asks,
checksum=checksum,
source_timestamp_ms=source_timestamp_ms,
)
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@dataclass(slots=True)
class KrakenApiResult:
endpoint: str
payload: dict[str, Any]
@dataclass(slots=True)
class LatencySample:
stage: str
at: datetime
latency_ms: float
@classmethod
def now(cls, stage: str, latency_ms: float) -> LatencySample:
return cls(stage=stage, at=datetime.now(UTC), latency_ms=latency_ms)
@dataclass(slots=True)
class BookLevel:
price: float
volume: float
@dataclass(slots=True)
class BookDelta:
symbol: str
bids: list[BookLevel]
asks: list[BookLevel]
checksum: int | None = None
source_timestamp_ms: int | None = None
-14
View File
@@ -1,14 +0,0 @@
from __future__ import annotations
import base64
import hashlib
import hmac
from functools import lru_cache
@lru_cache(maxsize=2048)
def sign_kraken_private_path(path: str, nonce: str, post_data: str, api_secret: str) -> str:
message = nonce.encode("utf-8") + post_data.encode("utf-8")
sha256 = hashlib.sha256(message).digest()
mac = hmac.new(base64.b64decode(api_secret), path.encode("utf-8") + sha256, hashlib.sha512)
return base64.b64encode(mac.digest()).decode("utf-8")
-32
View File
@@ -1,32 +0,0 @@
"""Trade execution helpers."""
from arbitrade.execution.fill_monitor import (
FillMonitor,
FillMonitorResult,
OrderFillState,
)
from arbitrade.execution.idempotency import (
IdempotencyKeyFactory,
OrderReconciler,
ReconciliationReport,
)
from arbitrade.execution.recovery import PartialFillRecovery, RecoveryAction
from arbitrade.execution.sequencer import (
ExecutionLeg,
TriangularExecutionResult,
TriangularExecutionSequencer,
)
__all__ = [
"ExecutionLeg",
"OrderFillState",
"FillMonitorResult",
"FillMonitor",
"IdempotencyKeyFactory",
"ReconciliationReport",
"OrderReconciler",
"RecoveryAction",
"PartialFillRecovery",
"TriangularExecutionResult",
"TriangularExecutionSequencer",
]
@@ -1,133 +0,0 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
class SupportsOrderStatusPolling(Protocol):
async def query_order(
self, *, order_id: str, include_trades: bool = True
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class OrderFillState:
order_id: str
status: str
filled_volume: float | None
avg_price: float | None
updated_at: datetime
source: str
@property
def is_terminal(self) -> bool:
return self.status in {"closed", "canceled", "expired"}
@dataclass(frozen=True, slots=True)
class FillMonitorResult:
order_id: str
timed_out: bool
terminal_state: OrderFillState | None
last_state: OrderFillState | None
elapsed_seconds: float
class FillMonitor:
def __init__(
self,
poll_client: SupportsOrderStatusPolling,
*,
poll_interval_seconds: float = 0.5,
max_wait_seconds: float = 10.0,
ws_status_provider: Callable[[str], OrderFillState | None] | None = None,
) -> None:
if poll_interval_seconds <= 0.0:
raise ValueError("poll_interval_seconds must be > 0.0")
if max_wait_seconds <= 0.0:
raise ValueError("max_wait_seconds must be > 0.0")
self._poll_client = poll_client
self._poll_interval_seconds = poll_interval_seconds
self._max_wait_seconds = max_wait_seconds
self._ws_status_provider = ws_status_provider
@staticmethod
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def _state_from_payload(
cls, order_id: str, payload: dict[str, Any], *, source: str
) -> OrderFillState:
status = str(payload.get("status", "unknown")).lower()
return OrderFillState(
order_id=order_id,
status=status,
filled_volume=cls._to_float(payload.get("vol_exec")),
avg_price=cls._to_float(payload.get("price") or payload.get("avg_price")),
updated_at=datetime.now(UTC),
source=source,
)
@classmethod
def _extract_order_payload(cls, order_id: str, response: dict[str, Any]) -> dict[str, Any]:
if order_id in response and isinstance(response[order_id], dict):
payload = response[order_id]
return {str(key): value for key, value in payload.items()}
return response
async def wait_for_terminal_fill(self, order_id: str) -> FillMonitorResult:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
started = time.monotonic()
last_state: OrderFillState | None = None
while True:
elapsed = time.monotonic() - started
if elapsed >= self._max_wait_seconds:
return FillMonitorResult(
order_id=order_id,
timed_out=True,
terminal_state=None,
last_state=last_state,
elapsed_seconds=elapsed,
)
if self._ws_status_provider is not None:
ws_state = self._ws_status_provider(order_id)
if ws_state is not None:
last_state = ws_state
if ws_state.is_terminal:
return FillMonitorResult(
order_id=order_id,
timed_out=False,
terminal_state=ws_state,
last_state=ws_state,
elapsed_seconds=elapsed,
)
response = await self._poll_client.query_order(order_id=order_id, include_trades=True)
payload = self._extract_order_payload(order_id, response)
polled_state = self._state_from_payload(order_id, payload, source="rest_poll")
last_state = polled_state
if polled_state.is_terminal:
return FillMonitorResult(
order_id=order_id,
timed_out=False,
terminal_state=polled_state,
last_state=polled_state,
elapsed_seconds=time.monotonic() - started,
)
await asyncio.sleep(self._poll_interval_seconds)
@@ -1,105 +0,0 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Protocol
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import ExecutionLeg
class SupportsOrderHistoryLookup(Protocol):
async def query_order(
self, *, order_id: str, include_trades: bool = True
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class ReconciliationReport:
order_id: str
user_ref: int
status: str
filled_volume: float | None
avg_price: float | None
is_terminal: bool
matches_request: bool
raw_payload: dict[str, Any]
class IdempotencyKeyFactory:
def user_ref_for_leg(self, event: OpportunityEvent, leg: ExecutionLeg, leg_index: int) -> int:
material = "|".join(
[
event.cycle,
event.updated_pair,
leg.from_currency,
leg.to_currency,
leg.pair,
leg.side,
f"{leg.volume:.12f}",
str(leg_index),
]
).encode("utf-8")
digest = hashlib.sha256(material).digest()
value = int.from_bytes(digest[:8], "big") % 2_147_483_647
return value or 1
class OrderReconciler:
def __init__(self, history_client: SupportsOrderHistoryLookup) -> None:
self._history_client = history_client
@staticmethod
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _extract_payload(order_id: str, response: dict[str, Any]) -> dict[str, Any]:
if order_id in response and isinstance(response[order_id], dict):
payload = response[order_id]
return {str(key): value for key, value in payload.items()}
return response
async def reconcile_order(
self,
*,
order_id: str,
user_ref: int,
expected_pair: str,
expected_side: str,
expected_volume: float,
) -> ReconciliationReport:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
response = await self._history_client.query_order(order_id=order_id, include_trades=True)
payload = self._extract_payload(order_id, response)
status = str(payload.get("status", "unknown")).lower()
filled_volume = self._to_float(payload.get("vol_exec"))
avg_price = self._to_float(payload.get("price") or payload.get("avg_price"))
reported_pair = str(payload.get("pair", expected_pair))
reported_side = str(payload.get("type", expected_side)).lower()
matches_request = (
reported_pair == expected_pair
and reported_side == expected_side.lower()
and (
expected_volume <= 0.0 or filled_volume is None or filled_volume <= expected_volume
)
and payload.get("userref") in {None, str(user_ref), user_ref}
)
return ReconciliationReport(
order_id=order_id,
user_ref=user_ref,
status=status,
filled_volume=filled_volume,
avg_price=avg_price,
is_terminal=status in {"closed", "canceled", "expired"},
matches_request=matches_request,
raw_payload=payload,
)
-98
View File
@@ -1,98 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from arbitrade.execution.fill_monitor import FillMonitorResult, OrderFillState
class SupportsOrderLifecycle(Protocol):
async def cancel_order(self, *, order_id: str) -> dict[str, Any]: ...
async def place_market_order(
self, *, pair: str, side: str, volume: float
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class RecoveryAction:
order_id: str
canceled: bool
hedged: bool
hedge_pair: str | None = None
hedge_side: str | None = None
hedge_volume: float | None = None
cancel_response: dict[str, Any] | None = None
hedge_response: dict[str, Any] | None = None
reason: str | None = None
class PartialFillRecovery:
def __init__(self, rest_client: SupportsOrderLifecycle) -> None:
self._rest_client = rest_client
@staticmethod
def _counter_side(side: str) -> str:
normalized = side.lower()
if normalized == "buy":
return "sell"
if normalized == "sell":
return "buy"
raise ValueError("side must be 'buy' or 'sell'")
@staticmethod
def _residual_volume(terminal_state: OrderFillState | None, requested_volume: float) -> float:
if requested_volume <= 0.0:
raise ValueError("requested_volume must be > 0.0")
if terminal_state is None or terminal_state.filled_volume is None:
return requested_volume
residual = requested_volume - terminal_state.filled_volume
return residual if residual > 0.0 else 0.0
async def recover_partial_fill(
self,
*,
order_id: str,
pair: str,
side: str,
requested_volume: float,
fill_result: FillMonitorResult,
) -> RecoveryAction:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
cancel_response: dict[str, Any] | None = None
hedge_response: dict[str, Any] | None = None
hedged = False
canceled = False
reason = None
state = fill_result.terminal_state or fill_result.last_state
residual_volume = self._residual_volume(state, requested_volume)
if state is not None and state.status in {"open", "partial"}:
cancel_response = await self._rest_client.cancel_order(order_id=order_id)
canceled = True
reason = f"canceled_{state.status}_order"
if residual_volume > 0.0 and fill_result.timed_out:
hedge_response = await self._rest_client.place_market_order(
pair=pair,
side=self._counter_side(side),
volume=residual_volume,
)
hedged = True
if reason is None:
reason = "hedged_timed_out_order"
return RecoveryAction(
order_id=order_id,
canceled=canceled,
hedged=hedged,
hedge_pair=pair if hedged else None,
hedge_side=self._counter_side(side) if hedged else None,
hedge_volume=residual_volume if hedged else None,
cancel_response=cancel_response,
hedge_response=hedge_response,
reason=reason,
)
-288
View File
@@ -1,288 +0,0 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
from arbitrade.alerting.notifier import SupportsAlerts
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import (
AuditRecord,
AuditRepository,
OrderRecord,
PnLRecord,
TradeRecord,
)
class SupportsOrderPlacement(Protocol):
async def place_market_order(
self, *, pair: str, side: str, volume: float
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class ExecutionLeg:
from_currency: str
to_currency: str
pair: str
side: str
volume: float
@dataclass(frozen=True, slots=True)
class TriangularExecutionResult:
success: bool
requested_legs: tuple[ExecutionLeg, ...]
completed_legs: int
responses: tuple[dict[str, Any], ...]
failure_reason: str | None = None
class TriangularExecutionSequencer:
def __init__(
self,
rest_client: SupportsOrderPlacement,
*,
available_pairs: Sequence[str],
volume_for_leg: Callable[[OpportunityEvent, ExecutionLeg, int], float] | None = None,
execution_writer: AsyncExecutionWriter | None = None,
alert_notifier: SupportsAlerts | None = None,
audit_repository: AuditRepository | None = None,
) -> None:
self._rest_client = rest_client
self._available_pairs = {self._normalize_pair(pair) for pair in available_pairs}
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
self._execution_writer = execution_writer
self._alert_notifier = alert_notifier
self._audit_repository = audit_repository
@staticmethod
def _normalize_pair(pair: str) -> str:
normalized = pair.strip().upper().replace("-", "/")
if "/" not in normalized:
return normalized
base, quote = normalized.split("/", 1)
return f"{base}/{quote}"
@staticmethod
def _default_volume_for_leg(event: OpportunityEvent, _leg: ExecutionLeg, _idx: int) -> float:
if event.allocated_capital <= 0.0:
raise ValueError("allocated_capital must be > 0.0")
return event.allocated_capital
def _resolve_leg(self, from_currency: str, to_currency: str, volume: float) -> ExecutionLeg:
from_cur = from_currency.upper()
to_cur = to_currency.upper()
buy_pair = f"{to_cur}/{from_cur}"
if buy_pair in self._available_pairs:
return ExecutionLeg(
from_currency=from_cur,
to_currency=to_cur,
pair=buy_pair,
side="buy",
volume=volume,
)
sell_pair = f"{from_cur}/{to_cur}"
if sell_pair in self._available_pairs:
return ExecutionLeg(
from_currency=from_cur,
to_currency=to_cur,
pair=sell_pair,
side="sell",
volume=volume,
)
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
currencies = [part.strip().upper() for part in event.cycle.split("->") if part.strip()]
if len(currencies) < 4 or currencies[0] != currencies[-1]:
raise ValueError("cycle must be a closed triangular path like A->B->C->A")
if len(currencies) != 4:
raise ValueError("cycle must contain exactly three unique currencies")
legs: list[ExecutionLeg] = []
for idx in range(3):
from_currency = currencies[idx]
to_currency = currencies[idx + 1]
placeholder_leg = ExecutionLeg(
from_currency=from_currency,
to_currency=to_currency,
pair="",
side="buy",
volume=0.0,
)
volume = self._volume_for_leg(event, placeholder_leg, idx)
if volume <= 0.0:
raise ValueError("volume_for_leg must return a positive volume")
legs.append(self._resolve_leg(from_currency, to_currency, volume))
return tuple(legs)
@staticmethod
def _trade_ref_for_event(event: OpportunityEvent) -> str:
material = (
f"{event.cycle}|{event.updated_pair}|"
f"{event.detected_at.timestamp():.6f}|"
f"{event.allocated_capital:.12f}"
)
return material.encode("utf-8").hex()[:32]
@staticmethod
def _order_ref_from_response(response: dict[str, Any], default: str) -> str:
txid = response.get("txid")
if isinstance(txid, list) and txid:
return str(txid[0])
if isinstance(txid, str) and txid.strip():
return txid
return default
async def execute(self, event: OpportunityEvent) -> TriangularExecutionResult:
legs = self._build_legs(event)
responses: list[dict[str, Any]] = []
trade_ref = self._trade_ref_for_event(event)
started_at = datetime.now(UTC)
for idx, leg in enumerate(legs):
try:
response = await self._rest_client.place_market_order(
pair=leg.pair,
side=leg.side,
volume=leg.volume,
)
except Exception as exc:
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.trade.failed",
decision="rejected",
payload={
"cycle": event.cycle,
"failed_leg_index": idx,
"error": str(exc),
},
correlation_id=trade_ref,
)
)
if self._alert_notifier is not None:
await self._alert_notifier.notify(
category="error",
severity="error",
title="Trade execution failed",
message="Triangular execution failed before completing all legs.",
details={
"cycle": event.cycle,
"failed_leg_index": str(idx),
"error": str(exc),
},
)
if self._execution_writer is not None:
await self._execution_writer.enqueue(
TradeRecord(
trade_ref=trade_ref,
started_at=started_at,
finished_at=datetime.now(UTC),
status="failed",
realized_pnl=None,
estimated_pnl=event.est_profit,
capital_used=event.allocated_capital,
cycle=event.cycle,
leg_count=len(legs),
)
)
return TriangularExecutionResult(
success=False,
requested_legs=legs,
completed_legs=idx,
responses=tuple(responses),
failure_reason=str(exc),
)
responses.append(response)
if self._execution_writer is not None:
order_ref = self._order_ref_from_response(response, f"leg-{idx}")
await self._execution_writer.enqueue(
OrderRecord(
trade_ref=trade_ref,
order_ref=order_ref,
leg_index=idx,
pair=leg.pair,
side=leg.side,
volume=leg.volume,
user_ref=None,
status=str(response.get("status", "submitted")),
filled_volume=None,
avg_price=None,
raw_response=response,
recorded_at=datetime.now(UTC),
)
)
if self._execution_writer is not None:
await self._execution_writer.enqueue(
TradeRecord(
trade_ref=trade_ref,
started_at=started_at,
finished_at=datetime.now(UTC),
status="filled",
realized_pnl=None,
estimated_pnl=event.est_profit,
capital_used=event.allocated_capital,
cycle=event.cycle,
leg_count=len(legs),
)
)
await self._execution_writer.enqueue(
PnLRecord(
trade_ref=trade_ref,
recorded_at=datetime.now(UTC),
kind="estimated",
pnl_usd=event.est_profit,
source="triangular_sequencer",
)
)
if self._alert_notifier is not None:
await self._alert_notifier.notify(
category="trade",
severity="warning" if event.est_profit < 0.0 else "info",
title="Trade execution completed",
message="Triangular execution completed all requested legs.",
details={
"cycle": event.cycle,
"completed_legs": str(len(legs)),
"estimated_pnl_usd": f"{event.est_profit}",
},
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.trade.completed",
decision="approved",
payload={
"cycle": event.cycle,
"completed_legs": len(legs),
"estimated_pnl_usd": event.est_profit,
},
correlation_id=trade_ref,
)
)
return TriangularExecutionResult(
success=True,
requested_legs=legs,
completed_legs=len(legs),
responses=tuple(responses),
)
-39
View File
@@ -1,39 +0,0 @@
from __future__ import annotations
import logging
import sys
from typing import Any
import structlog
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
level = getattr(logging, log_level.upper(), logging.INFO)
timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True)
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
timestamper,
]
if json_logs:
renderer: Any = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer()
structlog.configure(
processors=[
*shared_processors,
structlog.processors.dict_tracebacks,
structlog.processors.EventRenamer("message"),
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(level),
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=level, force=True)
-41
View File
@@ -1,41 +0,0 @@
from __future__ import annotations
import platform
from importlib import import_module
import uvicorn
from arbitrade.api.app import create_app
from arbitrade.config.settings import get_settings
def _install_uvloop_if_available() -> None:
if platform.system() == "Windows":
return
try:
uvloop = import_module("uvloop")
uvloop.install()
except Exception:
# App can still run with default asyncio loop.
return
def main() -> None:
_install_uvloop_if_available()
settings = get_settings()
app = create_app(settings)
uvicorn.run(
app,
host=settings.app_host,
port=settings.app_port,
log_level=settings.log_level.lower(),
loop="uvloop" if platform.system() != "Windows" else "asyncio",
http="httptools",
)
if __name__ == "__main__":
main()
@@ -1 +0,0 @@
"""Market data ingestion and book cache package."""
-485
View File
@@ -1,485 +0,0 @@
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
import structlog
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
from arbitrade.exchange.kraken_ws import KrakenWsClient
from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.kill_switch import KillSwitch
from arbitrade.risk.loss_limits import LossLimitGuard
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.stop_conditions import StopConditionsGuard
from arbitrade.risk.trade_limits import TradeLimitsGuard
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter, MarketSnapshot
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import AuditRecord, AuditRepository
_LOG = structlog.get_logger(__name__)
@dataclass(frozen=True, slots=True)
class ExecutionOutcome:
realized_pnl: float | None = None
close_trade: bool = True
class MarketDataFeed:
def __init__(
self,
ws_client: KrakenWsClient,
snapshot_writer: AsyncMarketSnapshotWriter,
detector: IncrementalCycleDetector | None = None,
opportunity_writer: AsyncOpportunityWriter | None = None,
paper_trading_mode: bool = True,
opportunity_executor: (
Callable[[OpportunityEvent], Awaitable[ExecutionOutcome | float | None]] | None
) = None,
trade_capital: float = 1.0,
max_trade_capital: float | None = None,
loss_limit_guard: LossLimitGuard | None = None,
trade_limits_guard: TradeLimitsGuard | None = None,
pre_trade_validator: PreTradeValidator | None = None,
balance_provider: Callable[[], Mapping[str, float]] | None = None,
quote_balance_asset: str = "USD",
kill_switch: KillSwitch | None = None,
stop_conditions_guard: StopConditionsGuard | None = None,
alert_notifier: SupportsAlerts | None = None,
audit_repository: AuditRepository | None = None,
) -> None:
self._ws_client = ws_client
self._snapshot_writer = snapshot_writer
self._books: dict[str, OrderBook] = {}
self._detector = detector
self._opportunity_writer = opportunity_writer
self._paper_trading_mode = paper_trading_mode
self._opportunity_executor = opportunity_executor
self._trade_capital = trade_capital
self._max_trade_capital = max_trade_capital
self._loss_limit_guard = loss_limit_guard
self._trade_limits_guard = trade_limits_guard
self._pre_trade_validator = pre_trade_validator
self._balance_provider = balance_provider
self._quote_balance_asset = quote_balance_asset.upper()
self._kill_switch = kill_switch
self._stop_conditions_guard = stop_conditions_guard
self._alert_notifier = alert_notifier
self._audit_repository = audit_repository
if self._trade_capital <= 0.0:
raise ValueError("trade_capital must be > 0.0")
if self._max_trade_capital is not None and self._max_trade_capital <= 0.0:
raise ValueError("max_trade_capital must be > 0.0")
@property
def books(self) -> dict[str, OrderBook]:
return self._books
def _effective_trade_capital(self) -> float:
if self._max_trade_capital is None:
return self._trade_capital
return min(self._trade_capital, self._max_trade_capital)
@staticmethod
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
currencies = [part for part in event.cycle.split("->") if part]
if len(currencies) < 2:
return {}
start = currencies[0]
exposure_assets = {currency for currency in currencies[1:] if currency != start}
return {asset: event.allocated_capital for asset in exposure_assets}
async def run(self) -> None:
async for message in self._ws_client.connect_stream():
parse_start = time.perf_counter()
delta = self._ws_client.parse_book_delta(message.payload)
if delta is None:
continue
book = self._books.setdefault(delta.symbol, OrderBook())
book.apply_bids(delta.bids)
book.apply_asks(delta.asks)
checksum_ok = True
if delta.checksum is not None:
checksum_ok = book.compute_checksum() == delta.checksum
apply_latency_ms = (time.perf_counter() - parse_start) * 1000
source_latency_ms: float | None = None
if delta.source_timestamp_ms is not None:
source_latency_ms = datetime.now(UTC).timestamp() * 1000 - float(
delta.source_timestamp_ms
)
_LOG.info(
"book_delta_applied",
symbol=delta.symbol,
bids=len(delta.bids),
asks=len(delta.asks),
checksum_ok=checksum_ok,
apply_latency_ms=apply_latency_ms,
source_latency_ms=source_latency_ms,
)
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.observe_latency(
source_latency_ms=source_latency_ms,
apply_latency_ms=apply_latency_ms,
)
if self._stop_conditions_guard.is_halted:
if self._kill_switch is not None and not self._kill_switch.is_active:
self._kill_switch.activate(
reason=self._stop_conditions_guard.halted_reason
or "stop_conditions_halted",
)
_LOG.warning(
"stop_condition_halt_triggered",
reason=self._stop_conditions_guard.halted_reason,
symbol=delta.symbol,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.stop_condition_halt",
decision="rejected",
payload={
"reason": self._stop_conditions_guard.halted_reason
or "unknown",
"symbol": delta.symbol,
},
)
)
if self._detector is not None:
opportunities = self._detector.opportunities_for_updated_pair(
delta.symbol,
self._books,
base_capital=self._effective_trade_capital(),
)
_LOG.debug(
"incremental_opportunity_scores",
symbol=delta.symbol,
opportunities=len(opportunities),
)
for event in opportunities:
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="detector",
event_type="detector.opportunity",
decision="scored",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"net_pct": event.net_pct,
"est_profit": event.est_profit,
},
)
)
_LOG.info(
"opportunity_detected",
cycle=event.cycle,
updated_pair=event.updated_pair,
gross_pct=event.gross_pct,
net_pct=event.net_pct,
est_profit=event.est_profit,
mode="paper" if self._paper_trading_mode else "live",
)
if self._opportunity_writer is not None:
await self._opportunity_writer.enqueue(event)
if self._paper_trading_mode:
_LOG.info(
"paper_trade_simulated",
cycle=event.cycle,
updated_pair=event.updated_pair,
net_pct=event.net_pct,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.paper_trade",
decision="skipped",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
},
)
)
continue
if self._opportunity_executor is None:
_LOG.warning(
"live_trade_skipped_no_executor",
cycle=event.cycle,
updated_pair=event.updated_pair,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="rejected",
payload={
"reason": "missing_executor",
"cycle": event.cycle,
},
)
)
continue
if self._kill_switch is not None and self._kill_switch.is_active:
_LOG.warning(
"live_trade_skipped_kill_switch",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._kill_switch.reason,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.kill_switch",
decision="rejected",
payload={
"reason": self._kill_switch.reason or "manual",
"cycle": event.cycle,
},
)
)
continue
if (
self._stop_conditions_guard is not None
and self._stop_conditions_guard.is_halted
):
_LOG.warning(
"live_trade_skipped_stop_condition_halt",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._stop_conditions_guard.halted_reason,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.stop_condition",
decision="rejected",
payload={
"reason": self._stop_conditions_guard.halted_reason
or "halted",
"cycle": event.cycle,
},
)
)
continue
if self._loss_limit_guard is not None and self._loss_limit_guard.is_halted:
_LOG.warning(
"live_trade_skipped_loss_limit_halted",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._loss_limit_guard.halted_reason,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.loss_limit",
decision="rejected",
payload={
"reason": self._loss_limit_guard.halted_reason or "halted",
"cycle": event.cycle,
},
)
)
continue
if self._pre_trade_validator is not None and self._balance_provider is not None:
required_balances = {self._quote_balance_asset: event.allocated_capital}
balances = {
asset.upper(): amount
for asset, amount in self._balance_provider().items()
}
if not self._pre_trade_validator.validate(
balances_by_asset=balances,
required_by_asset=required_balances,
):
_LOG.warning(
"live_trade_skipped_pre_trade_validation",
cycle=event.cycle,
updated_pair=event.updated_pair,
required_by_asset=required_balances,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.pre_trade_validation",
decision="rejected",
payload={
"cycle": event.cycle,
"required_by_asset": {
key: required_balances[key]
for key in required_balances
},
},
)
)
continue
exposure_by_asset = self._exposure_for_event(event)
if (
self._trade_limits_guard is not None
and not self._trade_limits_guard.is_trade_allowed(exposure_by_asset)
):
_LOG.warning(
"live_trade_skipped_trade_limits",
cycle=event.cycle,
updated_pair=event.updated_pair,
exposure_by_asset=exposure_by_asset,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.trade_limits",
decision="rejected",
payload={
"cycle": event.cycle,
"exposure_by_asset": {
key: exposure_by_asset[key] for key in exposure_by_asset
},
},
)
)
continue
if self._trade_limits_guard is not None:
self._trade_limits_guard.open_trade(exposure_by_asset)
try:
outcome = await self._opportunity_executor(event)
except Exception as exc:
if self._trade_limits_guard is not None:
self._trade_limits_guard.close_trade(exposure_by_asset)
dispatch_alert_nowait(
self._alert_notifier,
category="system",
severity="critical",
title="Critical execution exception",
message="Unhandled exception raised by opportunity executor.",
details={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"error": str(exc),
},
)
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.register_failure()
if self._stop_conditions_guard.is_halted:
if (
self._kill_switch is not None
and not self._kill_switch.is_active
):
self._kill_switch.activate(
reason=self._stop_conditions_guard.halted_reason
or "stop_conditions_halted",
)
_LOG.warning(
"stop_condition_halt_triggered",
reason=self._stop_conditions_guard.halted_reason,
cycle=event.cycle,
updated_pair=event.updated_pair,
)
_LOG.exception(
"live_trade_execution_failed",
cycle=event.cycle,
updated_pair=event.updated_pair,
)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="error",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"error": str(exc),
},
)
)
continue
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.register_success()
realized_pnl: float | None
close_trade = True
if isinstance(outcome, ExecutionOutcome):
realized_pnl = outcome.realized_pnl
close_trade = outcome.close_trade
else:
realized_pnl = outcome
if realized_pnl is not None and self._loss_limit_guard is not None:
self._loss_limit_guard.register_realized_pnl(realized_pnl)
if self._loss_limit_guard.is_halted:
_LOG.warning(
"loss_limit_halt_triggered",
reason=self._loss_limit_guard.halted_reason,
cumulative_pnl=self._loss_limit_guard.cumulative_pnl,
)
if self._trade_limits_guard is not None and close_trade:
self._trade_limits_guard.close_trade(exposure_by_asset)
if self._audit_repository is not None:
self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="approved",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"realized_pnl": realized_pnl,
"close_trade": close_trade,
},
)
)
await self._snapshot_writer.enqueue(
MarketSnapshot(
snapshot_at=datetime.now(UTC),
symbol=delta.symbol,
source="kraken_ws",
payload=message.payload,
latency_ms=source_latency_ms,
)
)
@@ -1,104 +0,0 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from sortedcontainers import SortedDict
from arbitrade.exchange.models import BookLevel
ZERO_CLEAN_RE = re.compile(r"^0+", re.ASCII)
def _normalize_price_for_checksum(value: float) -> str:
text = f"{value:.10f}".replace(".", "")
text = text.rstrip("0")
stripped = ZERO_CLEAN_RE.sub("", text)
return stripped or "0"
def _normalize_volume_for_checksum(value: float) -> str:
text = f"{value:.10f}".replace(".", "")
text = text.rstrip("0")
stripped = ZERO_CLEAN_RE.sub("", text)
return stripped or "0"
@dataclass(slots=True)
class BookView:
best_bid: BookLevel | None
best_ask: BookLevel | None
updated_at: datetime
class OrderBook:
def __init__(self) -> None:
self._bids: SortedDict[float, float] = SortedDict()
self._asks: SortedDict[float, float] = SortedDict()
self._updated_at: datetime = datetime.now(UTC)
@property
def updated_at(self) -> datetime:
return self._updated_at
def apply_bids(self, updates: Iterable[BookLevel]) -> None:
for level in updates:
if level.volume <= 0:
self._bids.pop(level.price, None)
else:
self._bids[level.price] = level.volume
self._updated_at = datetime.now(UTC)
def apply_asks(self, updates: Iterable[BookLevel]) -> None:
for level in updates:
if level.volume <= 0:
self._asks.pop(level.price, None)
else:
self._asks[level.price] = level.volume
self._updated_at = datetime.now(UTC)
def best_bid(self) -> BookLevel | None:
if not self._bids:
return None
price = self._bids.peekitem(-1)[0]
return BookLevel(price=price, volume=self._bids[price])
def best_ask(self) -> BookLevel | None:
if not self._asks:
return None
price = self._asks.peekitem(0)[0]
return BookLevel(price=price, volume=self._asks[price])
def snapshot(self) -> BookView:
return BookView(
best_bid=self.best_bid(),
best_ask=self.best_ask(),
updated_at=self._updated_at,
)
def top_levels(self, depth: int = 10) -> tuple[list[BookLevel], list[BookLevel]]:
bid_keys = list(self._bids.keys())
ask_keys = list(self._asks.keys())
bids = [
BookLevel(price=price, volume=self._bids[price])
for price in reversed(bid_keys[-depth:])
]
asks = [BookLevel(price=price, volume=self._asks[price]) for price in ask_keys[:depth]]
return bids, asks
def compute_checksum(self, depth: int = 10) -> int:
bids, asks = self.top_levels(depth)
combined: list[str] = []
for level in bids:
combined.append(_normalize_price_for_checksum(level.price))
combined.append(_normalize_volume_for_checksum(level.volume))
for level in asks:
combined.append(_normalize_price_for_checksum(level.price))
combined.append(_normalize_volume_for_checksum(level.volume))
import zlib
return zlib.crc32("".join(combined).encode("utf-8"))
-100
View File
@@ -1,100 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from arbitrade.storage.db import DuckDBStore
@dataclass(frozen=True, slots=True)
class PerformanceMetrics:
realized_pnl_usd: float
win_rate: float | None
avg_trade_duration_seconds: float | None
opportunities_per_minute: float | None
fill_rate: float | None
latency_p50_seconds: float | None
latency_p95_seconds: float | None
latency_p99_seconds: float | None
class MetricsCalculator:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def compute(self) -> PerformanceMetrics:
with self._store.connect() as conn:
tm = conn.execute("""
SELECT
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
COUNT(*) AS total_trades,
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
AVG(EPOCH(finished_at) - EPOCH(started_at)) AS avg_trade_duration_seconds,
quantile_cont(
EPOCH(finished_at) - EPOCH(started_at),
0.50
) AS latency_p50_seconds,
quantile_cont(
EPOCH(finished_at) - EPOCH(started_at),
0.95
) AS latency_p95_seconds,
quantile_cont(
EPOCH(finished_at) - EPOCH(started_at),
0.99
) AS latency_p99_seconds
FROM trades
WHERE finished_at IS NOT NULL
""").fetchone()
om = conn.execute("""
SELECT
COUNT(*) AS opportunity_count,
MIN(detected_at) AS first_detected_at,
MAX(detected_at) AS last_detected_at
FROM opportunities
""").fetchone()
fm = conn.execute("""
SELECT AVG(filled_volume / volume) AS fill_rate
FROM orders
WHERE volume > 0 AND filled_volume IS NOT NULL
""").fetchone()
r_pnl_usd = float(tm[0]) if tm and tm[0] is not None else 0.0
tt = int(tm[1]) if tm and tm[1] is not None else 0
wt = int(tm[2]) if tm and tm[2] is not None else 0
wr = wt / tt if tt > 0 else None
atd = float(tm[3]) if tm and tm[3] is not None else None
oc = int(om[0]) if om is not None and om[0] is not None else 0
fo = om[1] if om is not None and isinstance(om[1], datetime) else None
lo = om[2] if om is not None and isinstance(om[2], datetime) else None
opportunities_per_minute: float | None
if oc >= 2 and fo is not None and lo is not None:
span_seconds = (lo - fo).total_seconds()
opportunities_per_minute = (
oc / (span_seconds / 60.0) if span_seconds > 0.0 else float(oc)
)
elif oc == 1:
opportunities_per_minute = 60.0
else:
opportunities_per_minute = None
fill_rate = float(fm[0]) if fm and fm[0] is not None else None
lp50 = float(tm[4]) if tm and tm[4] is not None else None
lp95 = float(tm[5]) if tm and tm[5] is not None else None
lp99 = float(tm[6]) if tm and tm[6] is not None else None
return PerformanceMetrics(
realized_pnl_usd=r_pnl_usd,
win_rate=wr,
avg_trade_duration_seconds=atd,
opportunities_per_minute=opportunities_per_minute,
fill_rate=fill_rate,
latency_p50_seconds=lp50,
latency_p95_seconds=lp95,
latency_p99_seconds=lp99,
)
-4
View File
@@ -1,4 +0,0 @@
from arbitrade.perf.guardrails import evaluate_guardrails
from arbitrade.perf.latency import run_latency_profile
__all__ = ["run_latency_profile", "evaluate_guardrails"]
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
def evaluate_guardrails(
*,
baseline: dict[str, object],
current: dict[str, object],
thresholds: dict[str, object],
) -> list[str]:
failures: list[str] = []
baseline_scenarios = baseline.get("scenarios")
current_scenarios = current.get("scenarios")
if not isinstance(baseline_scenarios, dict) or not isinstance(current_scenarios, dict):
return ["invalid profile payload: missing scenarios map"]
default_thresholds = thresholds.get("default")
if not isinstance(default_thresholds, dict):
default_thresholds = {"p95_ms": 2.5, "p99_ms": 3.0}
scenario_thresholds = thresholds.get("scenarios")
if not isinstance(scenario_thresholds, dict):
scenario_thresholds = {}
for scenario, baseline_payload in baseline_scenarios.items():
current_payload = current_scenarios.get(scenario)
if not isinstance(baseline_payload, dict) or not isinstance(current_payload, dict):
failures.append(f"missing scenario in current profile: {scenario}")
continue
baseline_stages = baseline_payload.get("stages")
current_stages = current_payload.get("stages")
if not isinstance(baseline_stages, dict) or not isinstance(current_stages, dict):
failures.append(f"missing stages map for scenario: {scenario}")
continue
scenario_config = scenario_thresholds.get(scenario)
if not isinstance(scenario_config, dict):
scenario_config = {}
for stage, baseline_stage in baseline_stages.items():
current_stage = current_stages.get(stage)
if not isinstance(baseline_stage, dict) or not isinstance(current_stage, dict):
failures.append(f"missing stage in current profile: {scenario}.{stage}")
continue
for percentile_key in ("p95_ms", "p99_ms"):
threshold_ratio_raw = scenario_config.get(
percentile_key,
default_thresholds.get(percentile_key, 3.0),
)
threshold_ratio = (
float(threshold_ratio_raw)
if isinstance(threshold_ratio_raw, int | float)
else 3.0
)
base_value_raw = baseline_stage.get(percentile_key)
current_value_raw = current_stage.get(percentile_key)
if not isinstance(base_value_raw, int | float) or not isinstance(
current_value_raw, int | float
):
failures.append(
f"invalid percentile value: {scenario}.{stage}.{percentile_key}"
)
continue
base_value = float(base_value_raw)
current_value = float(current_value_raw)
# Avoid divide-by-zero while still preserving strict checks.
max_allowed = max(base_value * threshold_ratio, 0.001)
if current_value > max_allowed:
failures.append(
f"latency regression: {scenario}.{stage}.{percentile_key} "
f"current={current_value:.4f}ms "
f"baseline={base_value:.4f}ms "
f"allowed={max_allowed:.4f}ms"
)
return failures
-195
View File
@@ -1,195 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from time import perf_counter_ns
import orjson
@dataclass(frozen=True, slots=True)
class PercentileSummary:
p50_ms: float
p95_ms: float
p99_ms: float
@dataclass(frozen=True, slots=True)
class ScenarioProfile:
scenario: str
iterations: int
stages: dict[str, PercentileSummary]
def _percentile(samples: list[float], percentile: float) -> float:
if not samples:
return 0.0
ordered = sorted(samples)
if percentile <= 0.0:
return ordered[0]
if percentile >= 100.0:
return ordered[-1]
rank = (len(ordered) - 1) * (percentile / 100.0)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _summarize(samples: list[float]) -> PercentileSummary:
return PercentileSummary(
p50_ms=_percentile(samples, 50.0),
p95_ms=_percentile(samples, 95.0),
p99_ms=_percentile(samples, 99.0),
)
def _ingest_stage(raw_payload: bytes, state: dict[str, float]) -> None:
parsed = orjson.loads(raw_payload)
bids = parsed.get("bids", [])
asks = parsed.get("asks", [])
for price, volume in bids[:4]:
state[str(price)] = float(volume)
for price, volume in asks[:4]:
state[str(price)] = float(volume)
def _detect_stage(values: list[float], cycles: int) -> float:
best = 0.0
size = len(values)
for idx in range(cycles):
a = values[idx % size]
b = values[(idx + 3) % size]
c = values[(idx + 7) % size]
gross = (a / b) * c
net = gross * 0.9975
if net > best:
best = net
return best
def _risk_stage(net_edge: float, capital: float) -> float:
if net_edge < 1.0002:
return 0.0
if capital > 500.0:
capital = 500.0
return capital * min(net_edge - 1.0, 0.02)
def _execution_stage(planned_pnl: float, order_id: int) -> None:
payload = {
"order_id": order_id,
"planned_pnl": planned_pnl,
"legs": [
{"pair": "BTC/USD", "side": "buy", "qty": 0.01},
{"pair": "ETH/BTC", "side": "buy", "qty": 0.1},
{"pair": "ETH/USD", "side": "sell", "qty": 0.1},
],
}
_ = orjson.dumps(payload)
def _run_scenario(
name: str,
iterations: int,
detect_cycles: int,
reconnect_every: int,
) -> ScenarioProfile:
payloads = [
orjson.dumps(
{
"symbol": "BTC/USD",
"bids": [[100000.0 + i, 0.2 + (i % 5) * 0.01] for i in range(12)],
"asks": [[100001.0 + i, 0.2 + (i % 7) * 0.01] for i in range(12)],
}
)
for _ in range(5)
]
value_series = [1.0 + (idx % 31) * 0.0007 for idx in range(128)]
order_state: dict[str, float] = {}
ingest_ms: list[float] = []
detect_ms: list[float] = []
risk_ms: list[float] = []
execution_ms: list[float] = []
end_to_end_ms: list[float] = []
for idx in range(iterations):
start_ns = perf_counter_ns()
payload = payloads[idx % len(payloads)]
t0 = perf_counter_ns()
_ingest_stage(payload, order_state)
if reconnect_every > 0 and idx > 0 and idx % reconnect_every == 0:
order_state.clear()
t1 = perf_counter_ns()
net_edge = _detect_stage(value_series, detect_cycles)
t2 = perf_counter_ns()
planned = _risk_stage(net_edge, capital=100.0 + (idx % 50))
t3 = perf_counter_ns()
_execution_stage(planned, order_id=idx)
t4 = perf_counter_ns()
ingest_ms.append((t1 - t0) / 1_000_000.0)
detect_ms.append((t2 - t1) / 1_000_000.0)
risk_ms.append((t3 - t2) / 1_000_000.0)
execution_ms.append((t4 - t3) / 1_000_000.0)
end_to_end_ms.append((t4 - start_ns) / 1_000_000.0)
return ScenarioProfile(
scenario=name,
iterations=iterations,
stages={
"ingest": _summarize(ingest_ms),
"detect": _summarize(detect_ms),
"risk": _summarize(risk_ms),
"execution": _summarize(execution_ms),
"end_to_end": _summarize(end_to_end_ms),
},
)
def run_latency_profile(iterations: int = 600) -> dict[str, object]:
scenarios: list[Callable[[], ScenarioProfile]] = [
lambda: _run_scenario(
name="book_update_burst",
iterations=iterations,
detect_cycles=32,
reconnect_every=0,
),
lambda: _run_scenario(
name="execution_spike",
iterations=iterations,
detect_cycles=96,
reconnect_every=0,
),
lambda: _run_scenario(
name="reconnect_storm",
iterations=iterations,
detect_cycles=48,
reconnect_every=20,
),
]
result: dict[str, object] = {"iterations": iterations, "scenarios": {}}
scenario_map = result["scenarios"]
assert isinstance(scenario_map, dict)
for scenario in scenarios:
profile = scenario()
scenario_map[profile.scenario] = {
"iterations": profile.iterations,
"stages": {
stage: {
"p50_ms": summary.p50_ms,
"p95_ms": summary.p95_ms,
"p99_ms": summary.p99_ms,
}
for stage, summary in profile.stages.items()
},
}
return result
-15
View File
@@ -1,15 +0,0 @@
"""Risk management helpers."""
from arbitrade.risk.kill_switch import KillSwitch
from arbitrade.risk.loss_limits import LossLimitGuard
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.stop_conditions import StopConditionsGuard
from arbitrade.risk.trade_limits import TradeLimitsGuard
__all__ = [
"LossLimitGuard",
"TradeLimitsGuard",
"PreTradeValidator",
"KillSwitch",
"StopConditionsGuard",
]
-23
View File
@@ -1,23 +0,0 @@
from __future__ import annotations
class KillSwitch:
def __init__(self, *, active: bool = False, reason: str | None = None) -> None:
self._active = active
self._reason = reason or ("manual" if active else None)
@property
def is_active(self) -> bool:
return self._active
@property
def reason(self) -> str | None:
return self._reason
def activate(self, *, reason: str = "manual") -> None:
self._active = True
self._reason = reason
def deactivate(self) -> None:
self._active = False
self._reason = None
-90
View File
@@ -1,90 +0,0 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class LossLimitGuard:
def __init__(
self,
*,
daily_loss_limit: float | None = None,
cumulative_loss_limit: float | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
self._daily_loss_limit = daily_loss_limit
self._cumulative_loss_limit = cumulative_loss_limit
if self._daily_loss_limit is not None and self._daily_loss_limit <= 0.0:
raise ValueError("daily_loss_limit must be > 0.0")
if self._cumulative_loss_limit is not None and self._cumulative_loss_limit <= 0.0:
raise ValueError("cumulative_loss_limit must be > 0.0")
self._cumulative_pnl = 0.0
self._daily_pnl: dict[date, float] = {}
self._halted_reason: str | None = None
self._alert_notifier = alert_notifier
@property
def cumulative_pnl(self) -> float:
return self._cumulative_pnl
@property
def halted_reason(self) -> str | None:
return self._halted_reason
@property
def is_halted(self) -> bool:
return self._halted_reason is not None
def daily_pnl(self, day: date) -> float:
return self._daily_pnl.get(day, 0.0)
def register_realized_pnl(self, pnl: float, *, at: datetime | None = None) -> None:
if self.is_halted:
return
timestamp = at or datetime.now(UTC)
day_key = timestamp.date()
self._cumulative_pnl += pnl
self._daily_pnl[day_key] = self._daily_pnl.get(day_key, 0.0) + pnl
if (
self._daily_loss_limit is not None
and self._daily_pnl[day_key] <= -self._daily_loss_limit
):
self._halted_reason = "daily_loss_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Daily loss limit breached",
message="Trading halted because daily realized PnL crossed configured loss limit.",
details={
"daily_pnl": f"{self._daily_pnl[day_key]}",
"daily_loss_limit": f"{self._daily_loss_limit}",
},
)
return
if (
self._cumulative_loss_limit is not None
and self._cumulative_pnl <= -self._cumulative_loss_limit
):
self._halted_reason = "cumulative_loss_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Cumulative loss limit breached",
message=(
"Trading halted because cumulative realized PnL crossed "
"configured loss limit."
),
details={
"cumulative_pnl": f"{self._cumulative_pnl}",
"cumulative_loss_limit": f"{self._cumulative_loss_limit}",
},
)
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
class PreTradeValidator:
def __init__(
self,
*,
min_order_size_by_asset: Mapping[str, float] | None = None,
) -> None:
self._min_order_size_by_asset = {
asset.upper(): float(value) for asset, value in (min_order_size_by_asset or {}).items()
}
for value in self._min_order_size_by_asset.values():
if value <= 0.0:
raise ValueError("minimum order size must be > 0.0")
def validate(
self,
*,
balances_by_asset: Mapping[str, float],
required_by_asset: Mapping[str, float],
) -> bool:
# Minimum order size checks first to fail fast on structural invalid sizes.
for asset, required in required_by_asset.items():
if required <= 0.0:
continue
min_size = self._min_order_size_by_asset.get(asset.upper())
if min_size is not None and required < min_size:
return False
# Balance checks ensure required quantity is currently available.
for asset, required in required_by_asset.items():
if required <= 0.0:
continue
available = balances_by_asset.get(asset.upper(), 0.0)
if available < required:
return False
return True
-109
View File
@@ -1,109 +0,0 @@
from __future__ import annotations
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class StopConditionsGuard:
def __init__(
self,
*,
max_source_latency_ms: float | None = None,
max_apply_latency_ms: float | None = None,
max_consecutive_failures: int | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
if max_source_latency_ms is not None and max_source_latency_ms <= 0.0:
raise ValueError("max_source_latency_ms must be > 0.0")
if max_apply_latency_ms is not None and max_apply_latency_ms <= 0.0:
raise ValueError("max_apply_latency_ms must be > 0.0")
if max_consecutive_failures is not None and max_consecutive_failures <= 0:
raise ValueError("max_consecutive_failures must be > 0")
self._max_source_latency_ms = max_source_latency_ms
self._max_apply_latency_ms = max_apply_latency_ms
self._max_consecutive_failures = max_consecutive_failures
self._consecutive_failures = 0
self._halted_reason: str | None = None
self._alert_notifier = alert_notifier
@property
def halted_reason(self) -> str | None:
return self._halted_reason
@property
def is_halted(self) -> bool:
return self._halted_reason is not None
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
def observe_latency(
self,
*,
source_latency_ms: float | None,
apply_latency_ms: float,
) -> None:
if self.is_halted:
return
if (
self._max_source_latency_ms is not None
and source_latency_ms is not None
and source_latency_ms > self._max_source_latency_ms
):
self._halted_reason = "source_latency_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Source latency limit breached",
message="Trading halted because source latency exceeded configured limit.",
details={
"source_latency_ms": f"{source_latency_ms}",
"max_source_latency_ms": f"{self._max_source_latency_ms}",
},
)
return
if self._max_apply_latency_ms is not None and apply_latency_ms > self._max_apply_latency_ms:
self._halted_reason = "apply_latency_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Apply latency limit breached",
message="Trading halted because apply latency exceeded configured limit.",
details={
"apply_latency_ms": f"{apply_latency_ms}",
"max_apply_latency_ms": f"{self._max_apply_latency_ms}",
},
)
def register_failure(self) -> None:
if self.is_halted:
return
self._consecutive_failures += 1
if (
self._max_consecutive_failures is not None
and self._consecutive_failures >= self._max_consecutive_failures
):
self._halted_reason = "consecutive_failures_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Consecutive failures limit breached",
message="Trading halted because consecutive failures exceeded configured limit.",
details={
"consecutive_failures": f"{self._consecutive_failures}",
"max_consecutive_failures": f"{self._max_consecutive_failures}",
},
)
def register_success(self) -> None:
if self.is_halted:
return
self._consecutive_failures = 0
-98
View File
@@ -1,98 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class TradeLimitsGuard:
def __init__(
self,
*,
max_concurrent_trades: int | None = None,
max_exposure_per_asset: float | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
if max_concurrent_trades is not None and max_concurrent_trades <= 0:
raise ValueError("max_concurrent_trades must be > 0")
if max_exposure_per_asset is not None and max_exposure_per_asset <= 0.0:
raise ValueError("max_exposure_per_asset must be > 0.0")
self._max_concurrent_trades = max_concurrent_trades
self._max_exposure_per_asset = max_exposure_per_asset
self._active_trades = 0
self._asset_exposure: dict[str, float] = {}
self._alert_notifier = alert_notifier
@property
def active_trades(self) -> int:
return self._active_trades
def exposure_for_asset(self, asset: str) -> float:
return self._asset_exposure.get(asset.upper(), 0.0)
def is_trade_allowed(self, exposure_by_asset: Mapping[str, float]) -> bool:
if (
self._max_concurrent_trades is not None
and self._active_trades >= self._max_concurrent_trades
):
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="warning",
title="Concurrent trade limit reached",
message="Trade rejected by concurrent trade cap.",
details={
"active_trades": f"{self._active_trades}",
"max_concurrent_trades": f"{self._max_concurrent_trades}",
},
)
return False
if self._max_exposure_per_asset is None:
return True
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
next_exposure = self._asset_exposure.get(key, 0.0) + exposure
if next_exposure > self._max_exposure_per_asset:
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="warning",
title="Asset exposure limit reached",
message="Trade rejected by per-asset exposure cap.",
details={
"asset": key,
"next_exposure": f"{next_exposure}",
"max_exposure_per_asset": f"{self._max_exposure_per_asset}",
},
)
return False
return True
def open_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
self._active_trades += 1
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
self._asset_exposure[key] = self._asset_exposure.get(key, 0.0) + exposure
def close_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
if self._active_trades > 0:
self._active_trades -= 1
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
current = self._asset_exposure.get(key, 0.0)
next_exposure = max(current - exposure, 0.0)
if next_exposure == 0.0:
self._asset_exposure.pop(key, None)
else:
self._asset_exposure[key] = next_exposure
-15
View File
@@ -1,15 +0,0 @@
"""Runtime lifecycle and recovery helpers."""
from arbitrade.runtime.lifecycle import (
RuntimeRecoveryReport,
graceful_shutdown,
persist_runtime_snapshot,
restore_runtime_state,
)
__all__ = [
"RuntimeRecoveryReport",
"graceful_shutdown",
"persist_runtime_snapshot",
"restore_runtime_state",
]
-223
View File
@@ -1,223 +0,0 @@
from __future__ import annotations
import inspect
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, cast
from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import (
AuditRecord,
AuditRepository,
RuntimeStateRecord,
RuntimeStateRepository,
)
@dataclass(slots=True)
class RuntimeRecoveryReport:
restored_from_snapshot: bool
snapshot_at: str | None
open_trades_detected: int
restart_guard_active: bool
def _controls(app: FastAPI) -> DashboardControlState:
return cast(DashboardControlState, app.state.dashboard_controls)
def _store(app: FastAPI) -> DuckDBStore:
return cast(DuckDBStore, app.state.store)
def _audit_repository(app: FastAPI) -> AuditRepository | None:
repository = getattr(app.state, "audit_repository", None)
return repository if isinstance(repository, AuditRepository) else None
def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
repository = getattr(app.state, "runtime_state_repository", None)
return repository if isinstance(repository, RuntimeStateRepository) else None
def _open_trade_count(store: DuckDBStore) -> int:
with store.connect() as conn:
row = conn.execute("""
SELECT COUNT(*)
FROM trades
WHERE finished_at IS NULL
""").fetchone()
return int(row[0]) if row is not None else 0
def _latest_balances(store: DuckDBStore) -> dict[str, Any] | None:
with store.connect() as conn:
row = conn.execute("""
SELECT balances
FROM portfolio_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
if row is None or row[0] is None:
return None
raw_balances = row[0]
if isinstance(raw_balances, str):
return {"raw": raw_balances}
return {"raw": str(raw_balances)}
def _record_audit(
app: FastAPI,
*,
event_type: str,
decision: str,
payload: dict[str, Any] | None = None,
) -> None:
repository = _audit_repository(app)
if repository is None:
return
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="runtime",
event_type=event_type,
decision=decision,
payload=payload,
correlation_id=None,
)
)
async def _run_startup_reconciler(app: FastAPI) -> None:
reconciler = getattr(app.state, "startup_reconciler", None)
if reconciler is None:
return
reconcile_member = getattr(reconciler, "reconcile_open_trades", None)
if reconcile_member is None or not callable(reconcile_member):
return
result = reconcile_member()
if inspect.isawaitable(result):
await result
def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> RuntimeStateRecord | None:
repository = _runtime_repository(app)
if repository is None:
return None
controls = _controls(app)
store = _store(app)
snapshot = RuntimeStateRecord(
snapshot_at=datetime.now(UTC),
is_running=controls.is_running,
kill_switch_active=controls.kill_switch.is_active,
kill_switch_reason=controls.kill_switch.reason,
open_trade_count=_open_trade_count(store),
last_known_balances=_latest_balances(store),
note=note,
)
repository.insert(snapshot)
return snapshot
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
ctl = _controls(app)
store = _store(app)
repo = _runtime_repository(app)
restored_from_snapshot = False
snapshot_at: str | None = None
latest = repo.latest() if repo is not None else None
if latest is not None:
restored_from_snapshot = True
snapshot_at = latest.snapshot_at.isoformat()
ctl.is_running = latest.is_running
if latest.kill_switch_active:
r = latest.kill_switch_reason or "recovered"
ctl.kill_switch.activate(reason=r)
else:
ctl.kill_switch.deactivate()
ctl.mark_updated()
open_trades = _open_trade_count(store)
restart_guard_active = False
if open_trades > 0:
ctl.is_running = False
if not ctl.kill_switch.is_active:
ctl.kill_switch.activate(reason="recovery_open_trades_detected")
ctl.mark_updated()
restart_guard_active = True
report = RuntimeRecoveryReport(
restored_from_snapshot=restored_from_snapshot,
snapshot_at=snapshot_at,
open_trades_detected=open_trades,
restart_guard_active=restart_guard_active,
)
app.state.recovery_report = report
_record_audit(
app,
event_type="runtime.startup_recovery",
decision="applied",
payload={
"restored_from_snapshot": restored_from_snapshot,
"open_trades_detected": open_trades,
"restart_guard_active": restart_guard_active,
},
)
await _run_startup_reconciler(app)
return report
async def drain_background_workers(app: FastAPI) -> None:
workers: list[object] = []
declared = getattr(app.state, "background_workers", None)
if isinstance(declared, list):
workers.extend(declared)
for attr_name in ("execution_writer", "opportunity_writer", "snapshot_writer"):
worker = getattr(app.state, attr_name, None)
if worker is not None:
workers.append(worker)
seen: set[int] = set()
for worker in workers:
worker_id = id(worker)
if worker_id in seen:
continue
seen.add(worker_id)
stop_member = getattr(worker, "stop", None)
if stop_member is None or not callable(stop_member):
continue
result = stop_member()
if inspect.isawaitable(result):
await result
async def graceful_shutdown(app: FastAPI) -> None:
controls = _controls(app)
controls.is_running = False
controls.mark_updated()
_record_audit(
app,
event_type="runtime.shutdown",
decision="initiated",
payload={"execution_status": "stopped"},
)
await drain_background_workers(app)
persist_runtime_snapshot(app, note="graceful_shutdown")
-1
View File
@@ -1 +0,0 @@
"""Storage helpers."""
-128
View File
@@ -1,128 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import duckdb
import structlog
from arbitrade.config.settings import Settings
_LOG = structlog.get_logger(__name__)
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT uuid(),
detected_at TIMESTAMP NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE,
net_pct DOUBLE,
est_profit DOUBLE,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
status VARCHAR NOT NULL,
realized_pnl DOUBLE,
estimated_pnl DOUBLE,
capital_used DOUBLE,
cycle VARCHAR,
leg_count INTEGER
);
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
order_ref VARCHAR NOT NULL,
leg_index INTEGER NOT NULL,
pair VARCHAR NOT NULL,
side VARCHAR NOT NULL,
volume DOUBLE NOT NULL,
user_ref INTEGER,
status VARCHAR,
filled_volume DOUBLE,
avg_price DOUBLE,
raw_response JSON,
recorded_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMP NOT NULL,
kind VARCHAR NOT NULL,
pnl_usd DOUBLE NOT NULL,
source VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMP NOT NULL,
balances JSON,
total_value_usd DOUBLE
);
CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMP NOT NULL,
symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL,
payload JSON NOT NULL,
latency_ms DOUBLE
);
CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT uuid(),
occurred_at TIMESTAMP NOT NULL,
actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL,
payload JSON,
correlation_id VARCHAR
);
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMP NOT NULL,
is_running BOOLEAN NOT NULL,
kill_switch_active BOOLEAN NOT NULL,
kill_switch_reason VARCHAR,
open_trade_count INTEGER NOT NULL,
last_known_balances JSON,
note VARCHAR
);
"""
class DuckDBStore:
def __init__(self, settings: Settings) -> None:
self._db_path = Path(settings.duckdb_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._use_memory_fallback = False
@contextmanager
def connect(self) -> Iterator[duckdb.DuckDBPyConnection]:
try:
conn = duckdb.connect(str(self._db_path))
except duckdb.IOException:
if not self._use_memory_fallback:
_LOG.warning(
"duckdb_path_unavailable_falling_back_to_memory", path=str(self._db_path)
)
self._use_memory_fallback = True
conn = duckdb.connect(":memory:")
try:
yield conn
finally:
conn.close()
def migrate(self) -> None:
with self.connect() as conn:
conn.execute(SCHEMA_SQL)
-66
View File
@@ -1,66 +0,0 @@
from __future__ import annotations
import asyncio
import structlog
from arbitrade.storage.repositories import (
OrderRecord,
OrderRepository,
PnLRecord,
PnLRepository,
TradeRecord,
TradeRepository,
)
_LOG = structlog.get_logger(__name__)
class AsyncExecutionWriter:
def __init__(
self,
trade_repository: TradeRepository,
order_repository: OrderRepository,
pnl_repository: PnLRepository,
max_queue_size: int = 50_000,
) -> None:
self._trade_repository = trade_repository
self._order_repository = order_repository
self._pnl_repository = pnl_repository
self._queue: asyncio.Queue[TradeRecord | OrderRecord | PnLRecord] = asyncio.Queue(
maxsize=max_queue_size
)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="execution-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, record: TradeRecord | OrderRecord | PnLRecord) -> None:
await self._queue.put(record)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
record = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
if isinstance(record, TradeRecord):
self._trade_repository.insert(record)
elif isinstance(record, OrderRecord):
self._order_repository.insert(record)
else:
self._pnl_repository.insert(record)
except Exception as exc:
_LOG.error("execution_write_failed", error=str(exc))
finally:
self._queue.task_done()
@@ -1,64 +0,0 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import structlog
from arbitrade.storage.repositories import MarketSnapshotRecord, MarketSnapshotRepository
_LOG = structlog.get_logger(__name__)
@dataclass(slots=True)
class MarketSnapshot:
snapshot_at: datetime
symbol: str
source: str
payload: dict[str, Any]
latency_ms: float | None
class AsyncMarketSnapshotWriter:
def __init__(self, repository: MarketSnapshotRepository, max_queue_size: int = 50_000) -> None:
self._repository = repository
self._queue: asyncio.Queue[MarketSnapshot] = asyncio.Queue(maxsize=max_queue_size)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="market-snapshot-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, snapshot: MarketSnapshot) -> None:
await self._queue.put(snapshot)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
item = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
self._repository.insert(
MarketSnapshotRecord(
snapshot_at=item.snapshot_at,
symbol=item.symbol,
source=item.source,
payload=item.payload,
latency_ms=item.latency_ms,
)
)
except Exception as exc:
_LOG.error("market_snapshot_write_failed", error=str(exc), symbol=item.symbol)
finally:
self._queue.task_done()
@@ -1,58 +0,0 @@
from __future__ import annotations
import asyncio
import structlog
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.repositories import OpportunityRecord, OpportunityRepository
_LOG = structlog.get_logger(__name__)
class AsyncOpportunityWriter:
def __init__(self, repository: OpportunityRepository, max_queue_size: int = 50_000) -> None:
self._repository = repository
self._queue: asyncio.Queue[OpportunityEvent] = asyncio.Queue(maxsize=max_queue_size)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="opportunity-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, event: OpportunityEvent) -> None:
await self._queue.put(event)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
event = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
self._repository.insert(
OpportunityRecord(
detected_at=event.detected_at,
cycle=event.cycle,
gross_pct=event.gross_pct,
net_pct=event.net_pct,
est_profit=event.est_profit,
)
)
except Exception as exc:
_LOG.error(
"opportunity_write_failed",
error=str(exc),
cycle=event.cycle,
updated_pair=event.updated_pair,
)
finally:
self._queue.task_done()
-378
View File
@@ -1,378 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import orjson
from arbitrade.storage.db import DuckDBStore
@dataclass(slots=True)
class MarketSnapshotRecord:
snapshot_at: datetime
symbol: str
source: str
payload: dict[str, Any]
latency_ms: float | None
@dataclass(slots=True)
class OpportunityRecord:
detected_at: datetime
cycle: str
gross_pct: float
net_pct: float
est_profit: float
executed: bool = False
@dataclass(slots=True)
class TradeRecord:
trade_ref: str
started_at: datetime
finished_at: datetime | None
status: str
realized_pnl: float | None
estimated_pnl: float | None
capital_used: float | None
cycle: str | None = None
leg_count: int | None = None
@dataclass(slots=True)
class OrderRecord:
trade_ref: str
order_ref: str
leg_index: int
pair: str
side: str
volume: float
user_ref: int | None
status: str | None
filled_volume: float | None
avg_price: float | None
raw_response: dict[str, Any]
recorded_at: datetime
@dataclass(slots=True)
class PnLRecord:
trade_ref: str
recorded_at: datetime
kind: str
pnl_usd: float
source: str
@dataclass(slots=True)
class AuditRecord:
occurred_at: datetime
actor: str
event_type: str
decision: str
payload: dict[str, Any] | None = None
correlation_id: str | None = None
@dataclass(slots=True)
class RuntimeStateRecord:
snapshot_at: datetime
is_running: bool
kill_switch_active: bool
kill_switch_reason: str | None
open_trade_count: int
last_known_balances: dict[str, Any] | None = None
note: str | None = None
class MarketSnapshotRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: MarketSnapshotRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO market_snapshots (snapshot_at, symbol, source, payload, latency_ms)
VALUES (?, ?, ?, ?, ?)
""",
[
record.snapshot_at,
record.symbol,
record.source,
orjson.dumps(record.payload).decode("utf-8"),
record.latency_ms,
],
)
class OpportunityRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: OpportunityRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
record.detected_at,
record.cycle,
record.gross_pct,
record.net_pct,
record.est_profit,
record.executed,
],
)
class TradeRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: TradeRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
record.trade_ref,
record.started_at,
record.finished_at,
record.status,
record.realized_pnl,
record.estimated_pnl,
record.capital_used,
record.cycle,
record.leg_count,
],
)
class OrderRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: OrderRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO orders (
trade_ref,
order_ref,
leg_index,
pair,
side,
volume,
user_ref,
status,
filled_volume,
avg_price,
raw_response,
recorded_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
record.trade_ref,
record.order_ref,
record.leg_index,
record.pair,
record.side,
record.volume,
record.user_ref,
record.status,
record.filled_volume,
record.avg_price,
orjson.dumps(record.raw_response).decode("utf-8"),
record.recorded_at,
],
)
class PnLRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: PnLRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO pnl_events (
trade_ref,
recorded_at,
kind,
pnl_usd,
source
)
VALUES (?, ?, ?, ?, ?)
""",
[
record.trade_ref,
record.recorded_at,
record.kind,
record.pnl_usd,
record.source,
],
)
class AuditRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: AuditRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO audit_events (
occurred_at,
actor,
event_type,
decision,
payload,
correlation_id
)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
record.occurred_at,
record.actor,
record.event_type,
record.decision,
(
None
if record.payload is None
else orjson.dumps(record.payload).decode("utf-8")
),
record.correlation_id,
],
)
def list_recent(self, *, limit: int = 25) -> list[AuditRecord]:
with self._store.connect() as conn:
rows = conn.execute(
"""
SELECT occurred_at, actor, event_type, decision, payload, correlation_id
FROM audit_events
ORDER BY occurred_at DESC
LIMIT ?
""",
[limit],
).fetchall()
records: list[AuditRecord] = []
for row in rows:
payload: dict[str, Any] | None = None
raw_payload = row[4]
if isinstance(raw_payload, str) and raw_payload:
decoded = orjson.loads(raw_payload)
if isinstance(decoded, dict):
payload = {str(k): decoded[k] for k in decoded}
records.append(
AuditRecord(
occurred_at=row[0],
actor=str(row[1]),
event_type=str(row[2]),
decision=str(row[3]),
payload=payload,
correlation_id=str(row[5]) if row[5] is not None else None,
)
)
return records
class RuntimeStateRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert(self, record: RuntimeStateRecord) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO runtime_state_snapshots (
snapshot_at,
is_running,
kill_switch_active,
kill_switch_reason,
open_trade_count,
last_known_balances,
note
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
record.snapshot_at,
record.is_running,
record.kill_switch_active,
record.kill_switch_reason,
record.open_trade_count,
(
None
if record.last_known_balances is None
else orjson.dumps(record.last_known_balances).decode("utf-8")
),
record.note,
],
)
def latest(self) -> RuntimeStateRecord | None:
with self._store.connect() as conn:
row = conn.execute("""
SELECT
snapshot_at,
is_running,
kill_switch_active,
kill_switch_reason,
open_trade_count,
last_known_balances,
note
FROM runtime_state_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
if row is None:
return None
balances: dict[str, Any] | None = None
raw_balances = row[5]
if isinstance(raw_balances, str) and raw_balances:
decoded = orjson.loads(raw_balances)
if isinstance(decoded, dict):
balances = {str(key): decoded[key] for key in decoded}
return RuntimeStateRecord(
snapshot_at=row[0],
is_running=bool(row[1]),
kill_switch_active=bool(row[2]),
kill_switch_reason=str(row[3]) if row[3] is not None else None,
open_trade_count=int(row[4]),
last_known_balances=balances,
note=str(row[6]) if row[6] is not None else None,
)
-5
View File
@@ -1,5 +0,0 @@
"""Experimental strategy modules."""
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig, StatArbSignal
__all__ = ["StatArbExperiment", "StatArbExperimentConfig", "StatArbSignal"]
-152
View File
@@ -1,152 +0,0 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from statistics import fmean, pstdev
from typing import Literal
@dataclass(frozen=True, slots=True)
class StatArbExperimentConfig:
pair_a: str
pair_b: str
lookback_window: int = 120
entry_zscore: float = 2.0
exit_zscore: float = 0.5
max_holding_seconds: float = 900.0
@dataclass(frozen=True, slots=True)
class StatArbSignal:
action: Literal[
"warmup",
"hold",
"enter_long_spread",
"enter_short_spread",
"exit_position",
]
observed_at: datetime
spread: float
zscore: float | None
position: Literal["long", "short", "flat"]
class StatArbExperiment:
"""Simple mean-reversion experiment scaffold behind feature flags."""
def __init__(self, config: StatArbExperimentConfig) -> None:
if config.lookback_window < 2:
raise ValueError("lookback_window must be >= 2")
if config.entry_zscore <= 0.0:
raise ValueError("entry_zscore must be > 0")
if config.exit_zscore < 0.0:
raise ValueError("exit_zscore must be >= 0")
if config.entry_zscore <= config.exit_zscore:
raise ValueError("entry_zscore must be > exit_zscore")
if config.max_holding_seconds <= 0.0:
raise ValueError("max_holding_seconds must be > 0")
self._config = config
self._spreads: deque[float] = deque(maxlen=config.lookback_window)
self._position: Literal["long", "short", "flat"] = "flat"
self._position_opened_at: datetime | None = None
@property
def config(self) -> StatArbExperimentConfig:
return self._config
def reset(self) -> None:
self._spreads.clear()
self._position = "flat"
self._position_opened_at = None
def observe(
self,
*,
price_a: float,
price_b: float,
observed_at: datetime,
) -> StatArbSignal:
if price_a <= 0.0 or price_b <= 0.0:
raise ValueError("prices must be > 0")
at = observed_at.astimezone(UTC)
spread = price_a - price_b
self._spreads.append(spread)
if len(self._spreads) < self._config.lookback_window:
return StatArbSignal(
action="warmup",
observed_at=at,
spread=spread,
zscore=None,
position=self._position,
)
mean_spread = fmean(self._spreads)
std_spread = pstdev(self._spreads)
if std_spread == 0.0:
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=0.0,
position=self._position,
)
zscore = (spread - mean_spread) / std_spread
if self._position == "flat":
if zscore >= self._config.entry_zscore:
self._position = "short"
self._position_opened_at = at
return StatArbSignal(
action="enter_short_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
if zscore <= -self._config.entry_zscore:
self._position = "long"
self._position_opened_at = at
return StatArbSignal(
action="enter_long_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
assert self._position_opened_at is not None
held_seconds = (at - self._position_opened_at).total_seconds()
should_exit = abs(zscore) <= self._config.exit_zscore
if held_seconds >= self._config.max_holding_seconds:
should_exit = True
if should_exit:
self._position = "flat"
self._position_opened_at = None
return StatArbSignal(
action="exit_position",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
@@ -1,24 +0,0 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
content %}
<section class="hero">
<div>
<h1 class="title">Backtesting</h1>
<p class="subtitle">
Replay controls, run status, and recent summary reports.
</p>
</div>
<div class="toolbar">
<a class="button secondary" href="{{ dashboard_endpoint }}">Dashboard</a>
</div>
</section>
<section
id="backtesting-shell"
hx-get="{{ panel_endpoint }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/backtesting_panel.html" %}
</section>
{% endblock %}
@@ -1,180 +0,0 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
head_scripts %}
<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>
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
<section class="hero">
<div>
<h1 class="title">Arbitrade Dashboard</h1>
<p class="subtitle">Live execution, P&amp;L, and system state.</p>
</div>
<div class="toolbar">
<a
class="button"
href="{{ metrics_endpoint }}"
hx-get="{{ metrics_endpoint }}"
hx-target="#metrics-panel"
hx-swap="outerHTML"
>Refresh metrics</a
>
<a class="button secondary" href="/health">Health</a>
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
</div>
</section>
<section
id="metrics-shell"
hx-get="{{ metrics_endpoint }}"
hx-target="this"
hx-trigger="load, every 15s"
hx-swap="outerHTML"
>
{% include "partials/metrics.html" %}
</section>
<section
id="overview-shell"
hx-get="{{ overview_endpoint }}"
hx-target="this"
hx-trigger="load, every 10s"
hx-swap="outerHTML"
>
{% include "partials/overview.html" %}
</section>
<section
id="controls-shell"
hx-get="{{ controls_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/controls.html" %}
</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>
<section
id="audit-shell"
hx-get="{{ audit_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %} {% block scripts %}
<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 }}");
stream.addEventListener("metrics", (event) => {
const panel = document.getElementById("metrics-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
const overviewStream = new EventSource("{{ overview_stream_endpoint }}");
overviewStream.addEventListener("overview", (event) => {
const panel = document.getElementById("overview-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
</script>
{% endblock %}
@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a href="/health" hx-get="/health" hx-target="#health-json" hx-swap="innerHTML">refresh</a>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
{% endblock %}
@@ -1,37 +0,0 @@
<div id="audit-panel" class="panel" style="margin-top: 16px">
<div class="label">Audit Trail</div>
<div class="meta">Generated {{ generated_at }}</div>
<div style="overflow-x: auto; margin-top: 12px">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem">
<thead>
<tr>
<th style="text-align: left; padding: 8px">Time</th>
<th style="text-align: left; padding: 8px">Actor</th>
<th style="text-align: left; padding: 8px">Event</th>
<th style="text-align: left; padding: 8px">Decision</th>
<th style="text-align: left; padding: 8px">Payload</th>
<th style="text-align: left; padding: 8px">Correlation</th>
</tr>
</thead>
<tbody>
{% if entries %}
{% for entry in entries %}
<tr>
<td style="padding: 8px; color: #9fb2d0">{{ entry.occurred_at }}</td>
<td style="padding: 8px">{{ entry.actor }}</td>
<td style="padding: 8px">{{ entry.event_type }}</td>
<td style="padding: 8px">{{ entry.decision }}</td>
<td style="padding: 8px; color: #9fb2d0">{{ entry.payload }}</td>
<td style="padding: 8px; color: #9fb2d0">{{ entry.correlation_id }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" style="padding: 8px; color: #9fb2d0">No audit entries yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
@@ -1,142 +0,0 @@
<div id="backtesting-shell" class="panel">
<div
class="grid"
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"
>
<article class="card">
<div class="label">Run Status</div>
<div class="value">{{ status }}</div>
<div class="meta">{{ message }}</div>
</article>
<article class="card">
<div class="label">Latest Report</div>
{% if latest_report %}
<div class="meta">Run at {{ latest_report.run_at }}</div>
<div class="meta">Events: {{ latest_report.events_path }}</div>
<div class="meta">
Processed: {{ latest_report.report.processed_events }}
</div>
<div class="meta">
Opportunities: {{ latest_report.report.opportunities_seen }}
</div>
<div class="meta">Trades: {{ latest_report.report.trades_executed }}</div>
<div class="meta">
Realized P&amp;L: {{
'%.4f'|format(latest_report.report.realized_pnl_usd) }} USD
</div>
<div class="meta">
Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }}
USD
</div>
{% else %}
<div class="meta">No runs yet.</div>
{% endif %}
</article>
</div>
<article class="card" style="margin-top: 16px">
<div class="label">Run Backtest</div>
<form
class="form-grid"
hx-post="{{ run_endpoint }}"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
>
<label class="field">
<span>Replay events path (JSONL)</span>
<input
name="events_path"
type="text"
value="{{ events_path }}"
placeholder="data/replay.jsonl"
/>
</label>
<label class="field">
<span>Starting balances</span>
<input
name="starting_balances"
type="text"
value="{{ starting_balances }}"
placeholder="USD=1000.0,BTC=0.0"
/>
</label>
<label class="field">
<span>Trade capital</span>
<input
name="trade_capital"
type="number"
min="0"
step="0.01"
value="{{ trade_capital }}"
/>
</label>
<label class="field">
<span>Min profit threshold</span>
<input
name="min_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ min_profit_threshold }}"
/>
</label>
<label class="field">
<span>Fee profile</span>
<select name="fee_profile">
{% set sel = "selected" if fee_profile == "standard" else "" %}
<option value="standard" {{ sel }}>standard</option>
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
<option value="maker_heavy" {{ sel }}>maker_heavy</option>
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
<option value="taker_heavy" {{ sel }}>taker_heavy</option>
{% set sel = "selected" if fee_profile == "custom" else "" %}
<option value="custom" {{ sel }}>custom</option>
</select>
</label>
<label class="field">
<span>Custom fee rate (if fee profile = custom)</span>
<input
name="custom_fee_rate"
type="number"
min="0"
step="0.0001"
value="{{ custom_fee_rate }}"
/>
</label>
<label class="field">
<span>Slippage (bps)</span>
<input
name="slippage_bps"
type="number"
min="0"
step="0.1"
value="{{ slippage_bps }}"
/>
</label>
<label class="field">
<span>Execution latency (ms)</span>
<input
name="execution_latency_ms"
type="number"
min="0"
step="0.1"
value="{{ execution_latency_ms }}"
/>
</label>
<button type="submit" class="button">Run backtest</button>
</form>
</article>
<article class="card" style="margin-top: 16px">
<div class="label">Recent Runs</div>
{% if recent_reports %} {% for item in recent_reports %}
<div class="meta">
{{ item.run_at }} | {{ item.events_path }} | trades={{
item.report.trades_executed }} | pnl={{
'%.4f'|format(item.report.realized_pnl_usd) }} USD
</div>
{% endfor %} {% else %}
<div class="meta">No recent reports yet.</div>
{% endif %}
</article>
</div>
@@ -1,37 +0,0 @@
<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>
@@ -1,171 +0,0 @@
<div id="controls-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Runtime Status</div>
<div class="value">{{ execution_status }}</div>
<div class="meta">Updated {{ updated_at }}</div>
</article>
<article class="card">
<div class="label">Kill Switch</div>
<div class="value">{{ kill_switch_status }}</div>
<div class="meta">Reason {{ kill_switch_reason }}</div>
</article>
<article class="card">
<div class="label">Config Snapshot</div>
<div class="meta">Paper trading: {{ paper_trading_mode }}</div>
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
<div class="meta">Tradable pairs: {{ tradable_pairs_display }}</div>
<div class="meta">Strategy mode: {{ strategy_mode }}</div>
<div class="meta">Profit threshold: {{ strategy_profit_threshold }}</div>
<div class="meta">Max depth levels: {{ strategy_max_depth_levels }}</div>
</article>
<article class="card">
<div class="label">Alerting</div>
<div class="meta">Status: {{ alerts_enabled }}</div>
<div class="meta">Channels: {{ alerts_channels }}</div>
<div class="meta">Min severity: {{ alerts_min_severity }}</div>
<div class="meta">Dedup window: {{ alerts_dedup_seconds }}s</div>
<div class="meta">Last result: {{ alerts_last_result }}</div>
<div class="meta">Last attempted: {{ alerts_last_attempted_at }}</div>
<div class="meta">Last success: {{ alerts_last_success_at }}</div>
<div class="meta">Last event: {{ alerts_last_event_title }}</div>
<div class="meta">Last error: {{ alerts_last_error }}</div>
{% if alerts_last_channel_results %} {% for item in
alerts_last_channel_results %}
<div class="meta">{{ item }}</div>
{% endfor %} {% endif %}
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Execution Controls</div>
<div class="control-actions">
<form
hx-post="{{ start_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button">Start</button>
</form>
<form
hx-post="{{ stop_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button secondary">Stop</button>
</form>
<form
hx-post="{{ kill_switch_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="reason" value="manual" />
<button type="submit" class="button danger">
Trigger Kill Switch
</button>
</form>
</div>
</article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div>
</div>
@@ -1,31 +0,0 @@
<div id="metrics-panel" class="panel">
<div class="grid">
<article class="card">
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl }}</div>
</article>
<article class="card">
<div class="label">Win Rate</div>
<div class="value">{{ win_rate }}</div>
</article>
<article class="card">
<div class="label">Avg Trade Duration</div>
<div class="value">{{ avg_trade_duration }}</div>
</article>
<article class="card">
<div class="label">Opportunities / Min</div>
<div class="value">{{ opportunities_per_minute }}</div>
</article>
<article class="card">
<div class="label">Fill Rate</div>
<div class="value">{{ fill_rate }}</div>
</article>
<article class="card">
<div class="label">Latency p50 / p95 / p99</div>
<div class="value">
{{ latency_p50 }} | {{ latency_p95 }} | {{ latency_p99 }}
</div>
</article>
</div>
<div class="meta">Updated {{ generated_at }}</div>
</div>
@@ -1,67 +0,0 @@
<div id="overview-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Status</div>
<div class="value">{{ status }}</div>
</article>
<article class="card">
<div class="label">Balances</div>
<div class="value">{{ balances }}</div>
</article>
<article class="card">
<div class="label">Open Trades</div>
<div class="value">{{ open_trade_count }}</div>
</article>
<article class="card">
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl_total }}</div>
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Open Trades</div>
<ul>
{% for trade in open_trades %}
<li>
{{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{
trade.started_at }}
</li>
{% else %}
<li>No open trades.</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="label">Balances Snapshot</div>
<div
class="value"
style="font-size: 1rem; font-weight: 500; word-break: break-word"
>
{{ balances }}
</div>
<div class="meta">Total value {{ total_value }}</div>
</article>
<article class="card">
<div class="label">Opportunity Feed</div>
<ul>
{% for opp in opportunities %}
<li>
{{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{
opp.detected_at }}
</li>
{% else %}
<li>No opportunities.</li>
{% endfor %}
</ul>
</article>
</div>
<div class="meta">Updated {{ generated_at }}</div>
</div>
+34 -6
View File
@@ -6,10 +6,31 @@ from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
import duckdb
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
if fee_rate is not None:
return fee_rate
if db_path is not None:
try:
conn = duckdb.connect(db_path)
row = conn.execute("""
SELECT maker_fee FROM kraken_account_snapshots
ORDER BY snapshot_at DESC LIMIT 1
""").fetchone()
conn.close()
if row is not None and row[0] is not None:
return float(row[0])
except Exception:
pass
return 0.0026 # ultimate fallback
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph()
graph.add_pair("USD", "BTC", "BTC/USD")
@@ -30,19 +51,23 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
def main() -> int:
parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.")
parser = argparse.ArgumentParser(
description="Run a deterministic replay backtest.")
parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--trade-capital", type=float, default=100.0)
parser.add_argument("--fee-rate", type=float, default=0.0026)
parser.add_argument("--fee-rate", type=float, default=None)
parser.add_argument("--slippage-bps", type=float, default=4.0)
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
parser.add_argument("--db-path", type=str, default=None,
help="DuckDB path for fee lookup")
args = parser.parse_args()
cycles_by_pair, available_pairs = _build_graph()
events = load_replay_events(args.events)
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
config = BacktestConfig(
fee_rate=args.fee_rate,
fee_rate=fee_rate,
trade_capital=args.trade_capital,
slippage_bps=args.slippage_bps,
execution_latency_ms=args.execution_latency_ms,
@@ -55,15 +80,18 @@ def main() -> int:
started_at=events[0].occurred_at if events else datetime.now(UTC),
)
report = asyncio.run(
engine.run(events, starting_balances=_parse_balances(args.starting_balances))
engine.run(events, starting_balances=_parse_balances(
args.starting_balances))
)
print("Backtest report:")
print(f"- processed_events: {report.processed_events}")
print(f"- opportunities_seen: {report.opportunities_seen}")
print(f"- trades_executed: {report.trades_executed}")
print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
print(
f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
print(
f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
print(f"- miss_reasons: {dict(report.miss_reasons)}")
+41 -1
View File
@@ -5,11 +5,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncio
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.backtesting.runner import backtest_worker
from arbitrade.config.settings import Settings
from arbitrade.config.service import ConfigurationService
from arbitrade.exchange.fee_service import run_fee_sync_loop
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.logging_setup import configure_logging
from arbitrade.metrics import MetricsCalculator
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
@@ -22,21 +27,56 @@ def create_app(settings: Settings) -> FastAPI:
db = DuckDBStore(settings)
db.migrate()
kraken_client = KrakenRestClient(settings)
fee_sync_stop_event = asyncio.Event()
backtest_queue: asyncio.Queue[tuple[str, str,
dict[str, object] | None] | None] = asyncio.Queue()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await restore_runtime_state(app)
fee_sync_task = asyncio.create_task(
run_fee_sync_loop(
kraken_client,
db,
fee_sync_stop_event,
),
name="fee_sync_loop",
)
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db),
name="backtest_worker",
)
app.state.fee_sync_task = fee_sync_task
app.state.backtest_task = backtest_task
yield
fee_sync_stop_event.set()
fee_sync_task.cancel()
try:
await fee_sync_task
except asyncio.CancelledError:
pass
await backtest_queue.put(None) # poison pill
backtest_task.cancel()
try:
await backtest_task
except asyncio.CancelledError:
pass
await kraken_client.close()
await graceful_shutdown(app)
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings
app.state.store = db
app.state.kraken_client = kraken_client
app.state.fee_sync_stop_event = fee_sync_stop_event
app.state.backtest_queue = backtest_queue
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.configuration_service = ConfigurationService(settings, db, AuditRepository(db))
app.state.configuration_service = ConfigurationService(
settings, db, AuditRepository(db))
app.state.backtest_recent_reports = []
app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active,
+434 -101
View File
@@ -10,7 +10,7 @@ from typing import cast
from urllib.parse import parse_qs
import duckdb
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
@@ -19,7 +19,7 @@ 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
from arbitrade.storage.repositories import AuditRecord, AuditRepository, BacktestJobRepository, KrakenAccountSnapshotRepository
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
public_router = APIRouter()
@@ -127,12 +127,49 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
balances_value = ""
total_value = ""
equity_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 isinstance(balances_raw, str) and balances_raw:
try:
parsed = json.loads(balances_raw)
if isinstance(parsed, dict):
# Filter out zero balances, show non-zero as "AMT ASSET"
non_zero = {k: float(v)
for k, v in parsed.items() if float(v) > 0.0}
if non_zero:
balances_value = "<br>".join(
f"{v:.6g} {k}" for k, v in sorted(non_zero.items())
)
else:
balances_value = "No balances"
else:
balances_value = str(balances_raw)
except (json.JSONDecodeError, ValueError, TypeError):
balances_value = str(balances_raw)
elif balances_raw is not None:
balances_value = str(balances_raw)
if total_value_raw is not None:
total_value = f"{float(total_value_raw):.2f} USD"
# Query equity from kraken_account_snapshots
try:
equity_row = conn.execute("""
SELECT trade_balance_raw
FROM kraken_account_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
if equity_row is not None and equity_row[0] is not None:
tb_raw = equity_row[0]
if isinstance(tb_raw, str):
tb_raw = json.loads(tb_raw)
if isinstance(tb_raw, dict):
eb = tb_raw.get("eb")
equity_value = f"{float(eb):.2f} USD" if eb is not None else ""
except Exception:
pass
open_trade_rows = [
{
"trade_ref": str(row[0]),
@@ -152,15 +189,41 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
for row in latest_opportunities
]
# Query latest Kraken account snapshot for fee info
fee_tier = ""
maker_fee = ""
taker_fee = ""
thirty_day_volume = ""
try:
acct_row = conn.execute("""
SELECT fee_tier, maker_fee, taker_fee, thirty_day_volume
FROM kraken_account_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
if acct_row is not None:
fee_tier = str(acct_row[0]) if acct_row[0] is not None else ""
maker_fee = f"{float(acct_row[1]):.4%}" if acct_row[1] is not None else ""
taker_fee = f"{float(acct_row[2]):.4%}" if acct_row[2] is not None else ""
thirty_day_volume = f"{float(acct_row[3]):.2f}" if acct_row[3] is not None else ""
except Exception:
pass
return {
"status": "live",
"generated_at": datetime.now(UTC).isoformat(),
"balances": balances_value,
"total_value": total_value,
"equity": equity_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,
"fee_tier": fee_tier,
"maker_fee": maker_fee,
"taker_fee": taker_fee,
"thirty_day_volume": thirty_day_volume,
"fee_source": "API" if fee_tier != "" else "",
}
@@ -287,6 +350,157 @@ def _alert_status_snapshot(request: Request) -> dict[str, object]:
}
def _dashboard_config_context(request: Request) -> dict[str, object]:
ctl = _dashboard_controls_state(request)
rs = request.app.state.settings
alert_status = _alert_status_snapshot(request)
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 ""
)
max_exposure_per_asset = (
f"{float(rs.max_exposure_per_asset_usd):.2f} USD"
if rs.max_exposure_per_asset_usd is not None
else ""
)
max_exposure_per_asset_value = (
f"{float(rs.max_exposure_per_asset_usd):.2f}" if rs.max_exposure_per_asset_usd is not None else ""
)
daily_loss_limit = (
f"{float(rs.daily_loss_limit_usd):.2f} USD"
if rs.daily_loss_limit_usd is not None
else ""
)
daily_loss_limit_value = (
f"{float(rs.daily_loss_limit_usd):.2f}" if rs.daily_loss_limit_usd is not None else ""
)
cumulative_loss_limit = (
f"{float(rs.cumulative_loss_limit_usd):.2f} USD"
if rs.cumulative_loss_limit_usd is not None
else ""
)
cumulative_loss_limit_value = (
f"{float(rs.cumulative_loss_limit_usd):.2f}" if rs.cumulative_loss_limit_usd is not None else ""
)
max_source_latency = (
f"{float(rs.max_source_latency_ms):.1f} ms"
if rs.max_source_latency_ms is not None
else ""
)
max_source_latency_value = (
f"{float(rs.max_source_latency_ms):.1f}" if rs.max_source_latency_ms is not None else ""
)
max_apply_latency = (
f"{float(rs.max_apply_latency_ms):.1f} ms"
if rs.max_apply_latency_ms is not None
else ""
)
max_apply_latency_value = (
f"{float(rs.max_apply_latency_ms):.1f}" if rs.max_apply_latency_ms is not None else ""
)
max_consecutive_failures = (
str(rs.max_consecutive_failures) if rs.max_consecutive_failures is not None else ""
)
max_consecutive_failures_value = (
str(rs.max_consecutive_failures) if rs.max_consecutive_failures is not None else ""
)
strategy_stat_arb_enabled = bool(
getattr(rs, "strategy_enable_stat_arb_experiment", False))
return {
# Runtime
"app_env": rs.app_env,
"app_host": rs.app_host,
"app_port": str(rs.app_port),
"log_level": rs.log_level,
"log_json": "checked" if rs.log_json else "",
"paper_trading_mode": "checked" if rs.paper_trading_mode else "",
"trade_capital_usd": f"{float(rs.trade_capital_usd):.2f}",
"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,
"max_exposure_per_asset": max_exposure_per_asset,
"max_exposure_per_asset_value": max_exposure_per_asset_value,
"quote_balance_asset": rs.quote_balance_asset,
"min_order_size_usd": (
f"{float(rs.min_order_size_usd):.2f}" if rs.min_order_size_usd is not None else ""
),
"min_order_size_usd_value": (
f"{float(rs.min_order_size_usd):.2f}" if rs.min_order_size_usd is not None else ""
),
"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),
# Alerts
"alerts_enabled": "checked" if rs.alerts_enabled else "",
"alert_min_severity": rs.alert_min_severity,
"alert_dedup_seconds": f"{rs.alert_dedup_seconds:.0f}",
"alert_on_trade_events": "checked" if rs.alert_on_trade_events else "",
"alert_on_error_events": "checked" if rs.alert_on_error_events else "",
"alert_on_threshold_events": "checked" if rs.alert_on_threshold_events else "",
"alert_on_system_events": "checked" if rs.alert_on_system_events else "",
"telegram_alerts_enabled": "checked" if rs.telegram_alerts_enabled else "",
"telegram_bot_token": rs.telegram_bot_token or "",
"telegram_chat_id": rs.telegram_chat_id or "",
"discord_alerts_enabled": "checked" if rs.discord_alerts_enabled else "",
"discord_webhook_url": rs.discord_webhook_url or "",
"email_alerts_enabled": "checked" if rs.email_alerts_enabled else "",
"email_smtp_host": rs.email_smtp_host or "",
"email_smtp_port": str(rs.email_smtp_port),
"email_smtp_username": rs.email_smtp_username or "",
"email_smtp_password": "",
"email_alert_from": rs.email_alert_from or "",
"email_alert_to": rs.email_alert_to or "",
"email_smtp_use_tls": "checked" if rs.email_smtp_use_tls else "",
# Kraken
"kraken_rest_url": rs.kraken_rest_url,
"kraken_ws_url": rs.kraken_ws_url,
"kraken_private_rate_limit_seconds": f"{rs.kraken_private_rate_limit_seconds:.2f}",
"kraken_http_timeout_seconds": f"{rs.kraken_http_timeout_seconds:.1f}",
"kraken_retry_attempts": str(rs.kraken_retry_attempts),
"kraken_retry_base_delay_seconds": f"{rs.kraken_retry_base_delay_seconds:.2f}",
"kraken_api_key": rs.kraken_api_key or "",
"kraken_api_secret": "",
"kraken_api_key_permissions": rs.kraken_api_key_permissions,
"ws_heartbeat_timeout_seconds": f"{rs.ws_heartbeat_timeout_seconds:.1f}",
"ws_max_staleness_seconds": f"{rs.ws_max_staleness_seconds:.1f}",
# Risk
"daily_loss_limit": daily_loss_limit,
"daily_loss_limit_value": daily_loss_limit_value,
"cumulative_loss_limit": cumulative_loss_limit,
"cumulative_loss_limit_value": cumulative_loss_limit_value,
"max_source_latency": max_source_latency,
"max_source_latency_value": max_source_latency_value,
"max_apply_latency": max_apply_latency,
"max_apply_latency_value": max_apply_latency_value,
"max_consecutive_failures": max_consecutive_failures,
"max_consecutive_failures_value": max_consecutive_failures_value,
"kill_switch_active": "checked" if rs.kill_switch_active else "",
# Strategy stat-arb
"strategy_stat_arb_lookback_window": str(rs.strategy_stat_arb_lookback_window),
"strategy_stat_arb_entry_zscore": f"{rs.strategy_stat_arb_entry_zscore:.1f}",
"strategy_stat_arb_exit_zscore": f"{rs.strategy_stat_arb_exit_zscore:.1f}",
"strategy_stat_arb_max_holding_seconds": f"{rs.strategy_stat_arb_max_holding_seconds:.0f}",
# UI
"config_endpoint": "/dashboard/control/config",
}
def _dashboard_controls(request: Request) -> dict[str, object]:
ctl = _dashboard_controls_state(request)
rs = request.app.state.settings
@@ -389,24 +603,50 @@ 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:
def _fee_rate_for_profile(
profile: str,
custom_fee_rate: float | None,
request: Request | None = None,
) -> float:
"""Resolve fee rate from profile name.
- 'api': fetches latest maker_fee from kraken_account_snapshots (requires request)
- 'custom': uses custom_fee_rate
- legacy 'standard'/'maker_heavy'/'taker_heavy': still supported via hardcoded
fallback, logged at warning level
"""
normalized = _normalize_fee_profile(profile)
profile_map = {
"standard": 0.0026,
"maker_heavy": 0.0016,
"taker_heavy": 0.0035,
}
if normalized == "api":
if request is None:
raise ValueError("api fee profile requires request context")
store = request.app.state.store
repo = KrakenAccountSnapshotRepository(store)
latest = repo.latest_snapshot()
if latest is not None and latest.maker_fee is not None:
return latest.maker_fee
# Fallback to standard if no snapshot yet
return 0.0026
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}")
# Legacy hardcoded profiles (kept for backward compat, but soft-deprecated)
profile_map = {
"standard": 0.0026,
"maker_heavy": 0.0016,
"taker_heavy": 0.0035,
}
if normalized in profile_map:
return profile_map[normalized]
valid = ", ".join(sorted(list(profile_map.keys()) + ["api", "custom"]))
raise ValueError(f"fee_profile must be one of: {valid}")
def _parse_balances(raw: str) -> dict[str, float]:
balances: dict[str, float] = {}
@@ -453,10 +693,22 @@ def _build_cycles_from_events(
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 []
"""Fetch recent backtest jobs from DB."""
store = request.app.state.store
repo = BacktestJobRepository(store)
jobs = repo.list_jobs(limit=20)
return [
{
"job_id": j.id or "",
"run_at": j.created_at.isoformat() if j.created_at else "",
"events_path": j.events_path,
"status": j.status,
"config": j.config or {},
"report": j.report or {},
"error": j.error,
}
for j in jobs
]
def _backtesting_panel_context(
@@ -468,11 +720,13 @@ def _backtesting_panel_context(
defaults: dict[str, str] | None = None,
) -> dict[str, object]:
default_values = {
"events_path": "",
"symbols": "",
"start_time": "",
"end_time": "",
"starting_balances": "USD=1000.0",
"trade_capital": "100.0",
"min_profit_threshold": "0.0005",
"fee_profile": "standard",
"fee_profile": "api",
"custom_fee_rate": "",
"slippage_bps": "4.0",
"execution_latency_ms": "20.0",
@@ -507,13 +761,20 @@ async def _dashboard_response(
"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",
},
)
async def _health_response(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="health.html",
context={"title": "Arbitrade Health Check"},
)
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@@ -524,6 +785,11 @@ async def dashboard(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@router.get("/dashboard/health", response_class=HTMLResponse)
async def dashboard_health_page(request: Request) -> HTMLResponse:
return await _health_response(request)
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
@@ -583,6 +849,28 @@ async def dashboard_charts(request: Request) -> HTMLResponse:
)
@router.get("/dashboard/audit", response_class=HTMLResponse)
async def dashboard_audit_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="audit.html",
context={
"title": "Arbitrade Audit Trail",
"request": request,
**_dashboard_audit(request),
},
)
@router.get("/dashboard/audit/fragment", response_class=HTMLResponse)
async def dashboard_audit_fragment(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/audit.html",
context={"request": request, **_dashboard_audit(request)},
)
@router.get("/dashboard/fragment/audit", response_class=HTMLResponse)
async def dashboard_audit(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
@@ -592,6 +880,29 @@ async def dashboard_audit(request: Request) -> HTMLResponse:
)
@router.get("/dashboard/config", response_class=HTMLResponse)
async def dashboard_config_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="config.html",
context={
"title": "Arbitrade Configuration",
"request": request,
"config_endpoint": "/dashboard/control/config",
**_dashboard_config_context(request),
},
)
@router.get("/dashboard/fragment/config", response_class=HTMLResponse)
async def dashboard_config_fragment(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/config.html",
context={"request": request, **_dashboard_config_context(request)},
)
@router.get("/dashboard/api/alerts/status", response_class=JSONResponse)
async def dashboard_alert_status(request: Request) -> JSONResponse:
return JSONResponse(_alert_status_snapshot(request))
@@ -614,114 +925,65 @@ async def dashboard_backtesting_reports(request: Request) -> JSONResponse:
@router.post("/dashboard/backtesting/run", response_class=HTMLResponse)
async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
"""Submit a backtest job to the async queue. Returns panel with job list."""
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")),
"fee_profile": _normalize_fee_profile(form.get("fee_profile", "api")),
"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"),
"start_time": form.get("start_time", ""),
"end_time": form.get("end_time", ""),
"symbols": form.get("symbols", ""),
"source": form.get("source", "db"),
}
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
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"])
defaults["fee_profile"], custom_fee_rate, request=request)
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"],
config_dict: dict[str, object] = {
"source": defaults["source"],
"starting_balances": defaults["starting_balances"],
"trade_capital": float(defaults["trade_capital"]),
"min_profit_threshold": float(defaults["min_profit_threshold"]),
"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,
},
"fee_profile": defaults["fee_profile"],
"slippage_bps": float(defaults["slippage_bps"]),
"execution_latency_ms": float(defaults["execution_latency_ms"]),
"start_time": defaults["start_time"],
"end_time": defaults["end_time"],
"symbols": defaults["symbols"],
}
reports = _recent_backtest_reports(request)
reports.insert(0, report_item)
del reports[20:]
store = request.app.state.store
repo = BacktestJobRepository(store)
events_label = defaults["symbols"] if defaults["symbols"] else "DB-sourced"
job = repo.create_job(events_label, config_dict)
msg_job = job.id[:8] if job.id else "unknown"
queue = request.app.state.backtest_queue
await queue.put((job.id or "", config_dict))
_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,
},
event_type="dashboard.backtesting.submit",
decision="queued",
payload={"job_id": job.id, "source": defaults["source"]},
)
context = _backtesting_panel_context(
request,
status="completed",
message="Backtest run completed successfully.",
latest_report=report_item,
status="submitted",
message=f"Job {msg_job}... queued. Refresh to see results.",
defaults=defaults,
)
except ValueError as exc:
@@ -739,6 +1001,77 @@ async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
)
@router.post("/dashboard/backtesting/job/{job_id}/delete", response_class=HTMLResponse)
async def dashboard_backtesting_delete(request: Request, job_id: str) -> HTMLResponse:
store = request.app.state.store
repo = BacktestJobRepository(store)
repo.delete_job(job_id)
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **_backtesting_panel_context(request)},
)
@router.get("/dashboard/backtesting/job/{job_id}", response_class=HTMLResponse)
async def dashboard_backtesting_job_detail(request: Request, job_id: str) -> HTMLResponse:
store = request.app.state.store
repo = BacktestJobRepository(store)
job = repo.get_job(job_id)
if job is None:
return HTMLResponse("<p>Job not found</p>", status_code=404)
report_html = "<div class='meta'>No report yet</div>"
if job.report:
i = job.id[:8] if job.id else "unknown"
r = job.report
report_html = (
f"<div class='panel'>"
f"<div class='label'>Job {i}... Report</div>"
f"<div class='meta'>Status: {job.status}</div>"
f"<div class='meta'>Events: {job.events_path}</div>"
f"<div class='meta'>Processed: {r.get('processed_events', '')}</div>"
f"<div class='meta'>Opportunities: {r.get('opportunities_seen', '')}</div>"
f"<div class='meta'>Trades: {r.get('trades_executed', '')}</div>"
f"<div class='meta'>Realized P&L: {r.get('realized_pnl_usd', '')} USD</div>"
f"<div class='meta'>Max drawdown: {r.get('max_drawdown_usd', '')} USD</div>"
f"<div class='meta'>Win rate: {r.get('win_rate', '')}</div>"
f"<div class='meta'>Fill rate: {r.get('fill_rate', '')}</div>"
f"<div class='meta'>Latency p50: {r.get('execution_latency_p50_ms', '')} ms</div>"
f"<div class='meta'>Created: {job.created_at}</div>"
f"</div>"
)
return HTMLResponse(report_html)
@router.get("/dashboard/backtesting/job/{job_id}/export", response_class=Response)
async def dashboard_backtesting_export(request: Request, job_id: str) -> Response:
store = request.app.state.store
repo = BacktestJobRepository(store)
job = repo.get_job(job_id)
if job is None:
return Response("Job not found", status_code=404)
payload: dict[str, object] = {
"job_id": job_id,
"status": job.status,
"events_path": job.events_path,
"created_at": job.created_at.isoformat() if job.created_at else None,
}
if job.report:
payload["report"] = job.report
if job.config:
payload["config"] = job.config
return Response(
content=orjson.dumps(payload).decode("utf-8"),
media_type="application/x-jsonlines",
headers={
"Content-Disposition": f"attachment; filename=backtest_{job_id[:8]}.jsonl"},
)
@router.post("/dashboard/control/start", response_class=HTMLResponse)
async def dashboard_control_start(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request)
+99 -7
View File
@@ -56,7 +56,7 @@ class ReplayBookEvent:
@dataclass(frozen=True, slots=True)
class BacktestConfig:
fee_rate: float = 0.0026
fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
min_profit_threshold: float = 0.0005
trade_capital: float = 100.0
quote_asset: str = "USD"
@@ -153,7 +153,8 @@ def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
or not isinstance(raw_level[1], int | float)
):
raise ValueError("Each level must be [price, volume]")
levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
levels.append(BookLevel(price=float(
raw_level[0]), volume=float(raw_level[1])))
return tuple(levels)
@@ -172,7 +173,8 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
raise ValueError("Each event must include timestamp and symbol")
occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
occurred_at = datetime.fromisoformat(
timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
events.append(
ReplayBookEvent(
occurred_at=occurred_at,
@@ -185,6 +187,92 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
return sorted(events, key=lambda event: event.occurred_at)
def load_replay_events_from_db(
store: object,
*,
symbols: list[str] | None = None,
start: datetime | None = None,
end: datetime | None = None,
) -> list[ReplayBookEvent]:
"""Load replay events from market_snapshots table.
Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS).
Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]}
"""
with store.connect() as conn: # type: ignore[union-attr]
query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1"
params: list[object] = []
if symbols:
placeholders = ",".join("?" for _ in symbols)
query += f" AND symbol IN ({placeholders})"
params.extend(symbols)
if start is not None:
query += " AND snapshot_at >= ?"
params.append(start)
if end is not None:
query += " AND snapshot_at <= ?"
params.append(end)
query += " ORDER BY snapshot_at ASC"
# type: ignore[union-attr]
rows = conn.execute(query, params).fetchall()
events: list[ReplayBookEvent] = []
for row in rows:
snapshot_at: datetime = row[0]
symbol: str = row[1]
payload_raw = row[2]
if isinstance(payload_raw, str):
payload = orjson.loads(payload_raw)
elif isinstance(payload_raw, dict):
payload = payload_raw
else:
continue
data = payload.get("data")
if not isinstance(data, list) or not data:
continue
first = data[0]
if not isinstance(first, dict):
continue
bids = _parse_kraken_book_levels(first.get("bids"))
asks = _parse_kraken_book_levels(first.get("asks"))
if bids or asks:
events.append(
ReplayBookEvent(
occurred_at=snapshot_at,
symbol=symbol,
bids=bids,
asks=asks,
)
)
return events
def _parse_kraken_book_levels(
raw_levels: object | None,
) -> tuple[BookLevel, ...]:
"""Parse Kraken WS book level format: [{price, qty}, ...]."""
if not isinstance(raw_levels, list):
return ()
levels: list[BookLevel] = []
for level in raw_levels:
if isinstance(level, dict) and "price" in level and "qty" in level:
levels.append(
BookLevel(price=float(level["price"]),
volume=float(level["qty"]))
)
return tuple(levels)
class BacktestReplayEngine:
def __init__(
self,
@@ -206,7 +294,8 @@ class BacktestReplayEngine:
min_order_size_by_pair=config.min_order_size_by_pair,
)
self._pre_trade = PreTradeValidator()
self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
self._trade_limits = TradeLimitsGuard(
max_concurrent_trades=config.max_concurrent_trades)
self._simulated_rest = _SimulatedRestClient(
self._clock,
slippage_bps=config.slippage_bps,
@@ -241,7 +330,8 @@ class BacktestReplayEngine:
trades_executed = 0
realized_pnl = 0.0
equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
equity = float(starting_balances.get(
self._config.quote_asset.upper(), 0.0))
peak_equity = equity
max_drawdown = 0.0
@@ -284,7 +374,8 @@ class BacktestReplayEngine:
result = await self._sequencer.execute(opportunity)
self._trade_limits.close_trade(exposure)
execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
execution_latencies.append(
self._simulated_rest.last_trade_latency_ms)
fill_samples.append(self._simulated_rest.last_fill_ratio)
if not result.success:
@@ -307,7 +398,8 @@ class BacktestReplayEngine:
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
win_rate = (wins / len(realized_samples)) if realized_samples else None
fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
fill_rate = (sum(fill_samples) / len(fill_samples)
) if fill_samples else None
return BacktestReport(
started_at=events[0].occurred_at if events else self._clock.now,
+173
View File
@@ -0,0 +1,173 @@
"""Async backtest job runner — picks up pending jobs from DB and executes them."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from pathlib import Path
import structlog
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
load_replay_events,
load_replay_events_from_db,
)
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import BacktestJobRepository
_LOG = structlog.get_logger(__name__)
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 _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:
continue
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances or {"USD": 1000.0}
async def run_backtest_job(
job_id: str,
config_dict: dict[str, object] | None,
store: DuckDBStore,
) -> None:
"""Execute a single backtest job: load events from DB or file, run engine, store report."""
repo = BacktestJobRepository(store)
repo.update_status(job_id, "running")
_LOG.info("backtest_job_started", job_id=job_id)
try:
config = config_dict or {}
events_path = str(config.get("events_path", ""))
symbols_raw = config.get("symbols")
source = str(config.get("source", "db"))
start_dt = None
end_dt = None
if source == "db":
start_str = config.get("start_time")
end_str = config.get("end_time")
if isinstance(start_str, str) and start_str:
start_dt = datetime.fromisoformat(
start_str.replace("Z", "+00:00"))
if isinstance(end_str, str) and end_str:
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
symbols: list[str] | None = None
if isinstance(symbols_raw, str) and symbols_raw.strip():
symbols = [s.strip().upper()
for s in symbols_raw.split(",") if s.strip()]
elif isinstance(symbols_raw, list):
symbols = [str(s).upper() for s in symbols_raw]
events = load_replay_events_from_db(
store, symbols=symbols, start=start_dt, end=end_dt,
)
else:
path = Path(events_path)
if not path.is_absolute():
path = Path("data") / path
path = path.resolve()
events = load_replay_events(path)
if not events:
raise ValueError("No events found for backtest")
starting_balances_raw = str(config.get(
"starting_balances", "USD=1000.0"))
starting_balances = _parse_balances(starting_balances_raw)
fee_rate = float(config.get("fee_rate", 0.0026))
trade_capital = float(config.get("trade_capital", 100.0))
min_profit_threshold = float(
config.get("min_profit_threshold", 0.0005))
slippage_bps = float(config.get("slippage_bps", 4.0))
execution_latency_ms = float(config.get("execution_latency_ms", 20.0))
cycles_by_pair, available_pairs = _build_cycles_from_events(
{e.symbol.upper() for e in events}
)
if not cycles_by_pair:
raise ValueError("No triangular cycles found in event data")
bt_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,
)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=bt_config,
started_at=events[0].occurred_at,
)
report = await engine.run(events, starting_balances=starting_balances)
report_dict = {
"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,
"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,
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
}
repo.store_report(job_id, report_dict)
repo.update_status(job_id, "completed")
_LOG.info("backtest_job_completed", job_id=job_id,
pnl=report.realized_pnl_usd)
except Exception as exc:
repo.update_status(job_id, "failed", error=str(exc))
_LOG.exception("backtest_job_failed", job_id=job_id, error=str(exc))
async def backtest_worker(
queue: asyncio.Queue[tuple[str, dict[str, object] | None] | None],
store: DuckDBStore,
) -> None:
"""Worker coroutine: pull jobs from queue and execute them one at a time."""
_LOG.info("backtest_worker_started")
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
job_id, config = item
try:
await run_backtest_job(job_id, config, store)
except Exception:
_LOG.exception("backtest_worker_unhandled_error", job_id=job_id)
finally:
queue.task_done()
_LOG.info("backtest_worker_stopped")
+19 -8
View File
@@ -39,14 +39,6 @@ class ConfigPairing(BaseModel):
updated_at: datetime | None = None
class ConfigPairFee(BaseModel):
pairing_id: int
market_type: str # 'crypto_crypto' or 'crypto_fiat'
maker_fee_rate: float
taker_fee_rate: float
updated_at: datetime | None = None
class ConfigBacktestingDefaults(BaseModel):
starting_balances: dict[str, float] | None = None
trade_capital: float | None = None
@@ -208,3 +200,22 @@ class ConfigurationService:
def get_all_settings(self) -> dict[str, Any]:
"""Get all configuration settings."""
return self._loaded_settings.copy()
# --- Pairing & Fee Management ---
def _pairing_repo(self):
from arbitrade.storage.repositories import ConfigPairingRepository
return ConfigPairingRepository(self._store)
def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings."""
return self._pairing_repo().list_pairings()
def create_pairing(self, base_asset: str, quote_asset: str, source: str = "manual") -> ConfigPairing:
"""Create a new currency pairing."""
existing = self._pairing_repo().get_pairing(base_asset, quote_asset)
if existing:
return existing
pairing = ConfigPairing(
base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source)
return self._pairing_repo().create_pairing(pairing)
+3 -2
View File
@@ -47,7 +47,7 @@ def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, Ord
detector = IncrementalCycleDetector(
index,
fee_rate=0.001,
fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
min_profit_threshold=0.001,
max_depth_levels=5,
max_book_age_seconds=10.0,
@@ -92,7 +92,8 @@ def run_incremental_detection_benchmark(
def main() -> None:
parser = argparse.ArgumentParser(description="Benchmark incremental detection latency")
parser = argparse.ArgumentParser(
description="Benchmark incremental detection latency")
parser.add_argument("--iterations", type=int, default=50_000)
parser.add_argument("--target-ms", type=float, default=1.0)
args = parser.parse_args()
+152
View File
@@ -0,0 +1,152 @@
"""Fee service -- fetch Kraken account fee tier, sync pair fees, persist snapshots."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import structlog
import orjson
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import (
KrakenAccountSnapshot,
KrakenAccountSnapshotRepository,
)
_LOG = structlog.get_logger(__name__)
_FEE_REFRESH_INTERVAL_SECONDS = 86400 # 1 day
async def fetch_and_store_account_snapshot(
client: KrakenRestClient,
store: DuckDBStore,
) -> KrakenAccountSnapshot | None:
"""Query TradeVolume + TradeBalance, persist as snapshot.
Returns the snapshot or None if either call failed.
"""
repo = KrakenAccountSnapshotRepository(store)
try:
volume_data = await client.trade_volume()
except Exception:
_LOG.exception("trade_volume_fetch_failed")
return None
try:
balance_data = await client.trade_balance()
except Exception:
_LOG.exception("trade_balance_fetch_failed")
return None
fee_tier = volume_data.get("fee_tier") if isinstance(
volume_data, dict) else None
fees_dict = volume_data.get("fees") if isinstance(
volume_data, dict) else None
fees_maker = volume_data.get("fees_maker") if isinstance(
volume_data, dict) else None
currency = volume_data.get("currency")
thirty_day_volume_str = volume_data.get("volume")
maker_fee = None
taker_fee = None
fee_tier_str = str(fee_tier) if fee_tier is not None else None
# Extract current tier's maker/taker rates from fees dict
if isinstance(fees_dict, dict) and fee_tier_str is not None:
tier_fees = fees_dict.get(fee_tier_str)
if isinstance(tier_fees, dict):
maker_val = tier_fees.get("maker")
taker_val = tier_fees.get("taker")
maker_fee = float(maker_val) if maker_val is not None else None
taker_fee = float(taker_val) if taker_val is not None else None
# Build fee schedule as combined dict
fee_schedule: dict[str, object] = {}
if isinstance(fees_dict, dict):
fee_schedule["fees"] = fees_dict
if isinstance(fees_maker, dict):
fee_schedule["fees_maker"] = fees_maker
if currency is not None:
fee_schedule["currency"] = currency
thirty_day_volume = (
float(thirty_day_volume_str) if thirty_day_volume_str is not None else None
)
snapshot = KrakenAccountSnapshot(
snapshot_at=datetime.now(timezone.utc),
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
thirty_day_volume=thirty_day_volume,
trade_balance_raw=balance_data if isinstance(
balance_data, dict) else None,
fee_schedule_raw=fee_schedule if fee_schedule else None,
)
repo.insert_snapshot(snapshot)
_LOG.info(
"account_snapshot_stored",
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
)
# Fetch wallet balances and write to portfolio_snapshots
try:
wallet_balances = await client.balances()
total_value = 0.0
if isinstance(balance_data, dict):
eb = balance_data.get("eb")
total_value = float(eb) if eb is not None else 0.0
with store.connect() as conn:
conn.execute(
"INSERT INTO portfolio_snapshots (snapshot_at, balances, total_value_usd) VALUES (?, ?, ?)",
(
datetime.now(timezone.utc),
orjson.dumps(wallet_balances).decode(
"utf-8") if wallet_balances else None,
total_value,
),
)
_LOG.info("portfolio_snapshot_stored", total_value_usd=total_value)
except Exception:
_LOG.exception("balances_fetch_or_store_failed")
return snapshot
async def run_fee_sync_loop(
client: KrakenRestClient,
store: DuckDBStore,
stop_event: asyncio.Event,
) -> None:
"""Periodic loop: fetch account snapshot every hour.
Runs until stop_event is set.
"""
_LOG.info("fee_sync_loop_started",
interval_s=_FEE_REFRESH_INTERVAL_SECONDS)
while not stop_event.is_set():
try:
await fetch_and_store_account_snapshot(client, store)
except Exception:
_LOG.exception("fee_sync_loop_iteration_failed")
# Wait with stop_event check
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=_FEE_REFRESH_INTERVAL_SECONDS,
)
break # stop_event was set
except asyncio.TimeoutError:
pass # timeout elapsed, loop again
_LOG.info("fee_sync_loop_stopped")
+31
View File
@@ -279,3 +279,34 @@ class KrakenRestClient:
"/0/private/CancelOrder",
data={"txid": order_id},
)
async def trade_volume(self, *, pair: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeVolume for fee tier, 30d volume, and fee schedule.
Returns dict with keys: currency, volume, fees (dict of tiers),
fees_maker (dict of tier->fee mappings), fee_tier (current tier).
If pair provided, returns pair-specific fee info.
"""
data: dict[str, str] = {}
if pair is not None:
data["pair"] = pair
return await self._throttled_private_call(
"/0/private/TradeVolume",
data=data if data else None,
)
async def trade_balance(self, *, asset: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeBalance for equity, trade balance, margin info.
Returns dict with keys: eb (equivalent balance/equity),
tb (trade balance), m (margin amount), n (unrealized net P&L),
c (cost basis), v (current valuation), e (equity).
If asset provided, returns asset-class-specific balance.
"""
data: dict[str, str] = {}
if asset is not None:
data["asset"] = asset
return await self._throttled_private_call(
"/0/private/TradeBalance",
data=data if data else None,
)
+142 -10
View File
@@ -46,22 +46,14 @@ CREATE TABLE IF NOT EXISTS config_pairings (
UNIQUE(base_asset, quote_asset)
);
CREATE TABLE IF NOT EXISTS config_pair_fees (
pairing_id INTEGER NOT NULL,
market_type VARCHAR NOT NULL, -- 'crypto_crypto' or 'crypto_fiat'
maker_fee_rate DOUBLE NOT NULL,
taker_fee_rate DOUBLE NOT NULL,
updated_at TIMESTAMP DEFAULT current_timestamp,
FOREIGN KEY (pairing_id) REFERENCES config_pairings(id)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id INTEGER PRIMARY KEY,
starting_balances JSON,
trade_capital DOUBLE,
min_profit_threshold DOUBLE,
slippage_bps INTEGER,
execution_latency_ms INTEGER
execution_latency_ms INTEGER,
fee_source VARCHAR DEFAULT 'api'
);
CREATE TABLE IF NOT EXISTS opportunities (
@@ -145,10 +137,34 @@ CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
last_known_balances JSON,
note VARCHAR
);
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMP NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE,
taker_fee DOUBLE,
thirty_day_volume DOUBLE,
trade_balance_raw JSON,
fee_schedule_raw JSON
);
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT uuid(),
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSON,
report JSON,
error VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp,
started_at TIMESTAMP,
finished_at TIMESTAMP
);
"""
class DuckDBStore:
SCHEMA_VERSION = 5
def __init__(self, settings: Settings) -> None:
self._db_path = Path(settings.duckdb_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
@@ -170,6 +186,122 @@ class DuckDBStore:
finally:
conn.close()
def _get_table_columns(self, conn, table_name: str) -> set[str]:
try:
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
return {str(row[1]) for row in rows}
except Exception:
return set()
def _table_exists(self, conn, table_name: str) -> bool:
try:
result = conn.execute(
f"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{table_name}'"
).fetchone()
return result[0] > 0
except Exception:
return False
def _ensure_column(self, conn, table_name: str, column_def: str) -> None:
"""Add a column to a table if it doesn't already exist."""
existing = self._get_table_columns(conn, table_name)
col_name = column_def.split()[0]
if col_name not in existing:
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_def}")
def migrate(self) -> None:
with self.connect() as conn:
# Run CREATE TABLE IF NOT EXISTS for all tables
conn.execute(SCHEMA_SQL)
# Ensure schema_migrations table exists and get current version
if not self._table_exists(conn, "schema_migrations"):
conn.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
)
""")
# Get current schema version
try:
row = conn.execute(
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
).fetchone()
current_version = row[0] if row else 0
except Exception:
current_version = 0
# Apply migrations for each version
if current_version < 1:
# Migration v1: Add missing columns to trades table
# Note: DuckDB does not support ADD COLUMN with constraints
conn.execute(
"ALTER TABLE trades ADD COLUMN IF NOT EXISTS trade_ref VARCHAR")
conn.execute(
"ALTER TABLE trades ADD COLUMN IF NOT EXISTS estimated_pnl DOUBLE")
conn.execute(
"ALTER TABLE trades ADD COLUMN IF NOT EXISTS capital_used DOUBLE")
conn.execute(
"ALTER TABLE trades ADD COLUMN IF NOT EXISTS cycle VARCHAR")
conn.execute(
"ALTER TABLE trades ADD COLUMN IF NOT EXISTS leg_count INTEGER")
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (1)")
_LOG.info("migration_applied", version=1)
if current_version < 2:
# Migration v2: Ensure config_backtesting_defaults table
# config_backtesting_defaults already created by SCHEMA_SQL
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)")
_LOG.info("migration_applied", version=2)
if current_version < 3:
# Migration v3: Add kraken_account_snapshots table
conn.execute("""
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMP NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE,
taker_fee DOUBLE,
thirty_day_volume DOUBLE,
trade_balance_raw JSON,
fee_schedule_raw JSON
)
""")
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
_LOG.info("migration_applied", version=3)
if current_version < 4:
# Migration v4: Add fee_source to backtesting defaults
conn.execute(
"ALTER TABLE config_backtesting_defaults ADD COLUMN IF NOT EXISTS fee_source VARCHAR DEFAULT 'api'")
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)")
_LOG.info("migration_applied", version=4)
if current_version < 5:
conn.execute("""
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT uuid(),
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSON,
report JSON,
error VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp,
started_at TIMESTAMP,
finished_at TIMESTAMP
)
""")
conn.execute(
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (5)")
_LOG.info("migration_applied", version=5)
# Update version to current
conn.execute(
f"INSERT OR REPLACE INTO schema_migrations (version, applied_at) "
f"VALUES ({self.SCHEMA_VERSION}, current_timestamp)"
)
+168 -118
View File
@@ -6,7 +6,7 @@ from typing import Any
import orjson
from arbitrade.config.service import ConfigBacktestingDefaults, ConfigPairing, ConfigSection, ConfigSetting, ConfigPairFee
from arbitrade.config.service import ConfigBacktestingDefaults, ConfigPairing, ConfigSection, ConfigSetting
from arbitrade.storage.db import DuckDBStore
@@ -729,123 +729,6 @@ class ConfigPairingRepository:
]
class ConfigPairFeeRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def create_pair_fee(self, pair_fee: ConfigPairFee) -> ConfigPairFee:
"""Create a new pairing fee record."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
INSERT INTO config_pair_fees (pairing_id, market_type, maker_fee_rate, taker_fee_rate)
VALUES (?, ?, ?, ?)
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
""",
(
pair_fee.pairing_id,
pair_fee.market_type,
pair_fee.maker_fee_rate,
pair_fee.taker_fee_rate,
),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4]
)
raise ValueError("Failed to create pair fee")
def get_pair_fee(self, pairing_id: int, market_type: str) -> ConfigPairFee | None:
"""Get a pairing fee by pairing ID and market type."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
FROM config_pair_fees
WHERE pairing_id = ? AND market_type = ?
""",
(pairing_id, market_type),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4]
)
return None
def update_pair_fee(self, pairing_id: int, market_type: str, pair_fee: ConfigPairFee) -> ConfigPairFee:
"""Update an existing pairing fee."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
UPDATE config_pair_fees
SET maker_fee_rate = ?, taker_fee_rate = ?
WHERE pairing_id = ? AND market_type = ?
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
""",
(
pair_fee.maker_fee_rate,
pair_fee.taker_fee_rate,
pairing_id,
market_type,
),
)
row = cursor.fetchone()
if row:
return ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4]
)
raise ValueError("Failed to update pair fee")
def delete_pair_fee(self, pairing_id: int, market_type: str) -> bool:
"""Delete a pairing fee."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
DELETE FROM config_pair_fees
WHERE pairing_id = ? AND market_type = ?
""",
(pairing_id, market_type),
)
return cursor.rowcount > 0
def list_pair_fees(self, pairing_id: int) -> list[ConfigPairFee]:
"""List all fees for a pairing."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
FROM config_pair_fees
WHERE pairing_id = ?
ORDER BY market_type
""",
(pairing_id,),
)
return [
ConfigPairFee(
pairing_id=row[0],
market_type=row[1],
maker_fee_rate=row[2],
taker_fee_rate=row[3],
updated_at=row[4]
)
for row in cursor.fetchall()
]
class ConfigBacktestingDefaultsRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
@@ -932,3 +815,170 @@ class ConfigBacktestingDefaultsRepository:
execution_latency_ms=row[5]
)
raise ValueError("Failed to update backtesting defaults")
@dataclass(slots=True)
class KrakenAccountSnapshot:
snapshot_at: datetime
fee_tier: str | None
maker_fee: float | None
taker_fee: float | None
thirty_day_volume: float | None
trade_balance_raw: dict[str, Any] | None
fee_schedule_raw: dict[str, Any] | None
class KrakenAccountSnapshotRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def insert_snapshot(self, snapshot: KrakenAccountSnapshot) -> None:
with self._store.connect() as conn:
conn.execute(
"""
INSERT INTO kraken_account_snapshots
(snapshot_at, fee_tier, maker_fee, taker_fee,
thirty_day_volume, trade_balance_raw, fee_schedule_raw)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
snapshot.snapshot_at,
snapshot.fee_tier,
snapshot.maker_fee,
snapshot.taker_fee,
snapshot.thirty_day_volume,
orjson.dumps(snapshot.trade_balance_raw).decode("utf-8")
if snapshot.trade_balance_raw else None,
orjson.dumps(snapshot.fee_schedule_raw).decode("utf-8")
if snapshot.fee_schedule_raw else None,
),
)
def latest_snapshot(self) -> KrakenAccountSnapshot | None:
with self._store.connect() as conn:
row = conn.execute(
"""
SELECT snapshot_at, fee_tier, maker_fee, taker_fee,
thirty_day_volume, trade_balance_raw, fee_schedule_raw
FROM kraken_account_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
"""
).fetchone()
if row is None:
return None
return KrakenAccountSnapshot(
snapshot_at=row[0],
fee_tier=row[1],
maker_fee=row[2],
taker_fee=row[3],
thirty_day_volume=row[4],
trade_balance_raw=orjson.loads(row[5]) if row[5] else None,
fee_schedule_raw=orjson.loads(row[6]) if row[6] else None,
)
@dataclass(slots=True)
class BacktestJobRecord:
id: str | None = None
status: str = "pending"
events_path: str = ""
config: dict[str, Any] | None = None
report: dict[str, Any] | None = None
error: str | None = None
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class BacktestJobRepository:
def __init__(self, store: DuckDBStore) -> None:
self._store = store
def create_job(self, events_path: str, config: dict[str, Any] | None = None) -> BacktestJobRecord:
with self._store.connect() as conn:
row = conn.execute(
"""
INSERT INTO backtest_jobs (events_path, config)
VALUES (?, ?)
RETURNING id, status, events_path, config, created_at
""",
(events_path, orjson.dumps(config).decode(
"utf-8") if config else None),
).fetchone()
if row is None:
raise ValueError("Failed to create backtest job")
return BacktestJobRecord(
id=str(row[0]), status=str(row[1]), events_path=str(row[2]),
config=orjson.loads(row[3]) if row[3] else None,
created_at=row[4],
)
def update_status(self, job_id: str, status: str, error: str | None = None) -> None:
with self._store.connect() as conn:
if status == "running":
conn.execute(
"UPDATE backtest_jobs SET status = ?, started_at = current_timestamp WHERE id = ?",
(status, job_id),
)
elif status in ("completed", "failed"):
conn.execute(
"UPDATE backtest_jobs SET status = ?, finished_at = current_timestamp, error = ? WHERE id = ?",
(status, error, job_id),
)
else:
conn.execute(
"UPDATE backtest_jobs SET status = ?, error = ? WHERE id = ?",
(status, error, job_id),
)
def store_report(self, job_id: str, report: dict[str, Any]) -> None:
with self._store.connect() as conn:
conn.execute(
"UPDATE backtest_jobs SET report = ? WHERE id = ?",
(orjson.dumps(report).decode("utf-8"), job_id),
)
def get_job(self, job_id: str) -> BacktestJobRecord | None:
with self._store.connect() as conn:
row = conn.execute(
"""SELECT id, status, events_path, config, report, error,
created_at, started_at, finished_at
FROM backtest_jobs WHERE id = ?""",
(job_id,),
).fetchone()
if row is None:
return None
return BacktestJobRecord(
id=str(row[0]), status=str(row[1]), events_path=str(row[2]),
config=orjson.loads(row[3]) if row[3] else None,
report=orjson.loads(row[4]) if row[4] else None,
error=str(row[5]) if row[5] else None,
created_at=row[6], started_at=row[7], finished_at=row[8],
)
def list_jobs(self, limit: int = 20) -> list[BacktestJobRecord]:
with self._store.connect() as conn:
rows = conn.execute(
"""SELECT id, status, events_path, config, report, error,
created_at, started_at, finished_at
FROM backtest_jobs ORDER BY created_at DESC LIMIT ?""",
(limit,),
).fetchall()
return [
BacktestJobRecord(
id=str(r[0]), status=str(r[1]), events_path=str(r[2]),
config=orjson.loads(r[3]) if r[3] else None,
report=orjson.loads(r[4]) if r[4] else None,
error=str(r[5]) if r[5] else None,
created_at=r[6], started_at=r[7], finished_at=r[8],
)
for r in rows
]
def delete_job(self, job_id: str) -> bool:
with self._store.connect() as conn:
result = conn.execute(
"DELETE FROM backtest_jobs WHERE id = ?", (job_id,),
)
return result.rowcount > 0
@@ -14,9 +14,8 @@
color: #e5eefb;
}
.shell {
max-width: 1120px;
margin: 0 auto;
padding: 32px 20px 48px;
max-width: none;
padding: 24px 32px 48px;
}
.hero {
display: flex;
@@ -141,7 +140,7 @@
</head>
<body>
<main class="{% block main_class %}shell{% endblock %}">
{% block content %}{% endblock %}
{% block header %}{% endblock %} {% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
+30
View File
@@ -0,0 +1,30 @@
<section class="hero">
<div>
<h1 class="title">{{ page_title }}</h1>
<p class="subtitle">{{ page_subtitle }}</p>
</div>
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
"secondary"}, {"url": "/dashboard/backtesting", "label": "Backtesting",
"class": "secondary"}, {"url": "/dashboard/health", "label": "Health",
"class": "secondary"}, ] %}
<div class="toolbar">
{% for link in nav_links %}
<a
class="button{% if link.class %} {{ link.class }}{% endif %}"
href="{{ link.url }}"
{%
if
link.hx_get
%}hx-get="{{ link.hx_get }}"
hx-target="{{ link.hx_target }}"
hx-swap="{{ link.hx_swap | default('outerHTML') }}"
{%
endif
%}
>
{{ link.label }}
</a>
{% endfor %}
</div>
</section>
+16
View File
@@ -0,0 +1,16 @@
{% extends "_base.html" %} {% block title %}Audit Trail{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with page_title="Audit
Trail", page_subtitle="System activity, configuration changes, and execution
decisions." %} {% include "_header.html" %} {% endwith %} {% endblock %} {%
block content %}
<section
id="audit-shell"
hx-get="/dashboard/audit/fragment"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %}
+5 -13
View File
@@ -1,16 +1,8 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
content %}
<section class="hero">
<div>
<h1 class="title">Backtesting</h1>
<p class="subtitle">
Replay controls, run status, and recent summary reports.
</p>
</div>
<div class="toolbar">
<a class="button secondary" href="{{ dashboard_endpoint }}">Dashboard</a>
</div>
</section>
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Backtesting", page_subtitle="Replay controls, run status, and recent
summary reports." %} {% include "_header.html" %} {% endwith %} {% endblock %}
{% block content %}
<section
id="backtesting-shell"
-148
View File
@@ -1,148 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title or "Arbitrade" }}{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
{% block head_scripts %}{% endblock %}
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #0b1220;
color: #e5eefb;
}
.shell {
max-width: 1120px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.title {
font-size: 2rem;
margin: 0 0 8px;
}
.subtitle {
margin: 0;
color: #9fb2d0;
}
.panel {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 20px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
background: rgba(255, 255, 255, 0.04);
border-radius: 14px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.label {
color: #9fb2d0;
font-size: 0.85rem;
margin-bottom: 8px;
}
.value {
font-size: 1.4rem;
font-weight: 700;
}
.meta {
margin-top: 18px;
color: #7f95b7;
font-size: 0.85rem;
}
.toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.toolbar form {
margin: 0;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
cursor: pointer;
padding: 10px 14px;
border-radius: 999px;
background: #2d6cdf;
color: white;
text-decoration: none;
font: inherit;
}
.button.secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.button.danger {
background: #ba3d4f;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
color: #9fb2d0;
font-size: 0.9rem;
}
.field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: #e5eefb;
font: inherit;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.field.checkbox input {
width: auto;
}
.control-actions {
display: flex;
gap: 12px;
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>
{% block extra_style %}{% endblock %}
</head>
<body>
<main class="{% block main_class %}shell{% endblock %}">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Configuration", page_subtitle="Runtime settings, alerts, exchange,
risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock
%} {% block content %}
<section
id="config-shell"
hx-get="/dashboard/fragment/config"
hx-target="this"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
{% include "partials/config.html" %}
</section>
{% endblock %}
+5 -29
View File
@@ -1,26 +1,11 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
head_scripts %}
<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>
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
<section class="hero">
<div>
<h1 class="title">Arbitrade Dashboard</h1>
<p class="subtitle">Live execution, P&amp;L, and system state.</p>
</div>
<div class="toolbar">
<a
class="button"
href="{{ metrics_endpoint }}"
hx-get="{{ metrics_endpoint }}"
hx-target="#metrics-panel"
hx-swap="outerHTML"
>Refresh metrics</a
>
<a class="button secondary" href="/health">Health</a>
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
</div>
</section>
{% endblock %} {% block header %} {% with page_title="Arbitrade Dashboard",
page_subtitle="Live execution, P&L, and system state." %} {% include
"_header.html" %} {% endwith %} {% endblock %} {% block main_class %}shell{%
endblock %} {% block content %}
<section
id="metrics-shell"
@@ -62,15 +47,6 @@ head_scripts %}
{% include "partials/charts.html" %}
</section>
<section
id="audit-shell"
hx-get="{{ audit_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %} {% block scripts %}
<script>
window.arbitradeRenderCharts = (payload) => {
@@ -1,106 +0,0 @@
{% extends "base.html" %} {% block title %}Fee Configuration{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Fee Configuration</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Pairing Fees</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/fees/save">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Pairing</th>
<th>Crypto/Crypto Maker</th>
<th>Crypto/Crypto Taker</th>
<th>Crypto/Fiat Maker</th>
<th>Crypto/Fiat Taker</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>
{{ pairing.base_asset }}/{{ pairing.quote_asset }}
<input
type="hidden"
name="pairing_id_{{ pairing.id }}"
value="{{ pairing.id }}"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}"
placeholder="0.0010"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}"
placeholder="0.0020"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}_fiat"
placeholder="0.0010"
readonly
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}_fiat"
placeholder="0.0020"
readonly
/>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-primary">Save Fees</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+12 -5
View File
@@ -1,14 +1,21 @@
{% extends "base.html" %}
{% block content %}
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
{% block header %} {% with page_title="Arbitrade Health Check",
page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %}
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
<section class="card">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a href="/health" hx-get="/health" hx-target="#health-json" hx-swap="innerHTML">refresh</a>
<a
href="/health"
hx-get="/health"
hx-target="#health-json"
hx-swap="innerHTML"
>refresh</a
>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
{% endblock %}
{% endblock %} {% block scripts %}{% endblock %}
@@ -42,13 +42,32 @@
hx-target="#backtesting-shell"
hx-swap="outerHTML"
>
<input type="hidden" name="source" value="db" />
<label class="field">
<span>Replay events path (JSONL)</span>
<span>Symbols (comma-separated, blank=all)</span>
<input
name="events_path"
name="symbols"
type="text"
value="{{ events_path }}"
placeholder="data/replay.jsonl"
value="{{ symbols | default('') }}"
placeholder="BTC/USD,ETH/BTC"
/>
</label>
<label class="field">
<span>Start time (ISO datetime, optional)</span>
<input
name="start_time"
type="text"
value="{{ start_time | default('') }}"
placeholder="2025-01-01T00:00:00"
/>
</label>
<label class="field">
<span>End time (ISO datetime, optional)</span>
<input
name="end_time"
type="text"
value="{{ end_time | default('') }}"
placeholder="2025-01-02T00:00:00"
/>
</label>
<label class="field">
@@ -83,6 +102,8 @@
<label class="field">
<span>Fee profile</span>
<select name="fee_profile">
{% set sel = "selected" if fee_profile == "api" else "" %}
<option value="api" {{ sel }}>api (from Kraken)</option>
{% set sel = "selected" if fee_profile == "standard" else "" %}
<option value="standard" {{ sel }}>standard</option>
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
@@ -123,20 +144,78 @@
value="{{ execution_latency_ms }}"
/>
</label>
<button type="submit" class="button">Run backtest</button>
<button type="submit" class="button">Submit Job</button>
</form>
</article>
<article class="card" style="margin-top: 16px">
<div class="label">Recent Runs</div>
{% if recent_reports %} {% for item in recent_reports %}
<div class="meta">
{{ item.run_at }} | {{ item.events_path }} | trades={{
item.report.trades_executed }} | pnl={{
'%.4f'|format(item.report.realized_pnl_usd) }} USD
<div class="label">Recent Jobs</div>
{% if recent_reports %}
<div style="overflow-x: auto">
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem">
<thead>
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.14)">
<th style="text-align: left; padding: 8px; color: #9fb2d0">Job</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Status
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Events
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Trades
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">P&L</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Created
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0"></th>
</tr>
</thead>
<tbody>
{% for item in recent_reports %}
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.06)">
<td style="padding: 8px">
<button
class="button secondary"
style="padding: 2px 8px; font-size: 0.8rem"
hx-get="/dashboard/backtesting/job/{{ item.job_id }}"
hx-target="#job-detail-{{ loop.index }}"
hx-swap="innerHTML"
>
{{ item.job_id[:8] }}...
</button>
</td>
<td style="padding: 8px">{{ item.status }}</td>
<td style="padding: 8px; color: #7f95b7">{{ item.events_path }}</td>
<td style="padding: 8px">
{{ item.report.trades_executed if item.report else "—" }}
</td>
<td style="padding: 8px">
{{ '%.2f'|format(item.report.realized_pnl_usd) if item.report and
item.report.realized_pnl_usd else "—" }}
</td>
<td style="padding: 8px; color: #7f95b7">{{ item.run_at[:19] }}</td>
<td style="padding: 8px">
<button
class="button danger"
style="padding: 2px 8px; font-size: 0.8rem"
hx-post="/dashboard/backtesting/job/{{ item.job_id }}/delete"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
onclick="return confirm('Delete job {{ item.job_id[:8] }}...?');"
>
Del
</button>
</td>
</tr>
<tr id="job-detail-{{ loop.index }}"></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %} {% else %}
<div class="meta">No recent reports yet.</div>
{% else %}
<div class="meta">No jobs submitted yet.</div>
{% endif %}
</article>
</div>
@@ -0,0 +1,614 @@
<div id="config-panel" class="panel" style="margin-top: 16px">
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#config-panel"
hx-swap="outerHTML"
style="
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
"
>
<!-- Runtime -->
<div class="card">
<div class="label">Runtime</div>
<label class="field">
<span>App env</span>
<input type="text" value="{{ app_env }}" disabled />
</label>
<label class="field">
<span>App host</span>
<input name="app_host" type="text" value="{{ app_host }}" />
</label>
<label class="field">
<span>App port</span>
<input
name="app_port"
type="number"
min="1"
max="65535"
value="{{ app_port }}"
/>
</label>
<label class="field">
<span>Log level</span>
<select name="log_level">
{% for lvl in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] %} {%
set sel = "selected" if log_level == lvl else "" %}
<option value="{{ lvl }}" {{ sel }}>{{ lvl }}</option>
{% endfor %}
</select>
</label>
<label class="field checkbox">
<input name="log_json" type="checkbox" {{ log_json }} />
<span>JSON logs</span>
</label>
<label class="field checkbox">
<input
name="paper_trading_mode"
type="checkbox"
{{
paper_trading_mode
}}
/>
<span>Paper trading mode</span>
</label>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Max exposure per asset USD</span>
<input
name="max_exposure_per_asset_usd"
type="number"
min="0"
step="0.01"
value="{{ max_exposure_per_asset_value }}"
/>
</label>
<label class="field">
<span>Quote balance asset</span>
<input
name="quote_balance_asset"
type="text"
value="{{ quote_balance_asset }}"
/>
</label>
<label class="field">
<span>Min order size USD</span>
<input
name="min_order_size_usd"
type="number"
min="0"
step="0.01"
value="{{ min_order_size_usd_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs (comma-separated)</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
</div>
<!-- Alerts -->
<div class="card">
<div class="label">Alerting</div>
<label class="field checkbox">
<input name="alerts_enabled" type="checkbox" {{ alerts_enabled }} />
<span>Alerts enabled</span>
</label>
<label class="field">
<span>Min severity</span>
<select name="alert_min_severity">
{% for sev in ["info", "warning", "error", "critical"] %} {% set sel =
"selected" if alert_min_severity == sev else "" %}
<option value="{{ sev }}" {{ sel }}>{{ sev }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Dedup seconds</span>
<input
name="alert_dedup_seconds"
type="number"
min="0"
step="1"
value="{{ alert_dedup_seconds }}"
/>
</label>
<label class="field checkbox">
<input
name="alert_on_trade_events"
type="checkbox"
{{
alert_on_trade_events
}}
/>
<span>Trade events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_error_events"
type="checkbox"
{{
alert_on_error_events
}}
/>
<span>Error events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_threshold_events"
type="checkbox"
{{
alert_on_threshold_events
}}
/>
<span>Threshold events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_system_events"
type="checkbox"
{{
alert_on_system_events
}}
/>
<span>System events</span>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="telegram_alerts_enabled"
type="checkbox"
{{
telegram_alerts_enabled
}}
/>
<span>Telegram</span>
</label>
<label class="field">
<span>Telegram bot token</span>
<input
name="telegram_bot_token"
type="password"
value="{{ telegram_bot_token }}"
placeholder="Bot token"
/>
</label>
<label class="field">
<span>Telegram chat ID</span>
<input
name="telegram_chat_id"
type="text"
value="{{ telegram_chat_id }}"
placeholder="Chat ID"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="discord_alerts_enabled"
type="checkbox"
{{
discord_alerts_enabled
}}
/>
<span>Discord</span>
</label>
<label class="field">
<span>Discord webhook URL</span>
<input
name="discord_webhook_url"
type="password"
value="{{ discord_webhook_url }}"
placeholder="Webhook URL"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="email_alerts_enabled"
type="checkbox"
{{
email_alerts_enabled
}}
/>
<span>Email</span>
</label>
<label class="field">
<span>SMTP host</span>
<input
name="email_smtp_host"
type="text"
value="{{ email_smtp_host }}"
placeholder="smtp.example.com"
/>
</label>
<label class="field">
<span>SMTP port</span>
<input
name="email_smtp_port"
type="number"
min="1"
max="65535"
value="{{ email_smtp_port }}"
/>
</label>
<label class="field">
<span>SMTP username</span>
<input
name="email_smtp_username"
type="text"
value="{{ email_smtp_username }}"
/>
</label>
<label class="field">
<span>SMTP password</span>
<input
name="email_smtp_password"
type="password"
value="{{ email_smtp_password }}"
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>From address</span>
<input
name="email_alert_from"
type="text"
value="{{ email_alert_from }}"
/>
</label>
<label class="field">
<span>To address</span>
<input name="email_alert_to" type="text" value="{{ email_alert_to }}" />
</label>
<label class="field checkbox">
<input
name="email_smtp_use_tls"
type="checkbox"
{{
email_smtp_use_tls
}}
/>
<span>Use TLS</span>
</label>
</div>
<!-- Kraken -->
<div class="card">
<div class="label">Kraken Exchange</div>
<label class="field">
<span>REST URL</span>
<input
name="kraken_rest_url"
type="text"
value="{{ kraken_rest_url }}"
/>
</label>
<label class="field">
<span>WebSocket URL</span>
<input name="kraken_ws_url" type="text" value="{{ kraken_ws_url }}" />
</label>
<label class="field">
<span>Private rate limit (s)</span>
<input
name="kraken_private_rate_limit_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_private_rate_limit_seconds }}"
/>
</label>
<label class="field">
<span>HTTP timeout (s)</span>
<input
name="kraken_http_timeout_seconds"
type="number"
min="1"
step="0.5"
value="{{ kraken_http_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Retry attempts</span>
<input
name="kraken_retry_attempts"
type="number"
min="0"
step="1"
value="{{ kraken_retry_attempts }}"
/>
</label>
<label class="field">
<span>Retry base delay (s)</span>
<input
name="kraken_retry_base_delay_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_retry_base_delay_seconds }}"
/>
</label>
<label class="field">
<span>API key</span>
<input
name="kraken_api_key"
type="text"
value="{{ kraken_api_key }}"
placeholder="API key"
/>
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value="{{ kraken_api_secret }}"
placeholder="API secret"
/>
</label>
<label class="field">
<span>Key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
placeholder="query,trade"
/>
</label>
<label class="field">
<span>Heartbeat timeout (s)</span>
<input
name="ws_heartbeat_timeout_seconds"
type="number"
min="1"
step="1"
value="{{ ws_heartbeat_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="0"
step="0.5"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
<!-- Risk -->
<div class="card">
<div class="label">Risk Limits</div>
<label class="field">
<span>Daily loss limit USD</span>
<input
name="daily_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ daily_loss_limit_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Cumulative loss limit USD</span>
<input
name="cumulative_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ cumulative_loss_limit_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max source latency (ms)</span>
<input
name="max_source_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_source_latency_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max apply latency (ms)</span>
<input
name="max_apply_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_apply_latency_value }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="1"
step="1"
value="{{ max_consecutive_failures_value }}"
placeholder="None"
/>
</label>
<label class="field checkbox">
<input
name="kill_switch_active"
type="checkbox"
{{
kill_switch_active
}}
/>
<span>Kill switch active</span>
</label>
</div>
<!-- Strategy Stat-Arb -->
<div class="card">
<div class="label">Stat-Arb Strategy</div>
<label class="field checkbox">
<input
name="strategy_enable_stat_arb_experiment"
type="checkbox"
{%
if
strategy_stat_arb_enabled
%}checked{%
endif
%}
/>
<span>Enable stat-arb experiment</span>
</label>
{% if strategy_stat_arb_enabled %}
<label class="field">
<span>Lookback window</span>
<input
name="strategy_stat_arb_lookback_window"
type="number"
min="2"
step="1"
value="{{ strategy_stat_arb_lookback_window }}"
/>
</label>
<label class="field">
<span>Entry z-score</span>
<input
name="strategy_stat_arb_entry_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_entry_zscore }}"
/>
</label>
<label class="field">
<span>Exit z-score</span>
<input
name="strategy_stat_arb_exit_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_exit_zscore }}"
/>
</label>
<label class="field">
<span>Max holding seconds</span>
<input
name="strategy_stat_arb_max_holding_seconds"
type="number"
min="1"
step="1"
value="{{ strategy_stat_arb_max_holding_seconds }}"
/>
</label>
{% endif %}
</div>
<!-- Submit -->
<div
class="card"
style="display: flex; align-items: center; justify-content: center"
>
<button
type="submit"
class="button"
style="padding: 14px 32px; font-size: 1.1rem"
>
Save configuration
</button>
</div>
</form>
</div>
@@ -75,97 +75,5 @@
</form>
</div>
</article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div>
</div>
@@ -6,7 +6,7 @@
</article>
<article class="card">
<div class="label">Balances</div>
<div class="value">{{ balances }}</div>
<div class="value">{{ balances | safe }}</div>
</article>
<article class="card">
<div class="label">Open Trades</div>
@@ -16,6 +16,13 @@
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl_total }}</div>
</article>
<article class="card">
<div class="label">Fee Tier</div>
<div class="value">{{ fee_tier }}</div>
<div class="meta">
Maker {{ maker_fee }} / Taker {{ taker_fee }} &middot; {{ fee_source }}
</div>
</article>
</div>
<div
@@ -44,9 +51,10 @@
class="value"
style="font-size: 1rem; font-weight: 500; word-break: break-word"
>
{{ balances }}
{{ balances | safe }}
</div>
<div class="meta">Total value {{ total_value }}</div>
<div class="meta">Equity {{ equity }}</div>
</article>
<article class="card">
<div class="label">Opportunity Feed</div>
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
content %}
<section class="hero">
<div>
<h1 class="title">Backtesting</h1>
<p class="subtitle">
Replay controls, run status, and recent summary reports.
</p>
</div>
<div class="toolbar">
<a class="button secondary" href="{{ dashboard_endpoint }}">Dashboard</a>
</div>
</section>
<section
id="backtesting-shell"
hx-get="{{ panel_endpoint }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/backtesting_panel.html" %}
</section>
{% endblock %}
-148
View File
@@ -1,148 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title or "Arbitrade" }}{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
{% block head_scripts %}{% endblock %}
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #0b1220;
color: #e5eefb;
}
.shell {
max-width: 1120px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.title {
font-size: 2rem;
margin: 0 0 8px;
}
.subtitle {
margin: 0;
color: #9fb2d0;
}
.panel {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 20px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
background: rgba(255, 255, 255, 0.04);
border-radius: 14px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.label {
color: #9fb2d0;
font-size: 0.85rem;
margin-bottom: 8px;
}
.value {
font-size: 1.4rem;
font-weight: 700;
}
.meta {
margin-top: 18px;
color: #7f95b7;
font-size: 0.85rem;
}
.toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.toolbar form {
margin: 0;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
cursor: pointer;
padding: 10px 14px;
border-radius: 999px;
background: #2d6cdf;
color: white;
text-decoration: none;
font: inherit;
}
.button.secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.button.danger {
background: #ba3d4f;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
color: #9fb2d0;
font-size: 0.9rem;
}
.field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: #e5eefb;
font: inherit;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.field.checkbox input {
width: auto;
}
.control-actions {
display: flex;
gap: 12px;
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>
{% block extra_style %}{% endblock %}
</head>
<body>
<main class="{% block main_class %}shell{% endblock %}">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
-180
View File
@@ -1,180 +0,0 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
head_scripts %}
<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>
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
<section class="hero">
<div>
<h1 class="title">Arbitrade Dashboard</h1>
<p class="subtitle">Live execution, P&amp;L, and system state.</p>
</div>
<div class="toolbar">
<a
class="button"
href="{{ metrics_endpoint }}"
hx-get="{{ metrics_endpoint }}"
hx-target="#metrics-panel"
hx-swap="outerHTML"
>Refresh metrics</a
>
<a class="button secondary" href="/health">Health</a>
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
</div>
</section>
<section
id="metrics-shell"
hx-get="{{ metrics_endpoint }}"
hx-target="this"
hx-trigger="load, every 15s"
hx-swap="outerHTML"
>
{% include "partials/metrics.html" %}
</section>
<section
id="overview-shell"
hx-get="{{ overview_endpoint }}"
hx-target="this"
hx-trigger="load, every 10s"
hx-swap="outerHTML"
>
{% include "partials/overview.html" %}
</section>
<section
id="controls-shell"
hx-get="{{ controls_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/controls.html" %}
</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>
<section
id="audit-shell"
hx-get="{{ audit_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %} {% block scripts %}
<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 }}");
stream.addEventListener("metrics", (event) => {
const panel = document.getElementById("metrics-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
const overviewStream = new EventSource("{{ overview_stream_endpoint }}");
overviewStream.addEventListener("overview", (event) => {
const panel = document.getElementById("overview-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
</script>
{% endblock %}
-14
View File
@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a href="/health" hx-get="/health" hx-target="#health-json" hx-swap="innerHTML">refresh</a>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
{% endblock %}
-37
View File
@@ -1,37 +0,0 @@
<div id="audit-panel" class="panel" style="margin-top: 16px">
<div class="label">Audit Trail</div>
<div class="meta">Generated {{ generated_at }}</div>
<div style="overflow-x: auto; margin-top: 12px">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem">
<thead>
<tr>
<th style="text-align: left; padding: 8px">Time</th>
<th style="text-align: left; padding: 8px">Actor</th>
<th style="text-align: left; padding: 8px">Event</th>
<th style="text-align: left; padding: 8px">Decision</th>
<th style="text-align: left; padding: 8px">Payload</th>
<th style="text-align: left; padding: 8px">Correlation</th>
</tr>
</thead>
<tbody>
{% if entries %}
{% for entry in entries %}
<tr>
<td style="padding: 8px; color: #9fb2d0">{{ entry.occurred_at }}</td>
<td style="padding: 8px">{{ entry.actor }}</td>
<td style="padding: 8px">{{ entry.event_type }}</td>
<td style="padding: 8px">{{ entry.decision }}</td>
<td style="padding: 8px; color: #9fb2d0">{{ entry.payload }}</td>
<td style="padding: 8px; color: #9fb2d0">{{ entry.correlation_id }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" style="padding: 8px; color: #9fb2d0">No audit entries yet.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
@@ -1,142 +0,0 @@
<div id="backtesting-shell" class="panel">
<div
class="grid"
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"
>
<article class="card">
<div class="label">Run Status</div>
<div class="value">{{ status }}</div>
<div class="meta">{{ message }}</div>
</article>
<article class="card">
<div class="label">Latest Report</div>
{% if latest_report %}
<div class="meta">Run at {{ latest_report.run_at }}</div>
<div class="meta">Events: {{ latest_report.events_path }}</div>
<div class="meta">
Processed: {{ latest_report.report.processed_events }}
</div>
<div class="meta">
Opportunities: {{ latest_report.report.opportunities_seen }}
</div>
<div class="meta">Trades: {{ latest_report.report.trades_executed }}</div>
<div class="meta">
Realized P&amp;L: {{
'%.4f'|format(latest_report.report.realized_pnl_usd) }} USD
</div>
<div class="meta">
Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }}
USD
</div>
{% else %}
<div class="meta">No runs yet.</div>
{% endif %}
</article>
</div>
<article class="card" style="margin-top: 16px">
<div class="label">Run Backtest</div>
<form
class="form-grid"
hx-post="{{ run_endpoint }}"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
>
<label class="field">
<span>Replay events path (JSONL)</span>
<input
name="events_path"
type="text"
value="{{ events_path }}"
placeholder="data/replay.jsonl"
/>
</label>
<label class="field">
<span>Starting balances</span>
<input
name="starting_balances"
type="text"
value="{{ starting_balances }}"
placeholder="USD=1000.0,BTC=0.0"
/>
</label>
<label class="field">
<span>Trade capital</span>
<input
name="trade_capital"
type="number"
min="0"
step="0.01"
value="{{ trade_capital }}"
/>
</label>
<label class="field">
<span>Min profit threshold</span>
<input
name="min_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ min_profit_threshold }}"
/>
</label>
<label class="field">
<span>Fee profile</span>
<select name="fee_profile">
{% set sel = "selected" if fee_profile == "standard" else "" %}
<option value="standard" {{ sel }}>standard</option>
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
<option value="maker_heavy" {{ sel }}>maker_heavy</option>
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
<option value="taker_heavy" {{ sel }}>taker_heavy</option>
{% set sel = "selected" if fee_profile == "custom" else "" %}
<option value="custom" {{ sel }}>custom</option>
</select>
</label>
<label class="field">
<span>Custom fee rate (if fee profile = custom)</span>
<input
name="custom_fee_rate"
type="number"
min="0"
step="0.0001"
value="{{ custom_fee_rate }}"
/>
</label>
<label class="field">
<span>Slippage (bps)</span>
<input
name="slippage_bps"
type="number"
min="0"
step="0.1"
value="{{ slippage_bps }}"
/>
</label>
<label class="field">
<span>Execution latency (ms)</span>
<input
name="execution_latency_ms"
type="number"
min="0"
step="0.1"
value="{{ execution_latency_ms }}"
/>
</label>
<button type="submit" class="button">Run backtest</button>
</form>
</article>
<article class="card" style="margin-top: 16px">
<div class="label">Recent Runs</div>
{% if recent_reports %} {% for item in recent_reports %}
<div class="meta">
{{ item.run_at }} | {{ item.events_path }} | trades={{
item.report.trades_executed }} | pnl={{
'%.4f'|format(item.report.realized_pnl_usd) }} USD
</div>
{% endfor %} {% else %}
<div class="meta">No recent reports yet.</div>
{% endif %}
</article>
</div>
-37
View File
@@ -1,37 +0,0 @@
<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>
-171
View File
@@ -1,171 +0,0 @@
<div id="controls-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Runtime Status</div>
<div class="value">{{ execution_status }}</div>
<div class="meta">Updated {{ updated_at }}</div>
</article>
<article class="card">
<div class="label">Kill Switch</div>
<div class="value">{{ kill_switch_status }}</div>
<div class="meta">Reason {{ kill_switch_reason }}</div>
</article>
<article class="card">
<div class="label">Config Snapshot</div>
<div class="meta">Paper trading: {{ paper_trading_mode }}</div>
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
<div class="meta">Tradable pairs: {{ tradable_pairs_display }}</div>
<div class="meta">Strategy mode: {{ strategy_mode }}</div>
<div class="meta">Profit threshold: {{ strategy_profit_threshold }}</div>
<div class="meta">Max depth levels: {{ strategy_max_depth_levels }}</div>
</article>
<article class="card">
<div class="label">Alerting</div>
<div class="meta">Status: {{ alerts_enabled }}</div>
<div class="meta">Channels: {{ alerts_channels }}</div>
<div class="meta">Min severity: {{ alerts_min_severity }}</div>
<div class="meta">Dedup window: {{ alerts_dedup_seconds }}s</div>
<div class="meta">Last result: {{ alerts_last_result }}</div>
<div class="meta">Last attempted: {{ alerts_last_attempted_at }}</div>
<div class="meta">Last success: {{ alerts_last_success_at }}</div>
<div class="meta">Last event: {{ alerts_last_event_title }}</div>
<div class="meta">Last error: {{ alerts_last_error }}</div>
{% if alerts_last_channel_results %} {% for item in
alerts_last_channel_results %}
<div class="meta">{{ item }}</div>
{% endfor %} {% endif %}
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Execution Controls</div>
<div class="control-actions">
<form
hx-post="{{ start_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button">Start</button>
</form>
<form
hx-post="{{ stop_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button secondary">Stop</button>
</form>
<form
hx-post="{{ kill_switch_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="reason" value="manual" />
<button type="submit" class="button danger">
Trigger Kill Switch
</button>
</form>
</div>
</article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div>
</div>
-31
View File
@@ -1,31 +0,0 @@
<div id="metrics-panel" class="panel">
<div class="grid">
<article class="card">
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl }}</div>
</article>
<article class="card">
<div class="label">Win Rate</div>
<div class="value">{{ win_rate }}</div>
</article>
<article class="card">
<div class="label">Avg Trade Duration</div>
<div class="value">{{ avg_trade_duration }}</div>
</article>
<article class="card">
<div class="label">Opportunities / Min</div>
<div class="value">{{ opportunities_per_minute }}</div>
</article>
<article class="card">
<div class="label">Fill Rate</div>
<div class="value">{{ fill_rate }}</div>
</article>
<article class="card">
<div class="label">Latency p50 / p95 / p99</div>
<div class="value">
{{ latency_p50 }} | {{ latency_p95 }} | {{ latency_p99 }}
</div>
</article>
</div>
<div class="meta">Updated {{ generated_at }}</div>
</div>

Some files were not shown because too many files have changed in this diff Show More