From 1df4b11aefbf207fc591d14ef2c977148c84125c Mon Sep 17 00:00:00 2001 From: zwitschi Date: Tue, 2 Jun 2026 14:16:42 +0200 Subject: [PATCH] Add HTML templates for dashboard, metrics, overview, and backtesting - Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities. - Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity. - Updated the Jinja2 template resolution logic to support different deployment environments. - Added a health check template to display the service status. - Included a test suite to verify the template resolution logic. - Updated `pyproject.toml` to include new HTML templates in the package data. --- .gitea/workflows/ci.yml | 15 + DEPLOYMENT.md | 152 +++ README.md | 1 + build/lib/arbitrade/__init__.py | 3 + build/lib/arbitrade/alerting/__init__.py | 25 + build/lib/arbitrade/alerting/notifier.py | 400 ++++++++ build/lib/arbitrade/api/__init__.py | 0 build/lib/arbitrade/api/app.py | 44 + build/lib/arbitrade/api/auth.py | 38 + build/lib/arbitrade/api/control_state.py | 20 + build/lib/arbitrade/api/routes.py | 944 ++++++++++++++++++ build/lib/arbitrade/backtesting/__init__.py | 35 + build/lib/arbitrade/backtesting/replay.py | 326 ++++++ build/lib/arbitrade/backtesting/sweep.py | 396 ++++++++ build/lib/arbitrade/config/__init__.py | 0 build/lib/arbitrade/config/secrets.py | 39 + build/lib/arbitrade/config/settings.py | 219 ++++ build/lib/arbitrade/detection/__init__.py | 12 + build/lib/arbitrade/detection/benchmark.py | 113 +++ build/lib/arbitrade/detection/engine.py | 295 ++++++ build/lib/arbitrade/detection/graph.py | 90 ++ build/lib/arbitrade/exchange/__init__.py | 1 + build/lib/arbitrade/exchange/kraken_rest.py | 281 ++++++ build/lib/arbitrade/exchange/kraken_ws.py | 177 ++++ build/lib/arbitrade/exchange/models.py | 37 + build/lib/arbitrade/exchange/signing.py | 14 + build/lib/arbitrade/execution/__init__.py | 32 + build/lib/arbitrade/execution/fill_monitor.py | 133 +++ build/lib/arbitrade/execution/idempotency.py | 105 ++ build/lib/arbitrade/execution/recovery.py | 98 ++ build/lib/arbitrade/execution/sequencer.py | 288 ++++++ build/lib/arbitrade/logging_setup.py | 39 + build/lib/arbitrade/main.py | 41 + build/lib/arbitrade/market_data/__init__.py | 1 + build/lib/arbitrade/market_data/feed.py | 485 +++++++++ build/lib/arbitrade/market_data/order_book.py | 104 ++ build/lib/arbitrade/metrics.py | 100 ++ build/lib/arbitrade/perf/__init__.py | 4 + build/lib/arbitrade/perf/guardrails.py | 80 ++ build/lib/arbitrade/perf/latency.py | 195 ++++ build/lib/arbitrade/risk/__init__.py | 15 + build/lib/arbitrade/risk/kill_switch.py | 23 + build/lib/arbitrade/risk/loss_limits.py | 90 ++ build/lib/arbitrade/risk/pre_trade.py | 43 + build/lib/arbitrade/risk/stop_conditions.py | 109 ++ build/lib/arbitrade/risk/trade_limits.py | 98 ++ build/lib/arbitrade/runtime/__init__.py | 15 + build/lib/arbitrade/runtime/lifecycle.py | 223 +++++ build/lib/arbitrade/storage/__init__.py | 1 + build/lib/arbitrade/storage/db.py | 128 +++ build/lib/arbitrade/storage/executions.py | 66 ++ .../lib/arbitrade/storage/market_snapshots.py | 64 ++ build/lib/arbitrade/storage/opportunities.py | 58 ++ build/lib/arbitrade/storage/repositories.py | 378 +++++++ build/lib/arbitrade/strategy/__init__.py | 5 + build/lib/arbitrade/strategy/stat_arb.py | 152 +++ .../arbitrade/web/templates/backtesting.html | 24 + build/lib/arbitrade/web/templates/base.html | 148 +++ .../arbitrade/web/templates/dashboard.html | 180 ++++ build/lib/arbitrade/web/templates/health.html | 14 + .../web/templates/partials/audit.html | 37 + .../templates/partials/backtesting_panel.html | 142 +++ .../web/templates/partials/charts.html | 37 + .../web/templates/partials/controls.html | 171 ++++ .../web/templates/partials/metrics.html | 31 + .../web/templates/partials/overview.html | 67 ++ dist/arbitrade-0.1.0-py3-none-any.whl | Bin 0 -> 80562 bytes pyproject.toml | 7 + src/arbitrade/api/routes.py | 29 +- src/arbitrade/web/templates/backtesting.html | 24 + src/arbitrade/web/templates/base.html | 148 +++ src/arbitrade/web/templates/dashboard.html | 180 ++++ src/arbitrade/web/templates/health.html | 14 + .../web/templates/partials/audit.html | 37 + .../templates/partials/backtesting_panel.html | 142 +++ .../web/templates/partials/charts.html | 37 + .../web/templates/partials/controls.html | 171 ++++ .../web/templates/partials/metrics.html | 31 + .../web/templates/partials/overview.html | 67 ++ tests/unit/test_template_resolution.py | 19 + 80 files changed, 8604 insertions(+), 3 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 build/lib/arbitrade/__init__.py create mode 100644 build/lib/arbitrade/alerting/__init__.py create mode 100644 build/lib/arbitrade/alerting/notifier.py create mode 100644 build/lib/arbitrade/api/__init__.py create mode 100644 build/lib/arbitrade/api/app.py create mode 100644 build/lib/arbitrade/api/auth.py create mode 100644 build/lib/arbitrade/api/control_state.py create mode 100644 build/lib/arbitrade/api/routes.py create mode 100644 build/lib/arbitrade/backtesting/__init__.py create mode 100644 build/lib/arbitrade/backtesting/replay.py create mode 100644 build/lib/arbitrade/backtesting/sweep.py create mode 100644 build/lib/arbitrade/config/__init__.py create mode 100644 build/lib/arbitrade/config/secrets.py create mode 100644 build/lib/arbitrade/config/settings.py create mode 100644 build/lib/arbitrade/detection/__init__.py create mode 100644 build/lib/arbitrade/detection/benchmark.py create mode 100644 build/lib/arbitrade/detection/engine.py create mode 100644 build/lib/arbitrade/detection/graph.py create mode 100644 build/lib/arbitrade/exchange/__init__.py create mode 100644 build/lib/arbitrade/exchange/kraken_rest.py create mode 100644 build/lib/arbitrade/exchange/kraken_ws.py create mode 100644 build/lib/arbitrade/exchange/models.py create mode 100644 build/lib/arbitrade/exchange/signing.py create mode 100644 build/lib/arbitrade/execution/__init__.py create mode 100644 build/lib/arbitrade/execution/fill_monitor.py create mode 100644 build/lib/arbitrade/execution/idempotency.py create mode 100644 build/lib/arbitrade/execution/recovery.py create mode 100644 build/lib/arbitrade/execution/sequencer.py create mode 100644 build/lib/arbitrade/logging_setup.py create mode 100644 build/lib/arbitrade/main.py create mode 100644 build/lib/arbitrade/market_data/__init__.py create mode 100644 build/lib/arbitrade/market_data/feed.py create mode 100644 build/lib/arbitrade/market_data/order_book.py create mode 100644 build/lib/arbitrade/metrics.py create mode 100644 build/lib/arbitrade/perf/__init__.py create mode 100644 build/lib/arbitrade/perf/guardrails.py create mode 100644 build/lib/arbitrade/perf/latency.py create mode 100644 build/lib/arbitrade/risk/__init__.py create mode 100644 build/lib/arbitrade/risk/kill_switch.py create mode 100644 build/lib/arbitrade/risk/loss_limits.py create mode 100644 build/lib/arbitrade/risk/pre_trade.py create mode 100644 build/lib/arbitrade/risk/stop_conditions.py create mode 100644 build/lib/arbitrade/risk/trade_limits.py create mode 100644 build/lib/arbitrade/runtime/__init__.py create mode 100644 build/lib/arbitrade/runtime/lifecycle.py create mode 100644 build/lib/arbitrade/storage/__init__.py create mode 100644 build/lib/arbitrade/storage/db.py create mode 100644 build/lib/arbitrade/storage/executions.py create mode 100644 build/lib/arbitrade/storage/market_snapshots.py create mode 100644 build/lib/arbitrade/storage/opportunities.py create mode 100644 build/lib/arbitrade/storage/repositories.py create mode 100644 build/lib/arbitrade/strategy/__init__.py create mode 100644 build/lib/arbitrade/strategy/stat_arb.py create mode 100644 build/lib/arbitrade/web/templates/backtesting.html create mode 100644 build/lib/arbitrade/web/templates/base.html create mode 100644 build/lib/arbitrade/web/templates/dashboard.html create mode 100644 build/lib/arbitrade/web/templates/health.html create mode 100644 build/lib/arbitrade/web/templates/partials/audit.html create mode 100644 build/lib/arbitrade/web/templates/partials/backtesting_panel.html create mode 100644 build/lib/arbitrade/web/templates/partials/charts.html create mode 100644 build/lib/arbitrade/web/templates/partials/controls.html create mode 100644 build/lib/arbitrade/web/templates/partials/metrics.html create mode 100644 build/lib/arbitrade/web/templates/partials/overview.html create mode 100644 dist/arbitrade-0.1.0-py3-none-any.whl create mode 100644 src/arbitrade/web/templates/backtesting.html create mode 100644 src/arbitrade/web/templates/base.html create mode 100644 src/arbitrade/web/templates/dashboard.html create mode 100644 src/arbitrade/web/templates/health.html create mode 100644 src/arbitrade/web/templates/partials/audit.html create mode 100644 src/arbitrade/web/templates/partials/backtesting_panel.html create mode 100644 src/arbitrade/web/templates/partials/charts.html create mode 100644 src/arbitrade/web/templates/partials/controls.html create mode 100644 src/arbitrade/web/templates/partials/metrics.html create mode 100644 src/arbitrade/web/templates/partials/overview.html create mode 100644 tests/unit/test_template_resolution.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 954e043..95c313f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,21 @@ jobs: - name: Tests run: pytest -q + - name: Package template smoke check + run: | + pip install build + python -m build --wheel + pip uninstall -y arbitrade + pip install --force-reinstall dist/*.whl + python - <<'PY' + from arbitrade.api import routes + + template = routes.templates.env.get_template("dashboard.html") + rendered = template.render() + if "" not in rendered: + raise SystemExit("dashboard template render smoke check failed") + PY + - name: Latency guardrails run: | python scripts/check_latency_regression.py \ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..480ed0c --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,152 @@ +# Deployment Guide (Coolify) + +This guide provides two supported deployment paths for Arbitrade on Coolify: + +- Build directly from Git repository in Coolify. +- Deploy prebuilt container image: `git.allucanget.biz/allucanget/arbitrade:latest`. + +Reference docs: + +- Coolify Applications: https://coolify.io/docs/applications +- Coolify Build Packs: https://coolify.io/docs/applications/build-packs +- Coolify Dockerfile Build Pack: https://coolify.io/docs/applications/build-packs/dockerfile +- Coolify Nixpacks Build Pack: https://coolify.io/docs/applications/build-packs/nixpacks +- Coolify CI/CD (Git providers): https://coolify.io/docs/applications/ci-cd +- Coolify Gitea integration: https://coolify.io/docs/applications/ci-cd/gitea/integration +- Coolify environment variables: https://coolify.io/docs/knowledge-base/environment-variables +- Coolify persistent storage: https://coolify.io/docs/knowledge-base/persistent-storage +- Coolify health checks: https://coolify.io/docs/knowledge-base/health-checks +- Coolify Docker registry credentials: https://coolify.io/docs/knowledge-base/docker/registry + +## Common Runtime Configuration + +Use these values in both deployment modes. + +### Port and health + +- Container port: `9090` +- Health check path: `/health` +- Protocol: HTTP + +### Persistent storage + +- Add a persistent volume +- Mount path: `/app/data` +- Set DB path to: `DUCKDB_PATH=/app/data/arbitrade.duckdb` + +### Required environment variables + +- `APP_ENV=prod` +- `APP_HOST=0.0.0.0` +- `APP_PORT=9090` +- `DUCKDB_PATH=/app/data/arbitrade.duckdb` +- `LOG_LEVEL=INFO` +- `LOG_JSON=true` +- `KRAKEN_API_KEY=` +- `KRAKEN_API_SECRET=` +- `KRAKEN_API_KEY_PERMISSIONS=query,trade` +- `FERNET_KEY=` + +Notes: + +- Store secrets in Coolify secret variables, not in Git. +- Keep Kraken key scope minimal (query + trade, no withdrawal). + +## Option A: Build in Coolify from Git Repository + +Recommended when you want Coolify to build from source and optionally auto-deploy on commits. + +1. Open your Coolify project and select Create New Resource. +2. Choose deployment source: + +- Public repo: use `Public repository` and provide HTTPS URL. +- Private Gitea repo: use deploy key flow from the Gitea guide. + +3. Set repository URL for this project: + +- `https://git.allucanget.biz/allucanget/arbitrade.git` (public) +- or SSH URL if private deploy key is used. + +4. Choose build pack: + +- Prefer `Dockerfile` for this repo, because Docker build behavior is explicitly defined. +- Use `Nixpacks` only if you intentionally want auto-detected build logic. + +5. Configure branch and base directory: + +- Branch: your deploy branch (for example `main`) +- Base directory: `/` + +6. Configure network: + +- Exposed port: `9090` +- Domain: set your Coolify domain/custom domain + +7. Configure environment variables and secrets from the Common Runtime Configuration section. +8. Add persistent storage mount `/app/data`. +9. Configure health check: + +- Path: `/health` +- Ensure container includes `curl` or `wget` if using UI-defined checks. + +10. Click Deploy and verify: + +- Deployment logs complete successfully. +- `GET /health` returns success. + +Optional (Git webhook auto-deploy with Gitea): + +1. In Coolify resource, open `Webhooks` and copy Manual Git Webhook URL. +2. Set webhook secret in Coolify. +3. In Gitea repo settings, add webhook URL + same secret and enable Push events. +4. Push a commit and confirm Coolify triggers deploy. + +## Option B: Deploy Prebuilt Image from Container Registry + +Recommended when CI publishes the image and Coolify only runs it. + +Image: + +- `git.allucanget.biz/allucanget/arbitrade:latest` + +1. Ensure CI publishes the image before first deployment. +2. In Coolify, select Create New Resource. +3. Choose Application deployment based on Docker Image. +4. Set image reference: + +- Registry: `git.allucanget.biz` +- Image: `git.allucanget.biz/allucanget/arbitrade:latest` + +5. Configure registry credentials in Coolify if your registry requires auth. +6. Leave build/install/start commands empty unless you need overrides. +7. Set network and health: + +- Exposed port: `9090` +- Health check path: `/health` + +8. Add environment variables and secrets from the Common Runtime Configuration section. +9. Add persistent storage mount `/app/data`. +10. Deploy and verify: + +- Logs show container start success. +- `GET /health` returns success. + +Update flow for new releases: + +- Push code and let CI publish a new `latest` image. +- Trigger redeploy in Coolify for this resource. + +## Quick Troubleshooting + +- `No available server` from proxy: +- Check health check path/port and app bind (`APP_HOST=0.0.0.0`, `APP_PORT=9090`). +- Verify health check is passing in Coolify. +- `TemplateNotFound: dashboard.html` at runtime: +- Ensure the deployed image/wheel includes package templates under `arbitrade/web/templates/*`. +- If you build from source, do not remove packaged template files under `src/arbitrade/web/templates`. +- DB resets after deploy: +- Confirm persistent mount exists at `/app/data` and `DUCKDB_PATH` points there. +- Registry pull fails: +- Re-check Docker registry credentials in Coolify. +- App starts but unavailable externally: +- Confirm exposed port is `9090` and domain is attached to this resource. diff --git a/README.md b/README.md index c16b974..9f710b2 100644 --- a/README.md +++ b/README.md @@ -12,6 +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). ## Current Status diff --git a/build/lib/arbitrade/__init__.py b/build/lib/arbitrade/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/build/lib/arbitrade/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/build/lib/arbitrade/alerting/__init__.py b/build/lib/arbitrade/alerting/__init__.py new file mode 100644 index 0000000..dcaa841 --- /dev/null +++ b/build/lib/arbitrade/alerting/__init__.py @@ -0,0 +1,25 @@ +"""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", +] diff --git a/build/lib/arbitrade/alerting/notifier.py b/build/lib/arbitrade/alerting/notifier.py new file mode 100644 index 0000000..0ad3ef4 --- /dev/null +++ b/build/lib/arbitrade/alerting/notifier.py @@ -0,0 +1,400 @@ +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, + ) diff --git a/build/lib/arbitrade/api/__init__.py b/build/lib/arbitrade/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/arbitrade/api/app.py b/build/lib/arbitrade/api/app.py new file mode 100644 index 0000000..d7dd11e --- /dev/null +++ b/build/lib/arbitrade/api/app.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from arbitrade.alerting.notifier import build_notifier_from_settings +from arbitrade.api.control_state import DashboardControlState +from arbitrade.api.routes import public_router, router +from arbitrade.config.settings import Settings +from arbitrade.logging_setup import configure_logging +from arbitrade.metrics import MetricsCalculator +from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state +from arbitrade.storage.db import DuckDBStore +from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository + + +def create_app(settings: Settings) -> FastAPI: + configure_logging(settings.log_level, settings.log_json) + + db = DuckDBStore(settings) + db.migrate() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + await restore_runtime_state(app) + yield + await graceful_shutdown(app) + + app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan) + app.state.settings = settings + app.state.store = db + app.state.metrics = MetricsCalculator(db) + app.state.audit_repository = AuditRepository(db) + app.state.runtime_state_repository = RuntimeStateRepository(db) + app.state.alert_notifier = build_notifier_from_settings(settings) + app.state.backtest_recent_reports = [] + app.state.dashboard_controls = DashboardControlState( + is_running=not settings.kill_switch_active, + ) + app.include_router(public_router) + app.include_router(router) + return app diff --git a/build/lib/arbitrade/api/auth.py b/build/lib/arbitrade/api/auth.py new file mode 100644 index 0000000..450036a --- /dev/null +++ b/build/lib/arbitrade/api/auth.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from secrets import compare_digest +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +dashboard_basic_auth = HTTPBasic(auto_error=False) + + +def require_dashboard_auth( + request: Request, + credentials: Annotated[HTTPBasicCredentials | None, Depends(dashboard_basic_auth)], +) -> None: + settings = request.app.state.settings + username = settings.dashboard_auth_username + password = settings.dashboard_auth_password + + if username is None and password is None: + return + + if username is None or password is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Dashboard auth misconfigured", + ) + + if ( + credentials is None + or not compare_digest(credentials.username, username) + or not compare_digest(credentials.password, password) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": 'Basic realm="Arbitrade Dashboard"'}, + ) diff --git a/build/lib/arbitrade/api/control_state.py b/build/lib/arbitrade/api/control_state.py new file mode 100644 index 0000000..b715dcc --- /dev/null +++ b/build/lib/arbitrade/api/control_state.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from arbitrade.risk.kill_switch import KillSwitch + + +@dataclass(slots=True) +class DashboardControlState: + is_running: bool = True + kill_switch: KillSwitch = field(default_factory=KillSwitch) + tradable_pairs: list[str] = field(default_factory=list) + strategy_mode: str = "incremental" + strategy_profit_threshold: float = 0.0005 + strategy_max_depth_levels: int = 10 + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def mark_updated(self) -> None: + self.updated_at = datetime.now(UTC) diff --git a/build/lib/arbitrade/api/routes.py b/build/lib/arbitrade/api/routes.py new file mode 100644 index 0000000..074ac79 --- /dev/null +++ b/build/lib/arbitrade/api/routes.py @@ -0,0 +1,944 @@ +from __future__ import annotations + +import json +from asyncio import Lock +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from importlib import resources +from pathlib import Path +from typing import cast +from urllib.parse import parse_qs + +import duckdb +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.templating import Jinja2Templates + +from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus +from arbitrade.api.auth import require_dashboard_auth +from arbitrade.api.control_state import DashboardControlState +from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events +from arbitrade.detection.graph import CurrencyGraph, TriangularCycle +from arbitrade.storage.repositories import AuditRecord, AuditRepository + +router = APIRouter(dependencies=[Depends(require_dashboard_auth)]) +public_router = APIRouter() + + +def _resolve_templates_directory() -> str: + # Support source layout, Docker runtime (/app), and installed package data. + source_layout_path = Path( + __file__).resolve().parents[3] / "web" / "templates" + if source_layout_path.is_dir(): + return str(source_layout_path) + + docker_runtime_path = Path.cwd() / "web" / "templates" + if docker_runtime_path.is_dir(): + return str(docker_runtime_path) + + try: + package_path = resources.files( + "arbitrade").joinpath("web", "templates") + if package_path.is_dir(): + return str(package_path) + except (ModuleNotFoundError, AttributeError): + pass + + return str(source_layout_path) + + +templates = Jinja2Templates(directory=_resolve_templates_directory()) +_BACKTEST_ROOT = Path(__file__).resolve().parents[3] +_BACKTEST_RUN_LOCK = Lock() + + +def _format_metric(value: float | None, *, precision: int = 2, suffix: str = "") -> str: + if value is None: + return "—" + return f"{value:.{precision}f}{suffix}" + + +def _dashboard_metrics(request: Request) -> dict[str, str]: + metrics = request.app.state.metrics.compute() + return { + "realized_pnl": _format_metric(metrics.realized_pnl_usd, precision=2, suffix=" USD"), + "win_rate": _format_metric( + metrics.win_rate * 100.0 if metrics.win_rate is not None else None, + precision=1, + suffix="%", + ), + "avg_trade_duration": _format_metric( + metrics.avg_trade_duration_seconds, precision=1, suffix=" s" + ), + "opportunities_per_minute": _format_metric( + metrics.opportunities_per_minute, precision=1, suffix=" /min" + ), + "fill_rate": _format_metric( + metrics.fill_rate * 100.0 if metrics.fill_rate is not None else None, + precision=1, + suffix="%", + ), + "latency_p50": _format_metric(metrics.latency_p50_seconds, precision=3, suffix=" s"), + "latency_p95": _format_metric(metrics.latency_p95_seconds, precision=3, suffix=" s"), + "latency_p99": _format_metric(metrics.latency_p99_seconds, precision=3, suffix=" s"), + "generated_at": datetime.now(UTC).isoformat(), + } + + +def _table_columns(conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]: + rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall() + return {str(row[1]) for row in rows} + + +def _dashboard_overview(request: Request) -> dict[str, object]: + store = request.app.state.store + with store.connect() as conn: + trade_columns = _table_columns(conn, "trades") + trade_ref_expr = "trade_ref" if "trade_ref" in trade_columns else "CAST(id AS VARCHAR)" + cycle_expr = "cycle" if "cycle" in trade_columns else "NULL" + if "finished_at" in trade_columns: + open_trade_filter = "finished_at IS NULL" + else: + open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')" + + portfolio_row = conn.execute(""" + SELECT balances, total_value_usd + FROM portfolio_snapshots + ORDER BY snapshot_at DESC + LIMIT 1 + """).fetchone() + open_trades = conn.execute(f""" + SELECT {trade_ref_expr}, status, started_at, {cycle_expr} + FROM trades + WHERE {open_trade_filter} + ORDER BY started_at DESC + LIMIT 5 + """).fetchall() + rpnl = conn.execute(""" + SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) + FROM trades + """).fetchone() + latest_opportunities = conn.execute(""" + SELECT cycle, net_pct, est_profit, detected_at + FROM opportunities + ORDER BY detected_at DESC + LIMIT 5 + """).fetchall() + + balances_value = "—" + total_value = "—" + if portfolio_row is not None: + balances_raw, total_value_raw = portfolio_row + balances_value = str(balances_raw) if balances_raw is not None else "—" + if total_value_raw is not None: + total_value = f"{float(total_value_raw):.2f} USD" + + open_trade_rows = [ + { + "trade_ref": str(row[0]), + "status": str(row[1]), + "started_at": row[2].isoformat() if isinstance(row[2], datetime) else "—", + "cycle": str(row[3]) if row[3] is not None else "—", + } + for row in open_trades + ] + opportunity_rows = [ + { + "cycle": str(row[0]), + "net_pct": f"{float(row[1]):.2f}%" if row[1] is not None else "—", + "est_profit": f"{float(row[2]):.2f} USD" if row[2] is not None else "—", + "detected_at": row[3].isoformat() if isinstance(row[3], datetime) else "—", + } + for row in latest_opportunities + ] + + return { + "status": "live", + "generated_at": datetime.now(UTC).isoformat(), + "balances": balances_value, + "total_value": total_value, + "open_trade_count": len(open_trade_rows), + "open_trades": open_trade_rows, + "realized_pnl_total": f"{float(rpnl[0]):.2f} USD" if rpnl else "—", + "opportunities": opportunity_rows, + } + + +def _dashboard_charts(request: Request) -> dict[str, object]: + store = request.app.state.store + with store.connect() as conn: + opportunity_rows = conn.execute(""" + SELECT detected_at, cycle, net_pct, est_profit + FROM opportunities + ORDER BY detected_at DESC + LIMIT 10 + """).fetchall() + + cr = list(reversed(opportunity_rows)) + labels = [] + for index, row in enumerate(cr): + if isinstance(row[0], datetime): + labels.append(row[0].isoformat()) + else: + labels.append(f"opportunity-{index + 1}") + np = [float(row[2]) if row[2] is not None else 0.0 for row in cr] + ep = [float(row[3]) if row[3] is not None else 0.0 for row in cr] + cycles = [str(row[1]) for row in cr] + + return { + "labels": labels, + "net_pct_values": np, + "est_profit_values": ep, + "cycles": cycles, + "has_chart_data": bool(cr), + "generated_at": datetime.now(UTC).isoformat(), + } + + +def _dashboard_controls_state(request: Request) -> DashboardControlState: + return cast(DashboardControlState, request.app.state.dashboard_controls) + + +def _audit_repository(request: Request) -> AuditRepository | None: + repository = getattr(request.app.state, "audit_repository", None) + return cast(AuditRepository | None, repository) + + +def _record_audit( + request: Request, + *, + actor: str, + event_type: str, + decision: str, + payload: dict[str, object] | None = None, +) -> None: + repository = _audit_repository(request) + if repository is None: + return + correlation_id = request.headers.get("x-request-id") + if payload is not None: + ret_pl = {str(key): payload[key] for key in payload} + else: + ret_pl = None + repository.insert( + AuditRecord( + occurred_at=datetime.now(UTC), + actor=actor, + event_type=event_type, + decision=decision, + payload=ret_pl, + correlation_id=correlation_id, + ) + ) + + +def _dashboard_audit(request: Request, *, limit: int = 15) -> dict[str, object]: + repository = _audit_repository(request) + if repository is None: + return { + "entries": [], + "generated_at": datetime.now(UTC).isoformat(), + } + + records = repository.list_recent(limit=limit) + entries: list[dict[str, str]] = [] + for record in records: + payload_text = "—" + if record.payload: + payload_text = json.dumps(record.payload) + entries.append( + { + "occurred_at": record.occurred_at.isoformat(), + "actor": record.actor, + "event_type": record.event_type, + "decision": record.decision, + "payload": payload_text, + "correlation_id": record.correlation_id or "—", + } + ) + + return { + "entries": entries, + "generated_at": datetime.now(UTC).isoformat(), + } + + +def _alert_notifier(request: Request) -> SupportsAlerts | None: + notifier = getattr(request.app.state, "alert_notifier", None) + return cast(SupportsAlerts | None, notifier) + + +def _alert_status_snapshot(request: Request) -> dict[str, object]: + notifier = getattr(request.app.state, "alert_notifier", None) + if isinstance(notifier, SupportsAlertStatus): + return notifier.status_snapshot() + return { + "enabled": False, + "has_channels": False, + "configured_channels": [], + "min_severity": "—", + "dedup_seconds": 0.0, + "last_result": "unavailable", + "last_attempted_at": None, + "last_success_at": None, + "last_error": None, + "last_event": None, + "last_channel_results": [], + } + + +def _dashboard_controls(request: Request) -> dict[str, object]: + ctl = _dashboard_controls_state(request) + rs = request.app.state.settings + alert_status = _alert_status_snapshot(request) + last_event = alert_status.get("last_event") + last_event_title = "—" + if isinstance(last_event, dict): + title_value = last_event.get("title") + if isinstance(title_value, str): + last_event_title = title_value + + cc = alert_status.get("configured_channels") + cd = "—" + if isinstance(cc, list) and cc: + cd = ", ".join(str(channel) for channel in cc) + + ddsr = alert_status.get("dedup_seconds", 0.0) + dds = float(ddsr) if isinstance(ddsr, int | float) else 0.0 + tpd = ", ".join(ctl.tradable_pairs) if ctl.tradable_pairs else "All" + max_trade_capital_usd = ( + f"{float(rs.max_trade_capital_usd):.2f} USD" + if rs.max_trade_capital_usd is not None + else "—" + ) + max_trade_capital_usd_value = ( + f"{float(rs.max_trade_capital_usd):.2f}" if rs.max_trade_capital_usd is not None else "" + ) + max_concurrent_trades = ( + str(rs.max_concurrent_trades) if rs.max_concurrent_trades is not None else "—" + ) + max_concurrent_trades_value = ( + str(rs.max_concurrent_trades) if rs.max_concurrent_trades is not None else "" + ) + alerts_last_channel_results = [ + str(item) for item in cast(list[object], alert_status.get("last_channel_results", [])) + ] + strategy_stat_arb_enabled = bool( + getattr(rs, "strategy_enable_stat_arb_experiment", False)) + + return { + "execution_status": "running" if ctl.is_running else "stopped", + "kill_switch_status": "active" if ctl.kill_switch.is_active else "inactive", + "kill_switch_reason": ctl.kill_switch.reason or "—", + "paper_trading_mode": "enabled" if rs.paper_trading_mode else "disabled", + "trade_capital_usd": f"{float(rs.trade_capital_usd):.2f} USD", + "trade_capital_usd_value": f"{float(rs.trade_capital_usd):.2f}", + "max_trade_capital_usd": max_trade_capital_usd, + "max_trade_capital_usd_value": max_trade_capital_usd_value, + "max_concurrent_trades": max_concurrent_trades, + "max_concurrent_trades_value": max_concurrent_trades_value, + "alerts_enabled": "enabled" if bool(alert_status.get("enabled", False)) else "disabled", + "alerts_channels": cd, + "alerts_min_severity": str(alert_status.get("min_severity", "—")), + "alerts_dedup_seconds": f"{dds:.0f}", + "alerts_last_result": str(alert_status.get("last_result", "unavailable")), + "alerts_last_attempted_at": str(alert_status.get("last_attempted_at") or "—"), + "alerts_last_success_at": str(alert_status.get("last_success_at") or "—"), + "alerts_last_event_title": last_event_title, + "alerts_last_error": str(alert_status.get("last_error") or "—"), + "alerts_last_channel_results": alerts_last_channel_results, + "tradable_pairs_display": tpd, + "tradable_pairs_value": ", ".join(ctl.tradable_pairs), + "strategy_mode": ctl.strategy_mode, + "strategy_stat_arb_enabled": strategy_stat_arb_enabled, + "strategy_profit_threshold": f"{ctl.strategy_profit_threshold:.6f}", + "strategy_max_depth_levels": str(ctl.strategy_max_depth_levels), + "updated_at": ctl.updated_at.isoformat(), + "start_endpoint": "/dashboard/control/start", + "stop_endpoint": "/dashboard/control/stop", + "kill_switch_endpoint": "/dashboard/control/kill-switch", + "config_endpoint": "/dashboard/control/config", + "chart_endpoint": "/dashboard/fragment/charts", + } + + +def _parse_form_body(body: bytes) -> dict[str, str]: + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + return {key: values[-1] for key, values in parsed.items() if values} + + +def _form_bool(value: str | None) -> bool: + if value is None: + return False + return value.lower() in {"1", "true", "yes", "on"} + + +def _parse_comma_separated_list(value: str | None) -> list[str]: + if value is None: + return [] + + items: list[str] = [] + for raw_item in value.split(","): + item = raw_item.strip().upper() + if item and item not in items: + items.append(item) + return items + + +def _normalize_fee_profile(profile: str) -> str: + return profile.strip().lower().replace("-", "_") + + +def _fee_rate_for_profile(profile: str, custom_fee_rate: float | None) -> float: + normalized = _normalize_fee_profile(profile) + profile_map = { + "standard": 0.0026, + "maker_heavy": 0.0016, + "taker_heavy": 0.0035, + } + if normalized == "custom": + if custom_fee_rate is None: + raise ValueError("custom fee profile requires custom_fee_rate") + if custom_fee_rate < 0.0: + raise ValueError("custom_fee_rate must be >= 0") + return custom_fee_rate + if normalized not in profile_map: + valid = ", ".join(sorted(list(profile_map.keys()) + ["custom"])) + raise ValueError(f"fee_profile must be one of: {valid}") + return profile_map[normalized] + + +def _parse_balances(raw: str) -> dict[str, float]: + balances: dict[str, float] = {} + for entry in raw.split(","): + stripped = entry.strip() + if not stripped: + continue + if "=" not in stripped: + raise ValueError("starting_balances must be in ASSET=value format") + asset, value = stripped.split("=", 1) + balances[asset.strip().upper()] = float(value) + if not balances: + raise ValueError("starting_balances must include at least one balance") + return balances + + +def _resolve_workspace_path(raw: str) -> Path: + candidate = Path(raw.strip()) + if not candidate.is_absolute(): + candidate = (_BACKTEST_ROOT / candidate).resolve() + else: + candidate = candidate.resolve() + return candidate + + +def _display_path(path: Path) -> str: + try: + return str(path.relative_to(_BACKTEST_ROOT)) + except ValueError: + return str(path) + + +def _build_cycles_from_events( + symbols: set[str], +) -> tuple[dict[str, list[TriangularCycle]], list[str]]: + graph = CurrencyGraph() + for symbol in sorted(symbols): + if "/" not in symbol: + continue + base, quote = symbol.upper().split("/", 1) + graph.add_pair(base, quote, f"{base}/{quote}") + cycles = graph.triangular_cycles() + return graph.index_cycles_by_pair(cycles), sorted(symbols) + + +def _recent_backtest_reports(request: Request) -> list[dict[str, object]]: + reports = getattr(request.app.state, "backtest_recent_reports", []) + if isinstance(reports, list): + return cast(list[dict[str, object]], reports) + return [] + + +def _backtesting_panel_context( + request: Request, + *, + status: str = "idle", + message: str = "Configure a replay run and execute backtest.", + latest_report: dict[str, object] | None = None, + defaults: dict[str, str] | None = None, +) -> dict[str, object]: + default_values = { + "events_path": "", + "starting_balances": "USD=1000.0", + "trade_capital": "100.0", + "min_profit_threshold": "0.0005", + "fee_profile": "standard", + "custom_fee_rate": "", + "slippage_bps": "4.0", + "execution_latency_ms": "20.0", + } + if defaults is not None: + default_values.update(defaults) + + reports = _recent_backtest_reports(request) + latest = latest_report or (reports[0] if reports else None) + + return { + "status": status, + "message": message, + "latest_report": latest, + "recent_reports": reports, + "run_endpoint": "/dashboard/backtesting/run", + "reports_endpoint": "/dashboard/api/backtesting/reports", + **default_values, + } + + +async def _dashboard_response( + request: Request, template_name: str = "dashboard.html" +) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name=template_name, + context={ + "title": "Arbitrade Dashboard", + "request": request, + "metrics_endpoint": "/dashboard/fragment/metrics", + "overview_endpoint": "/dashboard/fragment/overview", + "controls_endpoint": "/dashboard/fragment/controls", + "charts_endpoint": "/dashboard/fragment/charts", + "audit_endpoint": "/dashboard/fragment/audit", + "stream_endpoint": "/dashboard/stream/metrics", + "overview_stream_endpoint": "/dashboard/stream/overview", + }, + ) + + +@router.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + return await _dashboard_response(request) + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request) -> HTMLResponse: + return await _dashboard_response(request) + + +@router.get("/dashboard/backtesting", response_class=HTMLResponse) +async def dashboard_backtesting_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="backtesting.html", + context={ + "title": "Arbitrade Backtesting", + "request": request, + "panel_endpoint": "/dashboard/fragment/backtesting", + "dashboard_endpoint": "/dashboard", + }, + ) + + +@router.get("/dashboard/fragment/backtesting", response_class=HTMLResponse) +async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/backtesting_panel.html", + context={"request": request, **_backtesting_panel_context(request)}, + ) + + +@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse) +async def dashboard_metrics(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/metrics.html", + context={"request": request, **_dashboard_metrics(request)}, + ) + + +@router.get("/dashboard/fragment/overview", response_class=HTMLResponse) +async def dashboard_overview(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/overview.html", + context={"request": request, **_dashboard_overview(request)}, + ) + + +@router.get("/dashboard/fragment/controls", response_class=HTMLResponse) +async def dashboard_controls(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.get("/dashboard/fragment/charts", response_class=HTMLResponse) +async def dashboard_charts(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/charts.html", + context={"request": request, **_dashboard_charts(request)}, + ) + + +@router.get("/dashboard/fragment/audit", response_class=HTMLResponse) +async def dashboard_audit(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/audit.html", + context={"request": request, **_dashboard_audit(request)}, + ) + + +@router.get("/dashboard/api/alerts/status", response_class=JSONResponse) +async def dashboard_alert_status(request: Request) -> JSONResponse: + return JSONResponse(_alert_status_snapshot(request)) + + +@router.get("/dashboard/api/audit/recent", response_class=JSONResponse) +async def dashboard_audit_recent(request: Request) -> JSONResponse: + return JSONResponse(_dashboard_audit(request, limit=25)) + + +@router.get("/dashboard/api/backtesting/reports", response_class=JSONResponse) +async def dashboard_backtesting_reports(request: Request) -> JSONResponse: + return JSONResponse( + { + "generated_at": datetime.now(UTC).isoformat(), + "reports": _recent_backtest_reports(request), + } + ) + + +@router.post("/dashboard/backtesting/run", response_class=HTMLResponse) +async def dashboard_backtesting_run(request: Request) -> HTMLResponse: + form = _parse_form_body(await request.body()) + defaults = { + "events_path": form.get("events_path", ""), + "starting_balances": form.get("starting_balances", "USD=1000.0"), + "trade_capital": form.get("trade_capital", "100.0"), + "min_profit_threshold": form.get("min_profit_threshold", "0.0005"), + "fee_profile": _normalize_fee_profile(form.get("fee_profile", "standard")), + "custom_fee_rate": form.get("custom_fee_rate", ""), + "slippage_bps": form.get("slippage_bps", "4.0"), + "execution_latency_ms": form.get("execution_latency_ms", "20.0"), + } + + try: + events_path = _resolve_workspace_path(defaults["events_path"]) + if not events_path.exists() or not events_path.is_file(): + raise ValueError( + "events_path must reference an existing JSONL file") + + events = load_replay_events(events_path) + if not events: + raise ValueError("events file contains no replay events") + + custom_fee_rate = ( + float(defaults["custom_fee_rate"] + ) if defaults["custom_fee_rate"].strip() else None + ) + fee_rate = _fee_rate_for_profile( + defaults["fee_profile"], custom_fee_rate) + starting_balances = _parse_balances(defaults["starting_balances"]) + + trade_capital = float(defaults["trade_capital"]) + min_profit_threshold = float(defaults["min_profit_threshold"]) + slippage_bps = float(defaults["slippage_bps"]) + execution_latency_ms = float(defaults["execution_latency_ms"]) + + cycles_by_pair, available_pairs = _build_cycles_from_events( + {event.symbol.upper() for event in events} + ) + if not cycles_by_pair: + raise ValueError( + "unable to derive triangular cycles from provided events") + + config = BacktestConfig( + fee_rate=fee_rate, + min_profit_threshold=min_profit_threshold, + trade_capital=trade_capital, + slippage_bps=slippage_bps, + execution_latency_ms=execution_latency_ms, + ) + + async with _BACKTEST_RUN_LOCK: + engine = BacktestReplayEngine( + cycles_by_pair=cycles_by_pair, + available_pairs=available_pairs, + config=config, + started_at=events[0].occurred_at, + ) + report = await engine.run(events, starting_balances=starting_balances) + + report_item: dict[str, object] = { + "run_at": datetime.now(UTC).isoformat(), + "events_path": _display_path(events_path), + "status": "completed", + "config": { + "trade_capital": trade_capital, + "min_profit_threshold": min_profit_threshold, + "fee_profile": defaults["fee_profile"], + "fee_rate": fee_rate, + "slippage_bps": slippage_bps, + "execution_latency_ms": execution_latency_ms, + }, + "report": { + "processed_events": report.processed_events, + "opportunities_seen": report.opportunities_seen, + "trades_executed": report.trades_executed, + "win_rate": report.win_rate, + "fill_rate": report.fill_rate, + "realized_pnl_usd": report.realized_pnl_usd, + "max_drawdown_usd": report.max_drawdown_usd, + "miss_reasons": dict(report.miss_reasons), + "execution_latency_p50_ms": report.execution_latency_p50_ms, + "execution_latency_p95_ms": report.execution_latency_p95_ms, + "execution_latency_p99_ms": report.execution_latency_p99_ms, + }, + } + + reports = _recent_backtest_reports(request) + reports.insert(0, report_item) + del reports[20:] + + _record_audit( + request, + actor="dashboard_user", + event_type="dashboard.backtesting.run", + decision="completed", + payload={ + "events_path": report_item["events_path"], + "processed_events": report.processed_events, + "trades_executed": report.trades_executed, + "realized_pnl_usd": report.realized_pnl_usd, + }, + ) + + context = _backtesting_panel_context( + request, + status="completed", + message="Backtest run completed successfully.", + latest_report=report_item, + defaults=defaults, + ) + except ValueError as exc: + context = _backtesting_panel_context( + request, + status="failed", + message=str(exc), + defaults=defaults, + ) + + return templates.TemplateResponse( + request=request, + name="partials/backtesting_panel.html", + context={"request": request, **context}, + ) + + +@router.post("/dashboard/control/start", response_class=HTMLResponse) +async def dashboard_control_start(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + controls.is_running = True + controls.mark_updated() + notifier = _alert_notifier(request) + if notifier is not None: + await notifier.notify( + category="system", + severity="info", + title="Execution started", + message="Dashboard control started execution.", + ) + _record_audit( + request, + actor="dashboard_user", + event_type="dashboard.control.start", + decision="approved", + payload={"execution_status": "running"}, + ) + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/stop", response_class=HTMLResponse) +async def dashboard_control_stop(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + controls.is_running = False + controls.mark_updated() + notifier = _alert_notifier(request) + if notifier is not None: + await notifier.notify( + category="system", + severity="warning", + title="Execution stopped", + message="Dashboard control stopped execution.", + ) + _record_audit( + request, + actor="dashboard_user", + event_type="dashboard.control.stop", + decision="approved", + payload={"execution_status": "stopped"}, + ) + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse) +async def dashboard_control_kill_switch(request: Request) -> HTMLResponse: + controls = _dashboard_controls_state(request) + form = _parse_form_body(await request.body()) + reason = form.get("reason") or "manual" + controls.kill_switch.activate(reason=reason) + controls.is_running = False + controls.mark_updated() + notifier = _alert_notifier(request) + if notifier is not None: + await notifier.notify( + category="threshold", + severity="critical", + title="Kill switch activated", + message="Kill switch triggered from dashboard control.", + details={"reason": reason}, + ) + _record_audit( + request, + actor="dashboard_user", + event_type="dashboard.control.kill_switch", + decision="approved", + payload={"reason": reason}, + ) + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.post("/dashboard/control/config", response_class=HTMLResponse) +async def dashboard_control_config(request: Request) -> HTMLResponse: + ctl = _dashboard_controls_state(request) + rs = request.app.state.settings + form = _parse_form_body(await request.body()) + + if "trade_capital_usd" in form and form["trade_capital_usd"]: + rs.trade_capital_usd = float(form["trade_capital_usd"]) + if "max_trade_capital_usd" in form: + mtcv = form["max_trade_capital_usd"].strip() + rs.max_trade_capital_usd = float(mtcv) if mtcv else None + if "max_concurrent_trades" in form: + mcv = form["max_concurrent_trades"].strip() + rs.max_concurrent_trades = int(mcv) if mcv else None + + form_pairs = form.get("tradable_pairs") + ctl.tradable_pairs = _parse_comma_separated_list(form_pairs) + if "strategy_mode" in form and form["strategy_mode"].strip(): + strategy_mode = form["strategy_mode"].strip().lower() + allowed_strategy_modes = {"incremental", "paper", "live"} + if bool(getattr(rs, "strategy_enable_stat_arb_experiment", False)): + allowed_strategy_modes.add("stat_arb_experiment") + if strategy_mode not in allowed_strategy_modes: + e = f"strategy_mode must be one of: {', '.join(sorted(allowed_strategy_modes))}" + raise ValueError(e) + ctl.strategy_mode = strategy_mode + if "strategy_profit_threshold" in form: + if form["strategy_profit_threshold"].strip(): + spt = float(form["strategy_profit_threshold"]) + ctl.strategy_profit_threshold = spt + if "strategy_max_depth_levels" in form: + if form["strategy_max_depth_levels"].strip(): + smdl = int(form["strategy_max_depth_levels"]) + ctl.strategy_max_depth_levels = smdl + + rs.paper_trading_mode = _form_bool(form.get("paper_trading_mode")) + ctl.mark_updated() + + notifier = _alert_notifier(request) + if notifier is not None: + await notifier.notify( + category="system", + severity="info", + title="Runtime config updated", + message="Dashboard control updated runtime risk and execution settings.", + details={ + "trade_capital_usd": f"{rs.trade_capital_usd}", + "max_trade_capital_usd": ( + "none" if rs.max_trade_capital_usd is None else f"{rs.max_trade_capital_usd}" + ), + "max_concurrent_trades": ( + "none" if rs.max_concurrent_trades is None else f"{rs.max_concurrent_trades}" + ), + "paper_trading_mode": "true" if rs.paper_trading_mode else "false", + }, + ) + _record_audit( + request, + actor="dashboard_user", + event_type="dashboard.control.config", + decision="approved", + payload={ + "trade_capital_usd": rs.trade_capital_usd, + "max_trade_capital_usd": rs.max_trade_capital_usd, + "max_concurrent_trades": rs.max_concurrent_trades, + "paper_trading_mode": rs.paper_trading_mode, + "tradable_pairs": ctl.tradable_pairs, + "strategy_mode": ctl.strategy_mode, + "strategy_profit_threshold": ctl.strategy_profit_threshold, + "strategy_max_depth_levels": ctl.strategy_max_depth_levels, + }, + ) + + return templates.TemplateResponse( + request=request, + name="partials/controls.html", + context={"request": request, **_dashboard_controls(request)}, + ) + + +@router.get("/dashboard/stream/metrics") +async def dashboard_metrics_stream(request: Request) -> StreamingResponse: + fragment = ( + templates.get_template("partials/metrics.html") + .render( + request=request, + **_dashboard_metrics(request), + ) + .strip() + .replace("\n", "") + ) + + async def _event_stream() -> AsyncIterator[bytes]: + payload = json.dumps(fragment) + yield f"event: metrics\ndata: {payload}\n\n".encode() + + return StreamingResponse(_event_stream(), media_type="text/event-stream") + + +@router.get("/dashboard/stream/overview") +async def dashboard_overview_stream(request: Request) -> StreamingResponse: + fragment = ( + templates.get_template("partials/overview.html") + .render(request=request, **_dashboard_overview(request)) + .strip() + .replace("\n", "") + ) + + async def _event_stream() -> AsyncIterator[bytes]: + payload = json.dumps(fragment) + yield f"event: overview\ndata: {payload}\n\n".encode() + + return StreamingResponse(_event_stream(), media_type="text/event-stream") + + +@public_router.get("/health", response_class=JSONResponse) +async def health() -> JSONResponse: + return JSONResponse({"status": "ok", "service": "arbitrade"}) diff --git a/build/lib/arbitrade/backtesting/__init__.py b/build/lib/arbitrade/backtesting/__init__.py new file mode 100644 index 0000000..e6cbc60 --- /dev/null +++ b/build/lib/arbitrade/backtesting/__init__.py @@ -0,0 +1,35 @@ +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", +] diff --git a/build/lib/arbitrade/backtesting/replay.py b/build/lib/arbitrade/backtesting/replay.py new file mode 100644 index 0000000..0a14ec7 --- /dev/null +++ b/build/lib/arbitrade/backtesting/replay.py @@ -0,0 +1,326 @@ +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), + ) diff --git a/build/lib/arbitrade/backtesting/sweep.py b/build/lib/arbitrade/backtesting/sweep.py new file mode 100644 index 0000000..67c44a7 --- /dev/null +++ b/build/lib/arbitrade/backtesting/sweep.py @@ -0,0 +1,396 @@ +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)) diff --git a/build/lib/arbitrade/config/__init__.py b/build/lib/arbitrade/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/arbitrade/config/secrets.py b/build/lib/arbitrade/config/secrets.py new file mode 100644 index 0000000..a04d347 --- /dev/null +++ b/build/lib/arbitrade/config/secrets.py @@ -0,0 +1,39 @@ +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") diff --git a/build/lib/arbitrade/config/settings.py b/build/lib/arbitrade/config/settings.py new file mode 100644 index 0000000..11ac3f0 --- /dev/null +++ b/build/lib/arbitrade/config/settings.py @@ -0,0 +1,219 @@ +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() diff --git a/build/lib/arbitrade/detection/__init__.py b/build/lib/arbitrade/detection/__init__.py new file mode 100644 index 0000000..efdc829 --- /dev/null +++ b/build/lib/arbitrade/detection/__init__.py @@ -0,0 +1,12 @@ +"""Arbitrage detection package.""" + +from arbitrade.detection.engine import CycleScore, IncrementalCycleDetector, OpportunityEvent +from arbitrade.detection.graph import CurrencyGraph, TriangularCycle + +__all__ = [ + "CurrencyGraph", + "TriangularCycle", + "CycleScore", + "OpportunityEvent", + "IncrementalCycleDetector", +] diff --git a/build/lib/arbitrade/detection/benchmark.py b/build/lib/arbitrade/detection/benchmark.py new file mode 100644 index 0000000..3ec8056 --- /dev/null +++ b/build/lib/arbitrade/detection/benchmark.py @@ -0,0 +1,113 @@ +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() diff --git a/build/lib/arbitrade/detection/engine.py b/build/lib/arbitrade/detection/engine.py new file mode 100644 index 0000000..fbed5da --- /dev/null +++ b/build/lib/arbitrade/detection/engine.py @@ -0,0 +1,295 @@ +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 diff --git a/build/lib/arbitrade/detection/graph.py b/build/lib/arbitrade/detection/graph.py new file mode 100644 index 0000000..47a1286 --- /dev/null +++ b/build/lib/arbitrade/detection/graph.py @@ -0,0 +1,90 @@ +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 diff --git a/build/lib/arbitrade/exchange/__init__.py b/build/lib/arbitrade/exchange/__init__.py new file mode 100644 index 0000000..26b16d5 --- /dev/null +++ b/build/lib/arbitrade/exchange/__init__.py @@ -0,0 +1 @@ +"""Kraken exchange integration package.""" diff --git a/build/lib/arbitrade/exchange/kraken_rest.py b/build/lib/arbitrade/exchange/kraken_rest.py new file mode 100644 index 0000000..b4d5816 --- /dev/null +++ b/build/lib/arbitrade/exchange/kraken_rest.py @@ -0,0 +1,281 @@ +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}, + ) diff --git a/build/lib/arbitrade/exchange/kraken_ws.py b/build/lib/arbitrade/exchange/kraken_ws.py new file mode 100644 index 0000000..e962228 --- /dev/null +++ b/build/lib/arbitrade/exchange/kraken_ws.py @@ -0,0 +1,177 @@ +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, + ) diff --git a/build/lib/arbitrade/exchange/models.py b/build/lib/arbitrade/exchange/models.py new file mode 100644 index 0000000..27b414d --- /dev/null +++ b/build/lib/arbitrade/exchange/models.py @@ -0,0 +1,37 @@ +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 diff --git a/build/lib/arbitrade/exchange/signing.py b/build/lib/arbitrade/exchange/signing.py new file mode 100644 index 0000000..64648d9 --- /dev/null +++ b/build/lib/arbitrade/exchange/signing.py @@ -0,0 +1,14 @@ +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") diff --git a/build/lib/arbitrade/execution/__init__.py b/build/lib/arbitrade/execution/__init__.py new file mode 100644 index 0000000..1fdb5e3 --- /dev/null +++ b/build/lib/arbitrade/execution/__init__.py @@ -0,0 +1,32 @@ +"""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", +] diff --git a/build/lib/arbitrade/execution/fill_monitor.py b/build/lib/arbitrade/execution/fill_monitor.py new file mode 100644 index 0000000..306530e --- /dev/null +++ b/build/lib/arbitrade/execution/fill_monitor.py @@ -0,0 +1,133 @@ +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) diff --git a/build/lib/arbitrade/execution/idempotency.py b/build/lib/arbitrade/execution/idempotency.py new file mode 100644 index 0000000..8864e2c --- /dev/null +++ b/build/lib/arbitrade/execution/idempotency.py @@ -0,0 +1,105 @@ +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, + ) diff --git a/build/lib/arbitrade/execution/recovery.py b/build/lib/arbitrade/execution/recovery.py new file mode 100644 index 0000000..b62c45b --- /dev/null +++ b/build/lib/arbitrade/execution/recovery.py @@ -0,0 +1,98 @@ +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, + ) diff --git a/build/lib/arbitrade/execution/sequencer.py b/build/lib/arbitrade/execution/sequencer.py new file mode 100644 index 0000000..35f7236 --- /dev/null +++ b/build/lib/arbitrade/execution/sequencer.py @@ -0,0 +1,288 @@ +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), + ) diff --git a/build/lib/arbitrade/logging_setup.py b/build/lib/arbitrade/logging_setup.py new file mode 100644 index 0000000..000f9da --- /dev/null +++ b/build/lib/arbitrade/logging_setup.py @@ -0,0 +1,39 @@ +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) diff --git a/build/lib/arbitrade/main.py b/build/lib/arbitrade/main.py new file mode 100644 index 0000000..4b5e119 --- /dev/null +++ b/build/lib/arbitrade/main.py @@ -0,0 +1,41 @@ +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() diff --git a/build/lib/arbitrade/market_data/__init__.py b/build/lib/arbitrade/market_data/__init__.py new file mode 100644 index 0000000..1202ad1 --- /dev/null +++ b/build/lib/arbitrade/market_data/__init__.py @@ -0,0 +1 @@ +"""Market data ingestion and book cache package.""" diff --git a/build/lib/arbitrade/market_data/feed.py b/build/lib/arbitrade/market_data/feed.py new file mode 100644 index 0000000..a633789 --- /dev/null +++ b/build/lib/arbitrade/market_data/feed.py @@ -0,0 +1,485 @@ +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, + ) + ) diff --git a/build/lib/arbitrade/market_data/order_book.py b/build/lib/arbitrade/market_data/order_book.py new file mode 100644 index 0000000..a4ba86a --- /dev/null +++ b/build/lib/arbitrade/market_data/order_book.py @@ -0,0 +1,104 @@ +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")) diff --git a/build/lib/arbitrade/metrics.py b/build/lib/arbitrade/metrics.py new file mode 100644 index 0000000..aadaf32 --- /dev/null +++ b/build/lib/arbitrade/metrics.py @@ -0,0 +1,100 @@ +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, + ) diff --git a/build/lib/arbitrade/perf/__init__.py b/build/lib/arbitrade/perf/__init__.py new file mode 100644 index 0000000..f0dbf88 --- /dev/null +++ b/build/lib/arbitrade/perf/__init__.py @@ -0,0 +1,4 @@ +from arbitrade.perf.guardrails import evaluate_guardrails +from arbitrade.perf.latency import run_latency_profile + +__all__ = ["run_latency_profile", "evaluate_guardrails"] diff --git a/build/lib/arbitrade/perf/guardrails.py b/build/lib/arbitrade/perf/guardrails.py new file mode 100644 index 0000000..51bcf4a --- /dev/null +++ b/build/lib/arbitrade/perf/guardrails.py @@ -0,0 +1,80 @@ +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 diff --git a/build/lib/arbitrade/perf/latency.py b/build/lib/arbitrade/perf/latency.py new file mode 100644 index 0000000..8749a03 --- /dev/null +++ b/build/lib/arbitrade/perf/latency.py @@ -0,0 +1,195 @@ +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 diff --git a/build/lib/arbitrade/risk/__init__.py b/build/lib/arbitrade/risk/__init__.py new file mode 100644 index 0000000..f90c40f --- /dev/null +++ b/build/lib/arbitrade/risk/__init__.py @@ -0,0 +1,15 @@ +"""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", +] diff --git a/build/lib/arbitrade/risk/kill_switch.py b/build/lib/arbitrade/risk/kill_switch.py new file mode 100644 index 0000000..3b3182c --- /dev/null +++ b/build/lib/arbitrade/risk/kill_switch.py @@ -0,0 +1,23 @@ +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 diff --git a/build/lib/arbitrade/risk/loss_limits.py b/build/lib/arbitrade/risk/loss_limits.py new file mode 100644 index 0000000..ab33199 --- /dev/null +++ b/build/lib/arbitrade/risk/loss_limits.py @@ -0,0 +1,90 @@ +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}", + }, + ) diff --git a/build/lib/arbitrade/risk/pre_trade.py b/build/lib/arbitrade/risk/pre_trade.py new file mode 100644 index 0000000..74ae2ec --- /dev/null +++ b/build/lib/arbitrade/risk/pre_trade.py @@ -0,0 +1,43 @@ +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 diff --git a/build/lib/arbitrade/risk/stop_conditions.py b/build/lib/arbitrade/risk/stop_conditions.py new file mode 100644 index 0000000..1691787 --- /dev/null +++ b/build/lib/arbitrade/risk/stop_conditions.py @@ -0,0 +1,109 @@ +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 diff --git a/build/lib/arbitrade/risk/trade_limits.py b/build/lib/arbitrade/risk/trade_limits.py new file mode 100644 index 0000000..978142b --- /dev/null +++ b/build/lib/arbitrade/risk/trade_limits.py @@ -0,0 +1,98 @@ +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 diff --git a/build/lib/arbitrade/runtime/__init__.py b/build/lib/arbitrade/runtime/__init__.py new file mode 100644 index 0000000..210b16c --- /dev/null +++ b/build/lib/arbitrade/runtime/__init__.py @@ -0,0 +1,15 @@ +"""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", +] diff --git a/build/lib/arbitrade/runtime/lifecycle.py b/build/lib/arbitrade/runtime/lifecycle.py new file mode 100644 index 0000000..c00a0e0 --- /dev/null +++ b/build/lib/arbitrade/runtime/lifecycle.py @@ -0,0 +1,223 @@ +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") diff --git a/build/lib/arbitrade/storage/__init__.py b/build/lib/arbitrade/storage/__init__.py new file mode 100644 index 0000000..76541db --- /dev/null +++ b/build/lib/arbitrade/storage/__init__.py @@ -0,0 +1 @@ +"""Storage helpers.""" diff --git a/build/lib/arbitrade/storage/db.py b/build/lib/arbitrade/storage/db.py new file mode 100644 index 0000000..63ce1bf --- /dev/null +++ b/build/lib/arbitrade/storage/db.py @@ -0,0 +1,128 @@ +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) diff --git a/build/lib/arbitrade/storage/executions.py b/build/lib/arbitrade/storage/executions.py new file mode 100644 index 0000000..4262091 --- /dev/null +++ b/build/lib/arbitrade/storage/executions.py @@ -0,0 +1,66 @@ +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() diff --git a/build/lib/arbitrade/storage/market_snapshots.py b/build/lib/arbitrade/storage/market_snapshots.py new file mode 100644 index 0000000..c87c529 --- /dev/null +++ b/build/lib/arbitrade/storage/market_snapshots.py @@ -0,0 +1,64 @@ +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() diff --git a/build/lib/arbitrade/storage/opportunities.py b/build/lib/arbitrade/storage/opportunities.py new file mode 100644 index 0000000..032b23a --- /dev/null +++ b/build/lib/arbitrade/storage/opportunities.py @@ -0,0 +1,58 @@ +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() diff --git a/build/lib/arbitrade/storage/repositories.py b/build/lib/arbitrade/storage/repositories.py new file mode 100644 index 0000000..5977f61 --- /dev/null +++ b/build/lib/arbitrade/storage/repositories.py @@ -0,0 +1,378 @@ +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, + ) diff --git a/build/lib/arbitrade/strategy/__init__.py b/build/lib/arbitrade/strategy/__init__.py new file mode 100644 index 0000000..6d1952d --- /dev/null +++ b/build/lib/arbitrade/strategy/__init__.py @@ -0,0 +1,5 @@ +"""Experimental strategy modules.""" + +from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig, StatArbSignal + +__all__ = ["StatArbExperiment", "StatArbExperimentConfig", "StatArbSignal"] diff --git a/build/lib/arbitrade/strategy/stat_arb.py b/build/lib/arbitrade/strategy/stat_arb.py new file mode 100644 index 0000000..78d574d --- /dev/null +++ b/build/lib/arbitrade/strategy/stat_arb.py @@ -0,0 +1,152 @@ +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, + ) diff --git a/build/lib/arbitrade/web/templates/backtesting.html b/build/lib/arbitrade/web/templates/backtesting.html new file mode 100644 index 0000000..7519e75 --- /dev/null +++ b/build/lib/arbitrade/web/templates/backtesting.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block +content %} +
+
+

Backtesting

+

+ Replay controls, run status, and recent summary reports. +

+
+
+ Dashboard +
+
+ +
+ {% include "partials/backtesting_panel.html" %} +
+{% endblock %} diff --git a/build/lib/arbitrade/web/templates/base.html b/build/lib/arbitrade/web/templates/base.html new file mode 100644 index 0000000..4155859 --- /dev/null +++ b/build/lib/arbitrade/web/templates/base.html @@ -0,0 +1,148 @@ + + + + + + {% block title %}{{ title or "Arbitrade" }}{% endblock %} + + {% block head_scripts %}{% endblock %} + + {% block extra_style %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/build/lib/arbitrade/web/templates/dashboard.html b/build/lib/arbitrade/web/templates/dashboard.html new file mode 100644 index 0000000..b6be1fa --- /dev/null +++ b/build/lib/arbitrade/web/templates/dashboard.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block +head_scripts %} + + +{% endblock %} {% block main_class %}shell{% endblock %} {% block content %} +
+
+

Arbitrade Dashboard

+

Live execution, P&L, and system state.

+
+ +
+ +
+ {% include "partials/metrics.html" %} +
+ +
+ {% include "partials/overview.html" %} +
+ +
+ {% include "partials/controls.html" %} +
+ +
+ {% include "partials/charts.html" %} +
+ +
+ {% include "partials/audit.html" %} +
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/build/lib/arbitrade/web/templates/health.html b/build/lib/arbitrade/web/templates/health.html new file mode 100644 index 0000000..aa86fd0 --- /dev/null +++ b/build/lib/arbitrade/web/templates/health.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
+

Arbitrade Bootstrap Complete

+

Status: {{ status }}

+

UTC: {{ time }}

+

+ Health JSON: + refresh +

+
{"status":"ok","service":"arbitrade"}
+
+{% endblock %} diff --git a/build/lib/arbitrade/web/templates/partials/audit.html b/build/lib/arbitrade/web/templates/partials/audit.html new file mode 100644 index 0000000..2aa55db --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/audit.html @@ -0,0 +1,37 @@ +
+
Audit Trail
+
Generated {{ generated_at }}
+ +
+ + + + + + + + + + + + + {% if entries %} + {% for entry in entries %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
TimeActorEventDecisionPayloadCorrelation
{{ entry.occurred_at }}{{ entry.actor }}{{ entry.event_type }}{{ entry.decision }}{{ entry.payload }}{{ entry.correlation_id }}
No audit entries yet.
+
+
diff --git a/build/lib/arbitrade/web/templates/partials/backtesting_panel.html b/build/lib/arbitrade/web/templates/partials/backtesting_panel.html new file mode 100644 index 0000000..15b665d --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/backtesting_panel.html @@ -0,0 +1,142 @@ +
+
+
+
Run Status
+
{{ status }}
+
{{ message }}
+
+
+
Latest Report
+ {% if latest_report %} +
Run at {{ latest_report.run_at }}
+
Events: {{ latest_report.events_path }}
+
+ Processed: {{ latest_report.report.processed_events }} +
+
+ Opportunities: {{ latest_report.report.opportunities_seen }} +
+
Trades: {{ latest_report.report.trades_executed }}
+
+ Realized P&L: {{ + '%.4f'|format(latest_report.report.realized_pnl_usd) }} USD +
+
+ Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }} + USD +
+ {% else %} +
No runs yet.
+ {% endif %} +
+
+ +
+
Run Backtest
+
+ + + + + + + + + +
+
+ +
+
Recent Runs
+ {% if recent_reports %} {% for item in recent_reports %} +
+ {{ item.run_at }} | {{ item.events_path }} | trades={{ + item.report.trades_executed }} | pnl={{ + '%.4f'|format(item.report.realized_pnl_usd) }} USD +
+ {% endfor %} {% else %} +
No recent reports yet.
+ {% endif %} +
+
diff --git a/build/lib/arbitrade/web/templates/partials/charts.html b/build/lib/arbitrade/web/templates/partials/charts.html new file mode 100644 index 0000000..91c51df --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/charts.html @@ -0,0 +1,37 @@ +
+
+
+
Opportunity Trend
+
Recent opportunities from DuckDB. Updated {{ generated_at }}
+
+ +
+ +
+
+ {% if has_chart_data %} + + + {% else %} +
No opportunity data yet.
+ {% endif %} +
+
+
\ No newline at end of file diff --git a/build/lib/arbitrade/web/templates/partials/controls.html b/build/lib/arbitrade/web/templates/partials/controls.html new file mode 100644 index 0000000..a5f968b --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/controls.html @@ -0,0 +1,171 @@ +
+
+
+
Runtime Status
+
{{ execution_status }}
+
Updated {{ updated_at }}
+
+
+
Kill Switch
+
{{ kill_switch_status }}
+
Reason {{ kill_switch_reason }}
+
+
+
Config Snapshot
+
Paper trading: {{ paper_trading_mode }}
+
Trade capital: {{ trade_capital_usd }}
+
Max trade capital: {{ max_trade_capital_usd }}
+
Max concurrent trades: {{ max_concurrent_trades }}
+
Tradable pairs: {{ tradable_pairs_display }}
+
Strategy mode: {{ strategy_mode }}
+
Profit threshold: {{ strategy_profit_threshold }}
+
Max depth levels: {{ strategy_max_depth_levels }}
+
+
+
Alerting
+
Status: {{ alerts_enabled }}
+
Channels: {{ alerts_channels }}
+
Min severity: {{ alerts_min_severity }}
+
Dedup window: {{ alerts_dedup_seconds }}s
+
Last result: {{ alerts_last_result }}
+
Last attempted: {{ alerts_last_attempted_at }}
+
Last success: {{ alerts_last_success_at }}
+
Last event: {{ alerts_last_event_title }}
+
Last error: {{ alerts_last_error }}
+ {% if alerts_last_channel_results %} {% for item in + alerts_last_channel_results %} +
{{ item }}
+ {% endfor %} {% endif %} +
+
+ +
+
+
Execution Controls
+
+
+ +
+
+ +
+
+ + +
+
+
+
+
Edit Config
+
+ + + + + + + + + +
+
+
+
diff --git a/build/lib/arbitrade/web/templates/partials/metrics.html b/build/lib/arbitrade/web/templates/partials/metrics.html new file mode 100644 index 0000000..1748e29 --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/metrics.html @@ -0,0 +1,31 @@ +
+
+
+
Realized P&L
+
{{ realized_pnl }}
+
+
+
Win Rate
+
{{ win_rate }}
+
+
+
Avg Trade Duration
+
{{ avg_trade_duration }}
+
+
+
Opportunities / Min
+
{{ opportunities_per_minute }}
+
+
+
Fill Rate
+
{{ fill_rate }}
+
+
+
Latency p50 / p95 / p99
+
+ {{ latency_p50 }} | {{ latency_p95 }} | {{ latency_p99 }} +
+
+
+
Updated {{ generated_at }}
+
diff --git a/build/lib/arbitrade/web/templates/partials/overview.html b/build/lib/arbitrade/web/templates/partials/overview.html new file mode 100644 index 0000000..6787b51 --- /dev/null +++ b/build/lib/arbitrade/web/templates/partials/overview.html @@ -0,0 +1,67 @@ +
+
+
+
Status
+
{{ status }}
+
+
+
Balances
+
{{ balances }}
+
+
+
Open Trades
+
{{ open_trade_count }}
+
+
+
Realized P&L
+
{{ realized_pnl_total }}
+
+
+ +
+
+
Open Trades
+
    + {% for trade in open_trades %} +
  • + {{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{ + trade.started_at }} +
  • + {% else %} +
  • No open trades.
  • + {% endfor %} +
+
+
+
Balances Snapshot
+
+ {{ balances }} +
+
Total value {{ total_value }}
+
+
+
Opportunity Feed
+
    + {% for opp in opportunities %} +
  • + {{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{ + opp.detected_at }} +
  • + {% else %} +
  • No opportunities.
  • + {% endfor %} +
+
+
+ +
Updated {{ generated_at }}
+
diff --git a/dist/arbitrade-0.1.0-py3-none-any.whl b/dist/arbitrade-0.1.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..f88f77e0796fd4be3f444c2a4006cfc79b1fdd23 GIT binary patch literal 80562 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 Hwg~heC+%){ literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index fe7512e..519d9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,13 @@ arbitrade-bench-detection = "arbitrade.detection.benchmark:main" [tool.setuptools] package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.package-data] +arbitrade = [ + "web/templates/*.html", + "web/templates/partials/*.html", +] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/arbitrade/api/routes.py b/src/arbitrade/api/routes.py index ff5c36d..074ac79 100644 --- a/src/arbitrade/api/routes.py +++ b/src/arbitrade/api/routes.py @@ -4,6 +4,7 @@ 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 @@ -22,9 +23,31 @@ from arbitrade.storage.repositories import AuditRecord, AuditRepository router = APIRouter(dependencies=[Depends(require_dashboard_auth)]) public_router = APIRouter() -templates = Jinja2Templates( - directory=str(Path(__file__).resolve().parents[3] / "web" / "templates") -) + + +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() diff --git a/src/arbitrade/web/templates/backtesting.html b/src/arbitrade/web/templates/backtesting.html new file mode 100644 index 0000000..7519e75 --- /dev/null +++ b/src/arbitrade/web/templates/backtesting.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block +content %} +
+
+

Backtesting

+

+ Replay controls, run status, and recent summary reports. +

+
+
+ Dashboard +
+
+ +
+ {% include "partials/backtesting_panel.html" %} +
+{% endblock %} diff --git a/src/arbitrade/web/templates/base.html b/src/arbitrade/web/templates/base.html new file mode 100644 index 0000000..4155859 --- /dev/null +++ b/src/arbitrade/web/templates/base.html @@ -0,0 +1,148 @@ + + + + + + {% block title %}{{ title or "Arbitrade" }}{% endblock %} + + {% block head_scripts %}{% endblock %} + + {% block extra_style %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/src/arbitrade/web/templates/dashboard.html b/src/arbitrade/web/templates/dashboard.html new file mode 100644 index 0000000..b6be1fa --- /dev/null +++ b/src/arbitrade/web/templates/dashboard.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block +head_scripts %} + + +{% endblock %} {% block main_class %}shell{% endblock %} {% block content %} +
+
+

Arbitrade Dashboard

+

Live execution, P&L, and system state.

+
+ +
+ +
+ {% include "partials/metrics.html" %} +
+ +
+ {% include "partials/overview.html" %} +
+ +
+ {% include "partials/controls.html" %} +
+ +
+ {% include "partials/charts.html" %} +
+ +
+ {% include "partials/audit.html" %} +
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/src/arbitrade/web/templates/health.html b/src/arbitrade/web/templates/health.html new file mode 100644 index 0000000..aa86fd0 --- /dev/null +++ b/src/arbitrade/web/templates/health.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
+

Arbitrade Bootstrap Complete

+

Status: {{ status }}

+

UTC: {{ time }}

+

+ Health JSON: + refresh +

+
{"status":"ok","service":"arbitrade"}
+
+{% endblock %} diff --git a/src/arbitrade/web/templates/partials/audit.html b/src/arbitrade/web/templates/partials/audit.html new file mode 100644 index 0000000..2aa55db --- /dev/null +++ b/src/arbitrade/web/templates/partials/audit.html @@ -0,0 +1,37 @@ +
+
Audit Trail
+
Generated {{ generated_at }}
+ +
+ + + + + + + + + + + + + {% if entries %} + {% for entry in entries %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
TimeActorEventDecisionPayloadCorrelation
{{ entry.occurred_at }}{{ entry.actor }}{{ entry.event_type }}{{ entry.decision }}{{ entry.payload }}{{ entry.correlation_id }}
No audit entries yet.
+
+
diff --git a/src/arbitrade/web/templates/partials/backtesting_panel.html b/src/arbitrade/web/templates/partials/backtesting_panel.html new file mode 100644 index 0000000..15b665d --- /dev/null +++ b/src/arbitrade/web/templates/partials/backtesting_panel.html @@ -0,0 +1,142 @@ +
+
+
+
Run Status
+
{{ status }}
+
{{ message }}
+
+
+
Latest Report
+ {% if latest_report %} +
Run at {{ latest_report.run_at }}
+
Events: {{ latest_report.events_path }}
+
+ Processed: {{ latest_report.report.processed_events }} +
+
+ Opportunities: {{ latest_report.report.opportunities_seen }} +
+
Trades: {{ latest_report.report.trades_executed }}
+
+ Realized P&L: {{ + '%.4f'|format(latest_report.report.realized_pnl_usd) }} USD +
+
+ Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }} + USD +
+ {% else %} +
No runs yet.
+ {% endif %} +
+
+ +
+
Run Backtest
+
+ + + + + + + + + +
+
+ +
+
Recent Runs
+ {% if recent_reports %} {% for item in recent_reports %} +
+ {{ item.run_at }} | {{ item.events_path }} | trades={{ + item.report.trades_executed }} | pnl={{ + '%.4f'|format(item.report.realized_pnl_usd) }} USD +
+ {% endfor %} {% else %} +
No recent reports yet.
+ {% endif %} +
+
diff --git a/src/arbitrade/web/templates/partials/charts.html b/src/arbitrade/web/templates/partials/charts.html new file mode 100644 index 0000000..91c51df --- /dev/null +++ b/src/arbitrade/web/templates/partials/charts.html @@ -0,0 +1,37 @@ +
+
+
+
Opportunity Trend
+
Recent opportunities from DuckDB. Updated {{ generated_at }}
+
+ +
+ +
+
+ {% if has_chart_data %} + + + {% else %} +
No opportunity data yet.
+ {% endif %} +
+
+
\ No newline at end of file diff --git a/src/arbitrade/web/templates/partials/controls.html b/src/arbitrade/web/templates/partials/controls.html new file mode 100644 index 0000000..a5f968b --- /dev/null +++ b/src/arbitrade/web/templates/partials/controls.html @@ -0,0 +1,171 @@ +
+
+
+
Runtime Status
+
{{ execution_status }}
+
Updated {{ updated_at }}
+
+
+
Kill Switch
+
{{ kill_switch_status }}
+
Reason {{ kill_switch_reason }}
+
+
+
Config Snapshot
+
Paper trading: {{ paper_trading_mode }}
+
Trade capital: {{ trade_capital_usd }}
+
Max trade capital: {{ max_trade_capital_usd }}
+
Max concurrent trades: {{ max_concurrent_trades }}
+
Tradable pairs: {{ tradable_pairs_display }}
+
Strategy mode: {{ strategy_mode }}
+
Profit threshold: {{ strategy_profit_threshold }}
+
Max depth levels: {{ strategy_max_depth_levels }}
+
+
+
Alerting
+
Status: {{ alerts_enabled }}
+
Channels: {{ alerts_channels }}
+
Min severity: {{ alerts_min_severity }}
+
Dedup window: {{ alerts_dedup_seconds }}s
+
Last result: {{ alerts_last_result }}
+
Last attempted: {{ alerts_last_attempted_at }}
+
Last success: {{ alerts_last_success_at }}
+
Last event: {{ alerts_last_event_title }}
+
Last error: {{ alerts_last_error }}
+ {% if alerts_last_channel_results %} {% for item in + alerts_last_channel_results %} +
{{ item }}
+ {% endfor %} {% endif %} +
+
+ +
+
+
Execution Controls
+
+
+ +
+
+ +
+
+ + +
+
+
+
+
Edit Config
+
+ + + + + + + + + +
+
+
+
diff --git a/src/arbitrade/web/templates/partials/metrics.html b/src/arbitrade/web/templates/partials/metrics.html new file mode 100644 index 0000000..1748e29 --- /dev/null +++ b/src/arbitrade/web/templates/partials/metrics.html @@ -0,0 +1,31 @@ +
+
+
+
Realized P&L
+
{{ realized_pnl }}
+
+
+
Win Rate
+
{{ win_rate }}
+
+
+
Avg Trade Duration
+
{{ avg_trade_duration }}
+
+
+
Opportunities / Min
+
{{ opportunities_per_minute }}
+
+
+
Fill Rate
+
{{ fill_rate }}
+
+
+
Latency p50 / p95 / p99
+
+ {{ latency_p50 }} | {{ latency_p95 }} | {{ latency_p99 }} +
+
+
+
Updated {{ generated_at }}
+
diff --git a/src/arbitrade/web/templates/partials/overview.html b/src/arbitrade/web/templates/partials/overview.html new file mode 100644 index 0000000..6787b51 --- /dev/null +++ b/src/arbitrade/web/templates/partials/overview.html @@ -0,0 +1,67 @@ +
+
+
+
Status
+
{{ status }}
+
+
+
Balances
+
{{ balances }}
+
+
+
Open Trades
+
{{ open_trade_count }}
+
+
+
Realized P&L
+
{{ realized_pnl_total }}
+
+
+ +
+
+
Open Trades
+
    + {% for trade in open_trades %} +
  • + {{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{ + trade.started_at }} +
  • + {% else %} +
  • No open trades.
  • + {% endfor %} +
+
+
+
Balances Snapshot
+
+ {{ balances }} +
+
Total value {{ total_value }}
+
+
+
Opportunity Feed
+
    + {% for opp in opportunities %} +
  • + {{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{ + opp.detected_at }} +
  • + {% else %} +
  • No opportunities.
  • + {% endfor %} +
+
+
+ +
Updated {{ generated_at }}
+
diff --git a/tests/unit/test_template_resolution.py b/tests/unit/test_template_resolution.py new file mode 100644 index 0000000..26cca3f --- /dev/null +++ b/tests/unit/test_template_resolution.py @@ -0,0 +1,19 @@ +from importlib import resources +from pathlib import Path + +from arbitrade.api import routes + + +def test_template_directory_resolves_to_existing_location() -> None: + template_dir = Path(routes._resolve_templates_directory()) + + assert template_dir.is_dir() + assert (template_dir / "dashboard.html").is_file() + + +def test_template_exists_in_package_resources() -> None: + template_path = resources.files("arbitrade").joinpath( + "web", "templates", "dashboard.html" + ) + + assert template_path.is_file()