Compare commits

..

48 Commits

Author SHA1 Message Date
zwitschi e4f5d8dfcc refactor: clean up imports and improve code formatting across multiple files
CI / lint-test-build (push) Successful in 2m21s
2026-06-09 10:02:41 +02:00
zwitschi 403daa6cf1 Refactor log aggregation periods and improve code formatting
- Removed the "3h" and "6h" periods from the log aggregation process in maintenance.py to streamline log counts.
- Enhanced code readability by adjusting line breaks and indentation in repositories.py for better clarity.
2026-06-09 09:41:23 +02:00
zwitschi dc99f1604e Refactor code for improved readability and consistency
CI / lint-test-build (push) Successful in 54s
- Cleaned up multiline statements and removed unnecessary line breaks in various files.
- Ensured consistent formatting in function definitions and calls across the codebase.
- Updated docstrings and comments for clarity where applicable.
- Removed trailing newlines in module docstrings.
- Enhanced logging statements for better clarity in maintenance tasks.
2026-06-07 21:59:09 +02:00
zwitschi f221464daa feat: enhance backtesting panel with flash messages and pairing checks
CI / lint-test-build (push) Failing after 12s
2026-06-07 21:51:09 +02:00
zwitschi 5e7732b85f feat: add flash message support to configuration panel and improve layout
CI / lint-test-build (push) Successful in 52s
2026-06-07 19:57:42 +02:00
zwitschi 77dfb08b23 feat: update package data inclusion to add dashboard templates and reorder partials
CI / lint-test-build (push) Successful in 1m6s
2026-06-07 19:35:05 +02:00
zwitschi 9acabddb7e feat: include config HTML templates in package data
CI / lint-test-build (push) Successful in 2m23s
2026-06-07 18:46:12 +02:00
zwitschi 2fbc78f7a9 feat: add logging package with DB sink and maintenance tasks
CI / lint-test-build (push) Successful in 2m24s
2026-06-07 18:31:27 +02:00
zwitschi f58634d438 feat: add storage schema SQL file to package data inclusion
CI / lint-test-build (push) Successful in 2m31s
2026-06-07 18:29:16 +02:00
zwitschi e44876c7c7 refactor: clean up imports and improve code formatting in various modules
CI / lint-test-build (push) Successful in 49s
2026-06-07 18:17:45 +02:00
zwitschi 1e4086a0fd feat: add logging routes and update health page to display system logs
CI / lint-test-build (push) Failing after 12s
2026-06-07 18:10:50 +02:00
zwitschi cf5ff2e2d8 feat: implement logging system with aggregation and archiving tasks 2026-06-07 18:06:35 +02:00
zwitschi db2e02c316 feat: add pairings management page and integrate with Kraken API for syncing
feat: create configuration templates for alerts, Kraken settings, risk limits, and runtime settings
refactor: streamline config form by including separate template files for better organization
2026-06-07 17:44:26 +02:00
zwitschi c1dda187af refactor: migrate database schema from TIMESTAMP to TIMESTAMPTZ for better timezone handling
CI / lint-test-build (push) Successful in 1m25s
2026-06-07 15:20:27 +02:00
zwitschi af0ac94a12 refactor: update tests to use async mocks and improve readability
CI / lint-test-build (push) Failing after 12s
2026-06-07 15:05:42 +02:00
zwitschi ef22e217c7 feat: update environment configuration and improve repository handling
CI / lint-test-build (push) Failing after 11s
- Added PG_PASSWORD to .env.example for database connection.
- Removed unnecessary imports and streamlined code in various modules.
- Enhanced error handling in ConfigSettingRepository and ConfigPairingRepository.
- Updated test files to remove unused imports and improve clarity.
2026-06-07 14:50:55 +02:00
zwitschi 529ff967cc Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s
- Implemented integration tests for the execution writer to ensure trade orders and PnL are persisted correctly.
- Created integration tests for the metrics calculator to summarize execution data accurately.
- Added integration tests for the opportunity writer to verify event persistence.
- Established PostgreSQL schema validation tests to ensure all expected tables, columns, and constraints exist.
- Removed outdated unit tests that relied on DuckDB and replaced them with tests using PgStore.
2026-06-07 14:37:53 +02:00
zwitschi 54feb2ecd4 fix: remove unnecessary duckdb file from .gitignore and add the database file
CI / lint-test-build (push) Successful in 2m36s
2026-06-05 10:17:16 +02:00
zwitschi df2f4f3246 fix: update type hint for _build_section_from_row to support tuple with Any
CI / lint-test-build (push) Successful in 1m6s
2026-06-04 22:30:38 +02:00
zwitschi 8cfd969dae fix: update ws_client type hint to allow None for better clarity 2026-06-04 22:29:24 +02:00
zwitschi 3f4b9a4012 refactor: streamline WebSocket connection logging and improve data handling in repositories 2026-06-04 22:28:57 +02:00
zwitschi 4c59a0e4cb feat: Implement pairing synchronization from Kraken and enhance market data feed
- Added `sync_pairings_from_kraken` function to fetch and upsert asset pairs into the config_pairings table.
- Introduced `run_pairing_sync_loop` for periodic synchronization of pairings.
- Enhanced `KrakenWsClient` to manage subscribed symbols for market data feeds.
- Created `build_detector_from_enabled_pairings` to initialize cycle detection based on enabled pairings.
- Updated FastAPI app to start market data feed and pairing synchronization tasks.
- Added new API routes for managing pairings, including listing, toggling, and syncing from Kraken.
- Improved dashboard templates to display pairing options and allow user interaction for backtesting.
- Refactored database queries to streamline fetching and updating of pairing data.
2026-06-04 22:10:06 +02:00
zwitschi 92b0b49535 refactor: remove unnecessary COPY command for web directory in Dockerfile
CI / lint-test-build (push) Successful in 2m37s
2026-06-04 20:56:03 +02:00
zwitschi 44da9220d6 refactor: improve code formatting and readability in test files
CI / lint-test-build (push) Failing after 1m13s
2026-06-04 20:42:58 +02:00
zwitschi df59f5ad7c feat: enhance mock database store with context manager support and improve cursor behavior
CI / lint-test-build (push) Failing after 52s
2026-06-04 20:28:06 +02:00
zwitschi 170f59eb89 refactor: improve SQL query formatting and enhance readability across multiple files
CI / lint-test-build (push) Failing after 56s
2026-06-04 20:16:58 +02:00
zwitschi 8ceca2a7e4 feat: improve SQL query formatting and add type hints for better clarity
CI / lint-test-build (push) Failing after 53s
2026-06-04 19:53:32 +02:00
zwitschi c8e3daeb57 Refactor code for improved readability and consistency
CI / lint-test-build (push) Failing after 12s
- Consolidated multiline string formatting into single-line for SQL queries in multiple files.
- Adjusted argument formatting in function calls for better alignment and readability.
- Removed unnecessary line breaks and improved spacing in various sections of the codebase.
- Updated test cases to maintain consistency in formatting and improve clarity.
2026-06-04 19:04:30 +02:00
zwitschi 7d18bdf316 feat: add 'dist' directory to .dockerignore and .gitignore for build artifact exclusion 2026-06-04 19:04:18 +02:00
zwitschi 83f2064fa9 feat: remove obsolete wheel distribution file 2026-06-04 19:03:40 +02:00
zwitschi 7728f9a8cd feat: enhance backtesting functionality with database integration and UI updates
CI / lint-test-build (push) Failing after 1m20s
2026-06-04 18:39:17 +02:00
zwitschi a83d231d06 feat: refactor health endpoint handling and improve job ID display in backtesting 2026-06-04 17:59:41 +02:00
zwitschi 1c2558cfb3 feat: refactor fee management by removing deprecated pair fee handling and updating dashboard to display equity 2026-06-04 17:48:41 +02:00
zwitschi a0366f06ff feat: add health check endpoint and refactor templates for consistent header usage 2026-06-04 17:13:40 +02:00
zwitschi 86d1046862 feat: update documentation structure and add deployment guide 2026-06-04 15:55:07 +02:00
zwitschi 6acd6bbbc9 feat: implement backtesting job management with database integration and UI updates 2026-06-03 21:34:19 +02:00
zwitschi ff71fc5feb feat: enhance fee management with API integration and audit trail support 2026-06-03 19:27:32 +02:00
zwitschi 587c9afc3b feat: implement fee synchronization and dashboard updates for Kraken account fees 2026-06-03 18:59:39 +02:00
zwitschi 5f2f968721 feat: update .gitignore and .dockerignore to include build artifacts 2026-06-03 18:48:11 +02:00
zwitschi 87dd655f08 feat: add pair fees configuration panel and related functionality
- Introduced a new HTML template for configuring pair fees, allowing users to add, edit, and delete fees for trading pairs.
- Implemented a responsive fee table displaying existing fees with options for editing and deleting.
- Added a form for adding new fees, including fields for base asset, quote asset, market type, and fee rates.
- Removed outdated templates related to backtesting, dashboard, health, and various partials to streamline the codebase.
- Ensured the new fee configuration panel integrates seamlessly with existing endpoints and uses htmx for dynamic updates.
2026-06-03 18:43:36 +02:00
zwitschi ccca9ef62a feat: add dashboard configuration management endpoints and services for pairing and fee management 2026-06-03 18:30:31 +02:00
zwitschi 57df3a4361 remove build dir 2026-06-03 18:30:08 +02:00
zwitschi 00bd2d664d feat: implement comprehensive configuration management system with web interface and database support
CI / lint-test-build (push) Failing after 1m18s
2026-06-02 17:23:58 +02:00
zwitschi 815284289e feat: add configuration management UI for fees, pairings, and application settings 2026-06-02 16:07:30 +02:00
zwitschi 107595826a refactor: update ConfigurationService to avoid circular imports and streamline repository usage 2026-06-02 15:33:14 +02:00
zwitschi 6b5973a0bb feat: implement configuration management with database support 2026-06-02 15:11:56 +02:00
zwitschi 1b21f2443a fix: remove package template smoke check from CI workflow
CI / lint-test-build (push) Successful in 1m4s
2026-06-02 14:30:43 +02:00
zwitschi 1df4b11aef Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities.
- Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity.
- Updated the Jinja2 template resolution logic to support different deployment environments.
- Added a health check template to display the service status.
- Included a test suite to verify the template resolution logic.
- Updated `pyproject.toml` to include new HTML templates in the package data.
2026-06-02 14:16:42 +02:00
95 changed files with 7545 additions and 1979 deletions
+2
View File
@@ -5,7 +5,9 @@ __pycache__
.pytest_cache .pytest_cache
.mypy_cache .mypy_cache
.ruff_cache .ruff_cache
build
data data
dist
logs logs
*.pyc *.pyc
.env .env
+20
View File
@@ -0,0 +1,20 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
max_line_length = 120
+5
View File
@@ -1,6 +1,11 @@
APP_ENV=dev APP_ENV=dev
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_JSON=true LOG_JSON=true
ALERTS_ENABLED=true ALERTS_ENABLED=true
+5 -1
View File
@@ -31,10 +31,14 @@ Thumbs.db
!.env.example !.env.example
secrets/ secrets/
# Local build artifacts
build/
dist/
# Local database / runtime data # Local database / runtime data
data/*.duckdb
data/*.duckdb.wal data/*.duckdb.wal
data/*.duckdb.tmp data/*.duckdb.tmp
data/arbitrade.duckdb
logs/ logs/
ops/performance/latest_profile.json ops/performance/latest_profile.json
-1
View File
@@ -12,7 +12,6 @@ RUN pip install --no-cache-dir -r requirements/latest-runtime.in
COPY pyproject.toml README.md /app/ COPY pyproject.toml README.md /app/
COPY src /app/src COPY src /app/src
COPY web /app/web
RUN pip install --no-cache-dir --no-deps . RUN pip install --no-cache-dir --no-deps .
+69 -13
View File
@@ -6,12 +6,12 @@ Current stack:
- Python 3.12+ - Python 3.12+
- FastAPI + HTMX/Jinja2 - FastAPI + HTMX/Jinja2
- DuckDB for dev/test/prod - PostgreSQL for all environments (via asyncpg)
- Native Kraken WebSocket planned for market-data hot path - Native Kraken WebSocket planned for market-data hot path
- Gitea Actions + Gitea container registry - Gitea Actions + Gitea container registry
Project plan lives in [PLAN.md](PLAN.md).
Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md). Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md).
Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
## Current Status ## Current Status
@@ -21,7 +21,7 @@ Bootstrap complete for foundation layer:
- typed settings and env loading - typed settings and env loading
- structured logging - structured logging
- encrypted secret helpers - encrypted secret helpers
- DuckDB connection + base schema - PostgreSQL connection + full schema migration
- FastAPI app with health endpoint - FastAPI app with health endpoint
- Gitea Actions CI scaffold - Gitea Actions CI scaffold
- Docker / docker-compose scaffold - Docker / docker-compose scaffold
@@ -34,6 +34,50 @@ Not implemented yet:
- trade execution - trade execution
- dashboard beyond health/bootstrap page - dashboard beyond health/bootstrap page
## Configuration Management
The arbitrage trading bot now includes a complete configuration management system that allows users to configure trading behavior, currency pairings, fees, and other application settings through a web interface. All user-configurable settings are persisted in the database while system variables remain in environment variables as per the settings split plan.
Key features include:
- Web-based configuration interface at `/dashboard/config/`
- Runtime hot-reloading of configuration changes
- Complete CRUD operations for all configuration entities
- Input validation and error handling
- Audit logging for all configuration changes
- Backtesting parameter configuration
- Fee configuration by pairing and market type
## Templates
Full page templates (`src/arbitrade/web/templates/`):
| Template | Route | Purpose |
| ------------------ | ------------------------ | ------------------------------------------------------- |
| `base.html` | — (root layout) | Dark theme, `.shell` container, HTMX, CSS variables |
| `dashboard.html` | `/`, `/dashboard` | Main dashboard: metrics, overview, controls, charts |
| `config.html` | `/dashboard/config` | Full configuration: fees, runtime, alerts, Kraken, risk |
| `audit.html` | `/dashboard/audit` | Audit trail with auto-refresh via HTMX |
| `backtesting.html` | `/dashboard/backtesting` | Backtesting panel with replay/sweep forms |
| `health.html` | `/health` | System health check |
Dashboard partials (`src/arbitrade/web/templates/partials/`):
| Partial | In page | Content |
| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------- |
| `metrics.html` | Dashboard | 6 KPI cards: P&L, win rate, avg duration, trade count, success %, profit factor |
| `overview.html` | Dashboard | Status, balances, fee tier, open trades list, opportunity feed |
| `controls.html` | Dashboard | Runtime status, kill switch, config snapshot, alerting status, execution controls (Start/Stop/Kill) |
| `charts.html` | Dashboard | Opportunity trend chart (Chart.js, Alpine toggle) |
| `config.html` | Config page | Config form: Runtime, Alerts, Kraken, Risk, Strategy sections |
| `config_fees.html` | Config page | Pair fee table + add/edit form |
| `backtesting_panel.html` | Backtesting page | Run status, replay/sweep forms, recent runs |
| `audit.html` | Audit page | Audit trail table: time, actor, event, decision, payload |
Legacy templates (`src/arbitrade/web/templates/dashboard/`):
- `config_settings.html`, `config_pairs.html`, `config_fees.html` — superseded by config page; retained for reference
## Prerequisites ## Prerequisites
- Python 3.12+ - Python 3.12+
@@ -107,7 +151,11 @@ APP_HOST=0.0.0.0
APP_PORT=9090 APP_PORT=9090
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_JSON=true LOG_JSON=true
DUCKDB_PATH=./data/arbitrade.duckdb PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
FERNET_KEY= FERNET_KEY=
KRAKEN_API_KEY= KRAKEN_API_KEY=
KRAKEN_API_SECRET= KRAKEN_API_SECRET=
@@ -137,15 +185,19 @@ Health endpoints:
## Database ## Database
DuckDB used everywhere: local dev, tests, production. PostgreSQL used everywhere: local dev, tests, production.
Default database file: Default connection:
```text ```text
./data/arbitrade.duckdb PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
``` ```
Schema bootstrap runs automatically on app startup. Schema bootstrap runs automatically on app startup via `PgStore.migrate()`.
Current tables: Current tables:
@@ -175,7 +227,7 @@ DELETE FROM audit_events
WHERE occurred_at < NOW() - INTERVAL 30 DAY; WHERE occurred_at < NOW() - INTERVAL 30 DAY;
``` ```
- Back up archive files and the main DuckDB file together. - Back up archive files and the PostgreSQL database together.
- For production, run archive + backup as scheduled maintenance (cron/task scheduler). - For production, run archive + backup as scheduled maintenance (cron/task scheduler).
## Quality Checks ## Quality Checks
@@ -297,7 +349,7 @@ Add a persistent volume in Coolify:
- Mount Path: `/app/data` - Mount Path: `/app/data`
This preserves DuckDB and other runtime artifacts across restarts/redeploys. This preserves PostgreSQL data and other runtime artifacts across restarts/redeploys.
### 5) Configure environment variables ### 5) Configure environment variables
@@ -306,7 +358,11 @@ Add runtime environment variables in Coolify (UI: Environment Variables):
- `APP_ENV=prod` - `APP_ENV=prod`
- `APP_HOST=0.0.0.0` - `APP_HOST=0.0.0.0`
- `APP_PORT=9090` - `APP_PORT=9090`
- `DUCKDB_PATH=/app/data/arbitrade.duckdb` - `PG_HOST=postgres`
`PG_PORT=5432`
`PG_DATABASE=arbitrade`
`PG_USER=arbitrade`
`PG_PASSWORD=arbitrade`
- `LOG_LEVEL=INFO` - `LOG_LEVEL=INFO`
- `LOG_JSON=true` - `LOG_JSON=true`
- `KRAKEN_API_KEY=...` - `KRAKEN_API_KEY=...`
@@ -351,9 +407,9 @@ git.allucanget.biz/allucanget/arbitrade:latest
## Architecture Docs ## Architecture Docs
Implementation detail moved into arc42 docs: Implementation detail moved into docs:
- [arc42 overview](docs/architecture/arc42.md) - system context, building blocks, runtime, deployment, quality goals, risks. - [architecture overview](docs/architecture/README.md) - system context, building blocks, runtime, deployment, quality goals, risks.
- [current implementation snapshot](docs/architecture/current-implementation.md) - codebase state, active routes, backtesting, strategy flags, deployment flow. - [current implementation snapshot](docs/architecture/current-implementation.md) - codebase state, active routes, backtesting, strategy flags, deployment flow.
For navigation from README, use the docs above instead of this file for deep architecture detail. For navigation from README, use the docs above instead of this file for deep architecture detail.
+142
View File
@@ -0,0 +1,142 @@
# 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 PG connection: `PG_HOST=postgres`, `PG_PORT=5432`, `PG_DATABASE=arbitrade`, `PG_USER=arbitrade`, `PG_PASSWORD=arbitrade`
### Required environment variables
- `APP_ENV=prod`
- `APP_HOST=0.0.0.0`
- `APP_PORT=9090`
- `PG_DATABASE=arbitrade`
- `LOG_LEVEL=INFO`
- `LOG_JSON=true`
- `KRAKEN_API_KEY=<set-in-coolify-secret>`
- `KRAKEN_API_SECRET=<set-in-coolify-secret>`
- `KRAKEN_API_KEY_PERMISSIONS=query,trade`
- `FERNET_KEY=<set-in-coolify-secret>`
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 PostgreSQL is reachable at `PG_HOST`.
- 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.
@@ -8,7 +8,7 @@ Primary goals:
- Detect and execute triangular opportunities on Kraken with fee/slippage-aware math. - Detect and execute triangular opportunities on Kraken with fee/slippage-aware math.
- Keep hot-path latency low with incremental order-book updates and event-driven scoring. - Keep hot-path latency low with incremental order-book updates and event-driven scoring.
- Persist operational data in DuckDB for dev, test, and prod. - Persist operational data in PostgreSQL for all environments.
- Provide operator controls, audit trail, and alerting through a server-rendered dashboard. - Provide operator controls, audit trail, and alerting through a server-rendered dashboard.
- Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags. - Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags.
@@ -17,7 +17,7 @@ Primary goals:
- Python 3.12+ runtime. - Python 3.12+ runtime.
- Native Kraken WebSocket on the hot path. - Native Kraken WebSocket on the hot path.
- HTMX + Jinja2 UI, no SPA build step. - HTMX + Jinja2 UI, no SPA build step.
- DuckDB everywhere. - PostgreSQL everywhere.
- Self-hosted Gitea Actions CI and Gitea registry. - Self-hosted Gitea Actions CI and Gitea registry.
- Windows development support. - Windows development support.
- Secrets must stay out of the repository. - Secrets must stay out of the repository.
@@ -32,7 +32,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- Kraken REST + WebSocket provide market data and execution. - Kraken REST + WebSocket provide market data and execution.
- FastAPI serves HTML fragments, JSON endpoints, and SSE streams. - FastAPI serves HTML fragments, JSON endpoints, and SSE streams.
- DuckDB stores trades, opportunities, snapshots, audit events, and runtime state. - PostgreSQL stores trades, opportunities, snapshots, audit events, and runtime state.
- Coolify can deploy the published image using environment variables and persistent storage. - Coolify can deploy the published image using environment variables and persistent storage.
## 4. Solution Strategy ## 4. Solution Strategy
@@ -53,9 +53,9 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- `detection/` - triangular graph and incremental detector. - `detection/` - triangular graph and incremental detector.
- `risk/` - pre-trade and trade-limit guards. - `risk/` - pre-trade and trade-limit guards.
- `execution/` - multi-leg trade sequencing. - `execution/` - multi-leg trade sequencing.
- `backtesting/` - replay engine, parameter sweep, experiment scaffolds. - `backtesting/` - replay engine, parameter sweep, experiment scaffolds. See [backtesting.md](backtesting.md).
- `strategy/` - experimental strategy modules such as stat-arb. - `strategy/` - experimental strategy modules such as stat-arb.
- `storage/` - DuckDB schema and repositories. - `storage/` - PostgreSQL schema and repositories.
- `alerting/` - multi-channel notifications. - `alerting/` - multi-channel notifications.
- `runtime/` - startup recovery and graceful shutdown. - `runtime/` - startup recovery and graceful shutdown.
@@ -64,7 +64,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates. - `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates.
- `orjson` for low-alloc parsing. - `orjson` for low-alloc parsing.
- `sortedcontainers` for book state. - `sortedcontainers` for book state.
- `duckdb` for persistence and analytics. - `asyncpg` for PostgreSQL persistence.
- `pydantic` / `pydantic-settings` for typed configuration. - `pydantic` / `pydantic-settings` for typed configuration.
- `cryptography` / keyring for secret handling. - `cryptography` / keyring for secret handling.
@@ -77,7 +77,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
3. Incremental detector scores impacted cycles. 3. Incremental detector scores impacted cycles.
4. Risk manager validates the opportunity. 4. Risk manager validates the opportunity.
5. Execution sequencer places legs if approved. 5. Execution sequencer places legs if approved.
6. Trades and snapshots persist to DuckDB. 6. Trades and snapshots persist to PostgreSQL.
7. Dashboard and alerts reflect state changes. 7. Dashboard and alerts reflect state changes.
### 6.2 Dashboard Control Flow ### 6.2 Dashboard Control Flow
@@ -89,11 +89,14 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
### 6.3 Backtesting Flow ### 6.3 Backtesting Flow
1. User selects JSONL replay file and run parameters. See [backtesting.md](backtesting.md) for full design and implementation details.
2. Replay engine loads ordered book events.
3. Detector, risk, and execution logic run in simulation mode. 1. User picks currency pairs (from config/pairings page, or all enabled).
4. Report is stored in memory for recent UI display. 2. User sets starting balances (required), time range (required), min profit threshold (required).
5. Parameter sweeps split data into train/test windows, rank results, and flag overfit. 3. Fee profile defaults to "api (from Kraken)"; slippage (4.0 bps) and execution latency (20 ms) are optional with sensible defaults.
4. Job is queued via `POST /dashboard/backtesting/run`.
5. Backend loads events from `market_snapshots` table, builds triangular cycles, runs replay engine.
6. Report stored in `backtest_jobs` table, visible in recent jobs list.
## 7. Deployment View ## 7. Deployment View
@@ -112,7 +115,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- Deploy from the published image. - Deploy from the published image.
- Configure runtime via environment variables. - Configure runtime via environment variables.
- Mount persistent storage at `/app/data` for DuckDB. - Connect to PostgreSQL at configured `PG_HOST`.
## 8. Cross-Cutting Concepts ## 8. Cross-Cutting Concepts
@@ -126,7 +129,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
## 9. Architecture Decisions ## 9. Architecture Decisions
- Native Kraken WS instead of a generic exchange abstraction on the hot path. - Native Kraken WS instead of a generic exchange abstraction on the hot path.
- DuckDB as the single database engine. - PostgreSQL as the single database engine.
- HTMX + Jinja2 instead of SPA frontend. - HTMX + Jinja2 instead of SPA frontend.
- Backtesting reuses production detector/risk/execution logic. - Backtesting reuses production detector/risk/execution logic.
- Experimental stat-arb stays behind a feature flag. - Experimental stat-arb stays behind a feature flag.
@@ -152,5 +155,5 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- WS: WebSocket. - WS: WebSocket.
- HTMX: HTML-over-the-wire UI library. - HTMX: HTML-over-the-wire UI library.
- SSE: Server-Sent Events. - SSE: Server-Sent Events.
- DUCKDB: Embedded analytical database used for all environments. - PGSQL: PostgreSQL database used for all environments.
- Stat arb: Statistical arbitrage, currently experimental and feature-flagged. - Stat arb: Statistical arbitrage, currently experimental and feature-flagged.
+130
View File
@@ -0,0 +1,130 @@
# Backtesting Architecture
> Detailed design and implementation of the backtesting subsystem.
> See [`README.md`](README.md#63-backtesting-flow) for the high-level user flow.
## Data Flow
```txt
market_snapshots (DB) ─┐
├──→ load_replay_events_from_db() ──→ list[ReplayBookEvent]
JSONL file ─────────────┘
BacktestReplayEngine.run()
BacktestReport
BacktestJobRepository.store_report()
```
Two event sources:
- **DB mode** (default) — loads snapshots from `market_snapshots` table. Supports symbol/time filtering.
- **File mode** — reads JSONL files from disk (legacy, used by `backtest_replay.py` script).
## Core Types
### `ReplayClock`
Timekeeper for simulation. Ensures events advance monotonically. Supports `advance_ms()` to model execution latency.
### `ReplayBookEvent`
One atomic book state at a point in time. Fields: `occurred_at`, `symbol`, `bids: tuple[BookLevel]`, `asks: tuple[BookLevel]`.
### `BacktestConfig`
| Field | Default | Description |
| ------------------------ | -------- | ----------------------------------------------------- |
| `fee_rate` | `0.0` | 0.0 → API-sourced fee from `kraken_account_snapshots` |
| `min_profit_threshold` | `0.0005` | Minimum net profit to attempt trade |
| `trade_capital` | `100.0` | Capital allocated per trade |
| `quote_asset` | `"USD"` | Base currency for P&L |
| `slippage_bps` | `4.0` | Simulated slippage in basis points |
| `execution_latency_ms` | `20.0` | Simulated latency per leg |
| `max_depth_levels` | `10` | Order book depth for detection |
| `max_concurrent_trades` | `1` | Max simultaneous trades |
| `min_order_size_by_pair` | `None` | Per-pair min order size overrides |
### `BacktestReport`
| Field | Type | Description |
| -------------------------------- | -------------- | ---------------------------------- |
| `started_at` / `finished_at` | datetime | Simulation window |
| `processed_events` | int | Events consumed |
| `opportunities_seen` | int | Detected opportunities |
| `trades_executed` | int | Successful trades |
| `win_rate` | float or None | Fraction of profitable trades |
| `fill_rate` | float or None | Average fill ratio |
| `realized_pnl_usd` | float | Net P&L after slippage |
| `max_drawdown_usd` | float | Peak-to-trough equity drop |
| `miss_reasons` | dict[str, int] | Counters for skipped opportunities |
| `execution_latency_p50/95/99_ms` | float or None | Latency percentiles |
## Simulation Client
`_SimulatedRestClient` replaces the real Kraken REST client during backtesting.
- **Slippage model:** `fill_ratio = max(0.85, 1.0 - (slippage_bps / 10000.0) * 8.0)`
- **Latency model:** Clock advances by `execution_latency_ms` before each simulated fill
- Orders always fill (status = `"closed"`) at the modeled ratio
## Job Worker
`backtest_worker` is an `asyncio.Task` started in `create_app()` lifespan:
```python
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db),
name="backtest_worker",
)
```
Workflow per job:
1. Dequeue `(job_id, config_dict)` from `asyncio.Queue`
2. Update status → `"running"` in `backtest_jobs` table
3. Load events (DB or file)
4. Build currency graph → triangular cycles
5. Instantiate `BacktestReplayEngine``engine.run()`
6. Store report → update status → `"completed"` (or `"failed"` on exception)
## Sweep Pipeline
`run_parameter_search` performs grid search over backtest parameters:
1. **Split** events into train/test windows by time ratio
2. **Build grid** — cartesian product of `theta_values × trade_capital_values × pair_universes × staleness_threshold_values`
3. **For each parameter set:**
- Filter events to pair universe + apply staleness gate
- Build cycles restricted to pair universe
- Run engine on train window → `train_report`
- Run engine on test window → `test_report`
- Score = `realized_pnl + win_rate_bonus + fill_rate_bonus - max_drawdown`
- Compute generalization gap = `|train_score - test_score| / max(train_score, test_score)`
4. **Evaluate promotion:**
- `PromotionCriteria` checks: min test P&L, min win rate ≥ 0.5, min fill rate ≥ 0.9, max drawdown ≤ $25, generalization gap ≤ 0.5
- Results passing all criteria are flagged `promotion_ready`
## UI
> See `backtesting.html` → `partials/backtesting_panel.html`.
- **Shell page** loads the panel via `hx-get="/dashboard/fragment/backtesting"`
- **Run form** — starting balances, time range, profit threshold (required); fee profile, slippage, latency (advanced/collapsible)
- **Status card** — current job status + message
- **Recent jobs table** — lists last 20 jobs with status, events, trades, P&L; each row has a detail button
- **Job detail** — `GET /dashboard/backtesting/job/{id}` returns report HTML
Pairings are managed on the `/dashboard/config/pairings` page. Backtest uses DB-enabled pairings by default when no symbols are specified.
## Source Files
| File | Role |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backtesting/replay.py` | `ReplayClock`, `ReplayBookEvent`, `BacktestConfig`, `BacktestReport`, `_SimulatedRestClient`, `BacktestReplayEngine`, `load_replay_events`, `load_replay_events_from_db` |
| `backtesting/runner.py` | `run_backtest_job`, `backtest_worker`, `_build_cycles_from_events`, `_parse_balances` |
| `backtesting/sweep.py` | `SweepParameters`, `SweepResult`, `SweepArtifacts`, `PromotionCriteria`, `split_events_time_windows`, `build_parameter_grid`, `run_parameter_search`, `persist_sweep_results` |
@@ -9,6 +9,20 @@ This document summarizes the code that exists now, not the original plan.
- DuckDB is initialized and migrated on startup. - DuckDB is initialized and migrated on startup.
- Runtime recovery persists and restores control state and snapshots. - Runtime recovery persists and restores control state and snapshots.
## Configuration Management
- Complete configuration management system implemented with database-backed user settings.
- Configuration service in [src/arbitrade/config/service.py](../../src/arbitrade/config/service.py) handles loading and applying settings.
- Repository classes in [src/arbitrade/storage/repositories.py](../../src/arbitrade/storage/repositories.py) provide database access.
- Web UI for configuration at `/dashboard/config/` with CRUD operations for:
- Currency pairings
- Fee configurations
- Application settings
- Backtesting parameters
- Hot-reloading capabilities for runtime configuration changes.
- Input validation and error handling for all configuration forms.
- Audit logging for all configuration modifications.
## Market Data and Detection ## Market Data and Detection
- Kraken market data is handled by native WS and thin REST code. - Kraken market data is handled by native WS and thin REST code.
+88
View File
@@ -0,0 +1,88 @@
# Database Layer: Schema & Repositories
> **Database engine**: PostgreSQL 15+ on `192.168.88.35`
> **Driver**: `asyncpg` (async connection pool)
> **Store class**: `PgStore` in `src/arbitrade/storage/pg_store.py`
## Connection Lifecycle
```txt
FastAPI lifespan (create_app)
└─ PgStore.start() # creates asyncpg connection pool
└─ PgStore.migrate() # reads schema_pg.sql, creates tables
└─ ... application runs ...
└─ PgStore.stop() # closes the pool
```
All repository classes accept a `PgStore` instance and acquire connections
via `async with self._store.pool.acquire() as conn:`.
## Schema
Defined in `src/arbitrade/storage/schema_pg.sql`. 15 tables:
| Table | Purpose | PK | Notes |
| ----------------------------- | -------------------------- | --------------- | ---------------------------------------- |
| `schema_migrations` | Version tracking | `version` | Single-row per version |
| `config_sections` | Config section metadata | `id` (SERIAL) | `name` UNIQUE |
| `config_settings` | Key-value config store | `key` (VARCHAR) | JSON-serialized values |
| `config_pairings` | Currency pairs to monitor | `id` (SERIAL) | `(base_asset, quote_asset)` UNIQUE |
| `config_backtesting_defaults` | Default backtest params | `id` (SERIAL) | Singleton via `ORDER BY id DESC LIMIT 1` |
| `opportunities` | Detected arb opportunities | `id` (UUID) | |
| `trades` | Executed trades | `id` (UUID) | |
| `orders` | Individual leg orders | `id` (UUID) | |
| `pnl_events` | P&L event stream | `id` (UUID) | |
| `portfolio_snapshots` | Balance snapshots | — | Append-only |
| `market_snapshots` | Raw order-book snapshots | — | Append-only |
| `audit_events` | Audit trail | `id` (UUID) | |
| `runtime_state_snapshots` | Runtime state history | — | Append-only |
| `kraken_account_snapshots` | Fee tier + account data | — | Append-only |
| `backtest_jobs` | Backtest job records | `id` (UUID) | |
JSON columns use `JSONB` for indexability. UUID primary keys use
`gen_random_uuid()` (requires `pgcrypto` extension).
## Repository Classes
All in `src/arbitrade/storage/repositories.py`. Every method is `async def`.
| Class | Key Methods | Used By |
| ------------------------------------- | ---------------------------------------------------------- | --------------------------- |
| `MarketSnapshotRepository` | `insert()` | `AsyncMarketSnapshotWriter` |
| `OpportunityRepository` | `insert()` | `AsyncOpportunityWriter` |
| `TradeRepository` | `insert()` | `AsyncExecutionWriter` |
| `OrderRepository` | `insert()` | `AsyncExecutionWriter` |
| `PnLRepository` | `insert()` | `AsyncExecutionWriter` |
| `AuditRepository` | `insert()`, `list_recent()` | API routes, lifecycle |
| `RuntimeStateRepository` | `insert()`, `latest()` | Lifecycle, API |
| `ConfigSectionRepository` | `create_section()`, `get_section()`, `list_sections()` | Config service |
| `ConfigSettingRepository` | Full CRUD + `get_latest_updated_at()` | Config service |
| `ConfigPairingRepository` | Full CRUD + `upsert_pairing()`, `list_pairings()` | Feeds, pairing sync |
| `ConfigBacktestingDefaultsRepository` | `create_defaults()`, `get_defaults()`, `update_defaults()` | Config service |
| `KrakenAccountSnapshotRepository` | `insert_snapshot()`, `latest_snapshot()` | Fee sync loop |
| `BacktestJobRepository` | Full CRUD | Backtesting UI + worker |
## Async Writers
Three background writer tasks buffer high-frequency writes:
- **`AsyncExecutionWriter`** — trades/orders/P&L queue
- **`AsyncMarketSnapshotWriter`** — order-book snapshot queue
- **`AsyncOpportunityWriter`** — opportunity event queue
Each uses an `asyncio.Queue` and drains it in a background task with
`await repo.insert(...)`.
## Integration Tests
`tests/integration/test_postgresql_schema.py` verifies:
- Connection to PostgreSQL server
- `pgcrypto` extension availability
- All 15 tables exist after migration
- Migration is idempotent
- Correct columns per table
- Primary keys and unique constraints
- Tables start empty
- Simple INSERT/SELECT round-trip
- `ON CONFLICT ... DO UPDATE` on config_pairings
+1 -1
View File
@@ -39,7 +39,7 @@ Key end-to-end latency baselines from `latency_baseline.json`:
## Optimization Note ## Optimization Note
`MetricsCalculator.compute()` was optimized to use DuckDB SQL aggregations and quantiles, reducing Python-side row scans. `MetricsCalculator.compute()` uses PostgreSQL SQL aggregations and percentiles, reducing Python-side row scans.
Measured benchmark (`scripts/benchmark_metrics_compute.py`): Measured benchmark (`scripts/benchmark_metrics_compute.py`):
+12 -2
View File
@@ -22,6 +22,16 @@ arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
[tool.setuptools] [tool.setuptools]
package-dir = {"" = "src"} package-dir = {"" = "src"}
include-package-data = true
[tool.setuptools.package-data]
arbitrade = [
"web/templates/*.html",
"web/templates/config/*.html",
"web/templates/dashboard/*.html",
"web/templates/partials/*.html",
"storage/schema_pg.sql",
]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -36,7 +46,7 @@ target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "N", "ASYNC"] select = ["E", "F", "I", "UP", "B", "N", "ASYNC"]
ignore = ["E203"] ignore = ["E203", "E501"]
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.12"
@@ -47,7 +57,7 @@ pretty = true
mypy_path = "src" mypy_path = "src"
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["duckdb", "keyring", "sortedcontainers"] module = ["asyncpg", "keyring", "sortedcontainers"]
ignore_missing_imports = true ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
+1
View File
@@ -1,4 +1,5 @@
# Unpinned dev dependencies (latest available) # Unpinned dev dependencies (latest available)
asyncpg-stubs
black black
mypy mypy
pre-commit pre-commit
+1 -1
View File
@@ -1,6 +1,6 @@
# Unpinned runtime dependencies (latest available) # Unpinned runtime dependencies (latest available)
asyncpg
cryptography cryptography
duckdb
fastapi fastapi
httptools httptools
httpx httpx
+38 -17
View File
@@ -6,10 +6,31 @@ from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import duckdb
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
if fee_rate is not None:
return fee_rate
if db_path is not None:
try:
conn = duckdb.connect(db_path)
row = conn.execute("""
SELECT maker_fee FROM kraken_account_snapshots
ORDER BY snapshot_at DESC LIMIT 1
""").fetchone()
conn.close()
if row is not None and row[0] is not None:
return float(row[0])
except Exception:
pass
return 0.0026 # ultimate fallback
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]: def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph() graph = CurrencyGraph()
graph.add_pair("USD", "BTC", "BTC/USD") graph.add_pair("USD", "BTC", "BTC/USD")
@@ -30,19 +51,20 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.") parser = argparse.ArgumentParser(description="Run backtest.")
parser.add_argument("--events", type=Path, required=True) parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0") parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--trade-capital", type=float, default=100.0) parser.add_argument("--trade-capital", type=float, default=100.0)
parser.add_argument("--fee-rate", type=float, default=0.0026) parser.add_argument("--fee-rate", type=float, default=None)
parser.add_argument("--slippage-bps", type=float, default=4.0) parser.add_argument("--slippage-bps", type=float, default=4.0)
parser.add_argument("--execution-latency-ms", type=float, default=20.0) parser.add_argument("--execution-latency-ms", type=float, default=20.0)
args = parser.parse_args() args = parser.parse_args()
cycles_by_pair, available_pairs = _build_graph() cycles_by_pair, available_pairs = _build_graph()
events = load_replay_events(args.events) events = load_replay_events(args.events)
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
config = BacktestConfig( config = BacktestConfig(
fee_rate=args.fee_rate, fee_rate=fee_rate,
trade_capital=args.trade_capital, trade_capital=args.trade_capital,
slippage_bps=args.slippage_bps, slippage_bps=args.slippage_bps,
execution_latency_ms=args.execution_latency_ms, execution_latency_ms=args.execution_latency_ms,
@@ -54,24 +76,23 @@ def main() -> int:
config=config, config=config,
started_at=events[0].occurred_at if events else datetime.now(UTC), started_at=events[0].occurred_at if events else datetime.now(UTC),
) )
report = asyncio.run( starting_balances = _parse_balances(args.starting_balances)
engine.run(events, starting_balances=_parse_balances(args.starting_balances)) r = asyncio.run(engine.run(events, starting_balances=starting_balances))
)
print("Backtest report:") print("Backtest report:")
print(f"- processed_events: {report.processed_events}") print(f"- processed_events: {r.processed_events}")
print(f"- opportunities_seen: {report.opportunities_seen}") print(f"- opportunities_seen: {r.opportunities_seen}")
print(f"- trades_executed: {report.trades_executed}") print(f"- trades_executed: {r.trades_executed}")
print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}") print(f"- win_rate: {r.win_rate if r.win_rate is not None else 'n/a'}")
print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}") print(f"- fill_rate: {r.fill_rate if r.fill_rate is not None else 'n/a'}")
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}") print(f"- realized_pnl_usd: {r.realized_pnl_usd:.4f}")
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}") print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
print(f"- miss_reasons: {dict(report.miss_reasons)}") print(f"- miss_reasons: {dict(r.miss_reasons)}")
print( print(
"- execution_latency_ms: " "- execution_latency_ms: "
f"p50={report.execution_latency_p50_ms or 0.0:.4f}, " f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
f"p95={report.execution_latency_p95_ms or 0.0:.4f}, " f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
f"p99={report.execution_latency_p99_ms or 0.0:.4f}" f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
) )
return 0 return 0
+12 -17
View File
@@ -36,8 +36,7 @@ def _parse_float_list(raw: str) -> list[float]:
def _parse_pair_universes(raw: str) -> list[tuple[str, ...]]: def _parse_pair_universes(raw: str) -> list[tuple[str, ...]]:
universes: list[tuple[str, ...]] = [] universes: list[tuple[str, ...]] = []
for chunk in raw.split(";"): for chunk in raw.split(";"):
symbols = tuple(item.strip().upper() symbols = tuple(item.strip().upper() for item in chunk.split("|") if item.strip())
for item in chunk.split("|") if item.strip())
if symbols: if symbols:
universes.append(symbols) universes.append(symbols)
if not universes: if not universes:
@@ -75,31 +74,29 @@ def _print_top_results(results: Sequence[SweepResult], *, limit: int = 5) -> Non
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run backtesting parameter sweep with train/test split.") description="Run backtesting parameter sweep with train/test split."
)
parser.add_argument("--events", type=Path, required=True) parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0") parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--theta-values", type=str, parser.add_argument("--theta-values", type=str, default="0.0003,0.0005,0.0008")
default="0.0003,0.0005,0.0008") parser.add_argument("--trade-capital-values", type=str, default="50,100,150")
parser.add_argument("--trade-capital-values",
type=str, default="50,100,150")
parser.add_argument( parser.add_argument(
"--pair-universes", "--pair-universes",
type=str, type=str,
default="BTC/USD|ETH/BTC|ETH/USD", default="BTC/USD|ETH/BTC|ETH/USD",
help="Semicolon-separated universes, each with | delimited pairs", help="Semicolon-separated universes, each with | delimited pairs",
) )
parser.add_argument("--staleness-threshold-values", parser.add_argument("--staleness-threshold-values", type=str, default="3,5,8")
type=str, default="3,5,8")
parser.add_argument("--train-ratio", type=float, default=0.7) parser.add_argument("--train-ratio", type=float, default=0.7)
parser.add_argument("--output", type=Path, parser.add_argument(
default=Path("ops/backtesting/parameter_sweep_results.json")) "--output", type=Path, default=Path("ops/backtesting/parameter_sweep_results.json")
)
parser.add_argument("--min-test-realized-pnl-usd", type=float, default=0.0) parser.add_argument("--min-test-realized-pnl-usd", type=float, default=0.0)
parser.add_argument("--min-test-win-rate", type=float, default=0.5) parser.add_argument("--min-test-win-rate", type=float, default=0.5)
parser.add_argument("--min-test-fill-rate", type=float, default=0.9) parser.add_argument("--min-test-fill-rate", type=float, default=0.9)
parser.add_argument("--max-test-drawdown-usd", type=float, default=25.0) parser.add_argument("--max-test-drawdown-usd", type=float, default=25.0)
parser.add_argument("--max-generalization-gap-ratio", parser.add_argument("--max-generalization-gap-ratio", type=float, default=0.5)
type=float, default=0.5)
args = parser.parse_args() args = parser.parse_args()
@@ -107,15 +104,13 @@ def main() -> int:
symbols = sorted({event.symbol.upper() for event in events}) symbols = sorted({event.symbol.upper() for event in events})
cycles_by_pair = _build_graph_from_symbols(symbols) cycles_by_pair = _build_graph_from_symbols(symbols)
if not cycles_by_pair: if not cycles_by_pair:
raise SystemExit( raise SystemExit("No triangular cycles found in supplied replay events")
"No triangular cycles found in supplied replay events")
grid = build_parameter_grid( grid = build_parameter_grid(
theta_values=_parse_float_list(args.theta_values), theta_values=_parse_float_list(args.theta_values),
trade_capital_values=_parse_float_list(args.trade_capital_values), trade_capital_values=_parse_float_list(args.trade_capital_values),
pair_universes=_parse_pair_universes(args.pair_universes), pair_universes=_parse_pair_universes(args.pair_universes),
staleness_threshold_values=_parse_float_list( staleness_threshold_values=_parse_float_list(args.staleness_threshold_values),
args.staleness_threshold_values),
) )
artifacts = run_parameter_search( artifacts = run_parameter_search(
+29 -27
View File
@@ -8,17 +8,19 @@ from time import perf_counter
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float | None]: async def _python_scan_compute(store: PgStore) -> tuple[float, float | None, float | None]:
with store.connect() as conn: sql_s = """
trade_rows = conn.execute(""" SELECT started_at, finished_at, realized_pnl
SELECT started_at, finished_at, realized_pnl FROM trades
FROM trades WHERE finished_at IS NOT NULL
WHERE finished_at IS NOT NULL """
""").fetchall() sql_d = "SELECT detected_at FROM opportunities"
opportunity_rows = conn.execute("SELECT detected_at FROM opportunities").fetchall() async with store.pool.acquire() as conn:
trade_rows = await conn.fetch(sql_s)
orows = await conn.fetch(sql_d)
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None) realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
durations = [ durations = [
@@ -28,10 +30,10 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
] ]
avg_duration = fmean(durations) if durations else None avg_duration = fmean(durations) if durations else None
times = [row[0] for row in opportunity_rows if isinstance(row[0], datetime)] times = [row[0] for row in orows if isinstance(row[0], datetime)]
if len(times) >= 2: if len(times) >= 2:
span_seconds = (max(times) - min(times)).total_seconds() ss = (max(times) - min(times)).total_seconds()
opm = len(times) / (span_seconds / 60.0) if span_seconds > 0.0 else float(len(times)) opm = len(times) / (ss / 60.0) if ss > 0.0 else float(len(times))
elif len(times) == 1: elif len(times) == 1:
opm = 60.0 opm = 60.0
else: else:
@@ -40,7 +42,7 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
return realized, avg_duration, opm return realized, avg_duration, opm
def _seed_dataset(store: DuckDBStore) -> None: async def _seed_dataset(store: PgStore) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
trade_rows: list[tuple[object, ...]] = [] trade_rows: list[tuple[object, ...]] = []
@@ -86,11 +88,11 @@ def _seed_dataset(store: DuckDBStore) -> None:
) )
) )
with store.connect() as conn: async with store.pool.acquire() as conn:
conn.execute("DELETE FROM trades") await conn.execute("DELETE FROM trades")
conn.execute("DELETE FROM opportunities") await conn.execute("DELETE FROM opportunities")
conn.execute("DELETE FROM orders") await conn.execute("DELETE FROM orders")
conn.executemany( await conn.executemany(
""" """
INSERT INTO trades ( INSERT INTO trades (
trade_ref, trade_ref,
@@ -106,7 +108,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
""", """,
trade_rows, trade_rows,
) )
conn.executemany( await conn.executemany(
""" """
INSERT INTO opportunities ( INSERT INTO opportunities (
detected_at, detected_at,
@@ -119,7 +121,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
""", """,
opportunity_rows, opportunity_rows,
) )
conn.executemany( await conn.executemany(
""" """
INSERT INTO orders ( INSERT INTO orders (
trade_ref, trade_ref,
@@ -140,28 +142,28 @@ def _seed_dataset(store: DuckDBStore) -> None:
) )
def main() -> int: async def main() -> int:
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb" db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
settings = Settings(_env_file=None, DUCKDB_PATH=db_path) settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
store = DuckDBStore(settings) store = PgStore(settings)
store.migrate() store.migrate()
_seed_dataset(store) await _seed_dataset(store)
calculator = MetricsCalculator(store) calculator = MetricsCalculator(store)
for _ in range(3): for _ in range(3):
_python_scan_compute(store) await _python_scan_compute(store)
calculator.compute() await calculator.compute()
runs = 20 runs = 20
start = perf_counter() start = perf_counter()
for _ in range(runs): for _ in range(runs):
_python_scan_compute(store) await _python_scan_compute(store)
python_ms = (perf_counter() - start) * 1000.0 / runs python_ms = (perf_counter() - start) * 1000.0 / runs
start = perf_counter() start = perf_counter()
for _ in range(runs): for _ in range(runs):
calculator.compute() await calculator.compute()
sql_ms = (perf_counter() - start) * 1000.0 / runs sql_ms = (perf_counter() - start) * 1000.0 / runs
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0 speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
+163 -4
View File
@@ -1,40 +1,199 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI from fastapi import FastAPI
from arbitrade.alerting.notifier import build_notifier_from_settings from arbitrade.alerting.notifier import build_notifier_from_settings
from arbitrade.api.control_state import DashboardControlState from arbitrade.api.control_state import DashboardControlState
from arbitrade.api.routes import public_router, router from arbitrade.api.routes import public_router, router
from arbitrade.backtesting.runner import backtest_worker
from arbitrade.config.pairing_sync import run_pairing_sync_loop
from arbitrade.config.service import ConfigurationService
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.exchange.fee_service import run_fee_sync_loop
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.exchange.kraken_ws import KrakenWsClient
from arbitrade.logging.db_sink import get_db_sink
from arbitrade.logging.maintenance import run_log_aggregation_loop, run_log_archive_loop
from arbitrade.logging_setup import configure_logging from arbitrade.logging_setup import configure_logging
from arbitrade.market_data.feed import MarketDataFeed
from arbitrade.market_data.feed_builder import (
build_detector_from_enabled_pairings,
get_enabled_pair_symbols,
)
from arbitrade.metrics import MetricsCalculator from arbitrade.metrics import MetricsCalculator
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter
from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
AuditRepository,
MarketSnapshotRepository,
OpportunityRepository,
RuntimeStateRepository,
)
_LOG = structlog.get_logger(__name__)
async def _start_feed(app: FastAPI, *, kill_switch_only: bool = False) -> asyncio.Task[None] | None:
"""Create and start a MarketDataFeed task from enabled pairings.
If kill_switch_only=True, only create a kill-switch-bound stub (no detector/feed).
Returns the task or None if no enabled pairings.
"""
settings = app.state.settings
db = app.state.store
alert_notifier = getattr(app.state, "alert_notifier", None)
controls = app.state.dashboard_controls
# Build detector from enabled pairings
detector = await build_detector_from_enabled_pairings(
db,
fee_rate=0.0, # will be overridden by fee sync
max_depth_levels=controls.strategy_max_depth_levels,
min_profit_threshold=controls.strategy_profit_threshold,
)
symbols = await get_enabled_pair_symbols(db)
if not symbols and not kill_switch_only:
_LOG.warning("no_enabled_pair_symbols_feed_not_started")
return None
ws_client: KrakenWsClient | None = getattr(app.state, "ws_client", None)
if ws_client is None:
ws_client = KrakenWsClient(settings, alert_notifier=alert_notifier)
app.state.ws_client = ws_client
ws_client.set_subscribed_symbols(symbols)
snapshot_writer = AsyncMarketSnapshotWriter(MarketSnapshotRepository(db))
opportunity_writer = AsyncOpportunityWriter(OpportunityRepository(db))
feed = MarketDataFeed(
ws_client=ws_client,
snapshot_writer=snapshot_writer,
detector=detector,
opportunity_writer=opportunity_writer,
paper_trading_mode=settings.paper_trading_mode,
trade_capital=settings.trade_capital_usd,
max_trade_capital=settings.max_trade_capital_usd,
kill_switch=controls.kill_switch,
alert_notifier=alert_notifier,
audit_repository=getattr(app.state, "audit_repository", None),
)
app.state.feed = feed
task = asyncio.create_task(feed.run(), name="market_data_feed")
app.state.feed_task = task
_LOG.info("market_data_feed_started", symbols=symbols)
return task
def create_app(settings: Settings) -> FastAPI: def create_app(settings: Settings) -> FastAPI:
configure_logging(settings.log_level, settings.log_json) configure_logging(settings.log_level, settings.log_json)
db = DuckDBStore(settings) db = PgStore(settings)
db.migrate()
kraken_client = KrakenRestClient(settings)
fee_sync_stop_event = asyncio.Event()
pairing_sync_stop_event = asyncio.Event()
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
asyncio.Queue()
)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await app.state.store.start()
await app.state.store.migrate()
get_db_sink().start_consumer(db)
await app.state.configuration_service.load_database_settings()
await restore_runtime_state(app) await restore_runtime_state(app)
fee_sync_task = asyncio.create_task(
run_fee_sync_loop(
kraken_client,
db,
fee_sync_stop_event,
),
name="fee_sync_loop",
)
pairing_sync_task = asyncio.create_task(
run_pairing_sync_loop(
kraken_client,
db,
pairing_sync_stop_event,
),
name="pairing_sync_loop",
)
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db), # type: ignore
name="backtest_worker",
)
# Start market data feed from enabled pairings
await _start_feed(app)
app.state.fee_sync_task = fee_sync_task
app.state.pairing_sync_task = pairing_sync_task
app.state.backtest_task = backtest_task
app.state.log_aggregation_task = asyncio.create_task(
run_log_aggregation_loop(db), name="log_aggregation"
)
app.state.log_archive_task = asyncio.create_task(
run_log_archive_loop(db), name="log_archive"
)
yield yield
fee_sync_stop_event.set()
pairing_sync_stop_event.set()
# Stop feed
feed = getattr(app.state, "feed", None)
if feed is not None:
ws_client = getattr(app.state, "ws_client", None)
if ws_client is not None:
await ws_client.stop()
ft = getattr(app.state, "feed_task", None)
if ft is not None:
ft.cancel()
try:
await ft
except asyncio.CancelledError:
pass
fee_sync_task.cancel()
try:
await fee_sync_task
except asyncio.CancelledError:
pass
pairing_sync_task.cancel()
try:
await pairing_sync_task
except asyncio.CancelledError:
pass
await backtest_queue.put(None) # poison pill
backtest_task.cancel()
try:
await backtest_task
except asyncio.CancelledError:
pass
await kraken_client.close()
await graceful_shutdown(app) await graceful_shutdown(app)
await app.state.store.stop()
await get_db_sink().stop_consumer()
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan) app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings app.state.settings = settings
app.state.store = db app.state.store = db
app.state.kraken_client = kraken_client
app.state.fee_sync_stop_event = fee_sync_stop_event
app.state.pairing_sync_stop_event = pairing_sync_stop_event
app.state.backtest_queue = backtest_queue
app.state.metrics = MetricsCalculator(db) app.state.metrics = MetricsCalculator(db)
app.state.audit_repository = AuditRepository(db) app.state.audit_repository = AuditRepository(db)
app.state.runtime_state_repository = RuntimeStateRepository(db) app.state.runtime_state_repository = RuntimeStateRepository(db)
app.state.alert_notifier = build_notifier_from_settings(settings) app.state.alert_notifier = build_notifier_from_settings(settings)
svc = ConfigurationService(settings, db, app.state.audit_repository)
app.state.configuration_service = svc
app.state.backtest_recent_reports = [] app.state.backtest_recent_reports = []
app.state.dashboard_controls = DashboardControlState( app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active, is_running=not settings.kill_switch_active,
File diff suppressed because it is too large Load Diff
+85 -1
View File
@@ -17,6 +17,7 @@ from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.market_data.order_book import OrderBook from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.pre_trade import PreTradeValidator from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.trade_limits import TradeLimitsGuard from arbitrade.risk.trade_limits import TradeLimitsGuard
from arbitrade.storage.pg_store import PgStore
@dataclass(slots=True) @dataclass(slots=True)
@@ -56,7 +57,7 @@ class ReplayBookEvent:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class BacktestConfig: class BacktestConfig:
fee_rate: float = 0.0026 fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
min_profit_threshold: float = 0.0005 min_profit_threshold: float = 0.0005
trade_capital: float = 100.0 trade_capital: float = 100.0
quote_asset: str = "USD" quote_asset: str = "USD"
@@ -185,6 +186,89 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
return sorted(events, key=lambda event: event.occurred_at) return sorted(events, key=lambda event: event.occurred_at)
async def load_replay_events_from_db(
store: PgStore,
*,
symbols: list[str] | None = None,
start: datetime | None = None,
end: datetime | None = None,
) -> list[ReplayBookEvent]:
"""Load replay events from market_snapshots table.
Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS).
Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]}
"""
async with store.pool.acquire() as conn:
query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1"
params: list[object] = []
if symbols:
placeholders = ",".join(f"${i+1}" for i in range(len(symbols)))
query += f" AND symbol IN ({placeholders})"
params.extend(symbols)
if start is not None:
params.append(start)
query += f" AND snapshot_at >= ${len(params)}"
if end is not None:
params.append(end)
query += f" AND snapshot_at <= ${len(params)}"
query += " ORDER BY snapshot_at ASC"
rows = await conn.fetch(query, *params)
events: list[ReplayBookEvent] = []
for row in rows:
snapshot_at: datetime = row["snapshot_at"]
symbol: str = row["symbol"]
payload_raw = row["payload"]
if isinstance(payload_raw, str):
payload = orjson.loads(payload_raw)
elif isinstance(payload_raw, dict):
payload = payload_raw
else:
continue
data = payload.get("data")
if not isinstance(data, list) or not data:
continue
first = data[0]
if not isinstance(first, dict):
continue
bids = _parse_kraken_book_levels(first.get("bids"))
asks = _parse_kraken_book_levels(first.get("asks"))
if bids or asks:
events.append(
ReplayBookEvent(
occurred_at=snapshot_at,
symbol=symbol,
bids=bids,
asks=asks,
)
)
return events
def _parse_kraken_book_levels(
raw_levels: object | None,
) -> tuple[BookLevel, ...]:
"""Parse Kraken WS book level format: [{price, qty}, ...]."""
if not isinstance(raw_levels, list):
return ()
levels: list[BookLevel] = []
for level in raw_levels:
if isinstance(level, dict) and "price" in level and "qty" in level:
levels.append(BookLevel(price=float(level["price"]), volume=float(level["qty"])))
return tuple(levels)
class BacktestReplayEngine: class BacktestReplayEngine:
def __init__( def __init__(
self, self,
+171
View File
@@ -0,0 +1,171 @@
"""Async backtest job runner — picks up pending jobs from DB and executes them."""
from __future__ import annotations
import asyncio
from datetime import datetime
from pathlib import Path
import structlog
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
load_replay_events,
load_replay_events_from_db,
)
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import BacktestJobRepository
_LOG = structlog.get_logger(__name__)
def _build_cycles_from_events(
symbols: set[str],
) -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph()
for symbol in sorted(symbols):
if "/" not in symbol:
continue
base, quote = symbol.upper().split("/", 1)
graph.add_pair(base, quote, f"{base}/{quote}")
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles), sorted(symbols)
def _parse_balances(raw: str) -> dict[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
stripped = entry.strip()
if not stripped:
continue
if "=" not in stripped:
continue
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances or {"USD": 1000.0}
async def run_backtest_job(
job_id: str,
config_dict: dict[str, object] | None,
store: PgStore,
) -> None:
"""Execute a single backtest job: load events from DB or file, run engine, store report."""
repo = BacktestJobRepository(store)
await repo.update_status(job_id, "running")
_LOG.info("backtest_job_started", job_id=job_id)
try:
config = config_dict or {}
events_path = str(config.get("events_path", ""))
symbols_raw = config.get("symbols")
source = str(config.get("source", "db"))
start_dt = None
end_dt = None
if source == "db":
start_str = config.get("start_time")
end_str = config.get("end_time")
if isinstance(start_str, str) and start_str:
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
if isinstance(end_str, str) and end_str:
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
symbols: list[str] | None = None
if isinstance(symbols_raw, str) and symbols_raw.strip():
symbols = [s.strip().upper() for s in symbols_raw.split(",") if s.strip()]
elif isinstance(symbols_raw, list):
symbols = [str(s).upper() for s in symbols_raw]
events = await load_replay_events_from_db(
store,
symbols=symbols,
start=start_dt,
end=end_dt,
)
else:
path = Path(events_path)
if not path.is_absolute():
path = Path("data") / path
path = path.resolve()
events = load_replay_events(path)
if not events:
raise ValueError("No events found for backtest")
starting_balances_raw = str(config.get("starting_balances", "USD=1000.0"))
starting_balances = _parse_balances(starting_balances_raw)
fee_rate = float(config.get("fee_rate", 0.0026)) # type: ignore
trade_capital = float(config.get("trade_capital", 100.0)) # type: ignore
min_profit_threshold = float(config.get("min_profit_threshold", 0.0005)) # type: ignore
slippage_bps = float(config.get("slippage_bps", 4.0)) # type: ignore
execution_latency_ms = float(config.get("execution_latency_ms", 20.0)) # type: ignore
cycles_by_pair, available_pairs = _build_cycles_from_events(
{e.symbol.upper() for e in events}
)
if not cycles_by_pair:
raise ValueError("No triangular cycles found in event data")
bt_config = BacktestConfig(
fee_rate=fee_rate,
min_profit_threshold=min_profit_threshold,
trade_capital=trade_capital,
slippage_bps=slippage_bps,
execution_latency_ms=execution_latency_ms,
)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=bt_config,
started_at=events[0].occurred_at,
)
report = await engine.run(events, starting_balances=starting_balances)
report_dict = {
"processed_events": report.processed_events,
"opportunities_seen": report.opportunities_seen,
"trades_executed": report.trades_executed,
"win_rate": report.win_rate,
"fill_rate": report.fill_rate,
"realized_pnl_usd": report.realized_pnl_usd,
"max_drawdown_usd": report.max_drawdown_usd,
"execution_latency_p50_ms": report.execution_latency_p50_ms,
"execution_latency_p95_ms": report.execution_latency_p95_ms,
"execution_latency_p99_ms": report.execution_latency_p99_ms,
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
}
await repo.store_report(job_id, report_dict)
await repo.update_status(job_id, "completed")
_LOG.info("backtest_job_completed", job_id=job_id, pnl=report.realized_pnl_usd)
except Exception as exc:
await repo.update_status(job_id, "failed", error=str(exc))
_LOG.exception("backtest_job_failed", job_id=job_id, error=str(exc))
async def backtest_worker(
queue: asyncio.Queue[tuple[str, dict[str, object] | None] | None],
store: PgStore,
) -> None:
"""Worker coroutine: pull jobs from queue and execute them one at a time."""
_LOG.info("backtest_worker_started")
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
job_id, config = item
try:
await run_backtest_job(job_id, config, store)
except Exception:
_LOG.exception("backtest_worker_unhandled_error", job_id=job_id)
finally:
queue.task_done()
_LOG.info("backtest_worker_stopped")
+11 -16
View File
@@ -91,16 +91,14 @@ def build_parameter_grid(
for theta in theta_values: for theta in theta_values:
for trade_capital in trade_capital_values: for trade_capital in trade_capital_values:
for pair_universe in pair_universes: for pair_universe in pair_universes:
normalized_universe = tuple( normalized_universe = tuple(sorted({pair.upper() for pair in pair_universe}))
sorted({pair.upper() for pair in pair_universe}))
for staleness_threshold in staleness_threshold_values: for staleness_threshold in staleness_threshold_values:
grid.append( grid.append(
SweepParameters( SweepParameters(
min_profit_threshold=float(theta), min_profit_threshold=float(theta),
trade_capital=float(trade_capital), trade_capital=float(trade_capital),
pair_universe=normalized_universe, pair_universe=normalized_universe,
staleness_threshold_seconds=float( staleness_threshold_seconds=float(staleness_threshold),
staleness_threshold),
) )
) )
return grid return grid
@@ -147,8 +145,9 @@ def _restrict_cycles_by_pair(
if normalized_pair not in pair_universe: if normalized_pair not in pair_universe:
continue continue
kept = [cycle for cycle in cycles if all( kept = [
pair.upper() in pair_universe for pair in cycle.pairs)] cycle for cycle in cycles if all(pair.upper() in pair_universe for pair in cycle.pairs)
]
if kept: if kept:
restricted[normalized_pair] = kept restricted[normalized_pair] = kept
return restricted return restricted
@@ -175,9 +174,7 @@ def _evaluate_promotion(
test = result.test_report test = result.test_report
if test.realized_pnl_usd < criteria.min_test_realized_pnl_usd: if test.realized_pnl_usd < criteria.min_test_realized_pnl_usd:
reasons.append( reasons.append("test_realized_pnl_below_threshold")
"test_realized_pnl_below_threshold"
)
if (test.win_rate or 0.0) < criteria.min_test_win_rate: if (test.win_rate or 0.0) < criteria.min_test_win_rate:
reasons.append("test_win_rate_below_threshold") reasons.append("test_win_rate_below_threshold")
if (test.fill_rate or 0.0) < criteria.min_test_fill_rate: if (test.fill_rate or 0.0) < criteria.min_test_fill_rate:
@@ -221,8 +218,7 @@ def run_parameter_search(
quote_asset: str = "USD", quote_asset: str = "USD",
) -> SweepArtifacts: ) -> SweepArtifacts:
criteria = promotion_criteria or PromotionCriteria() criteria = promotion_criteria or PromotionCriteria()
train_events, test_events = split_events_time_windows( train_events, test_events = split_events_time_windows(events, train_ratio=train_ratio)
events, train_ratio=train_ratio)
results: list[SweepResult] = [] results: list[SweepResult] = []
promoted: list[SweepResult] = [] promoted: list[SweepResult] = []
@@ -293,7 +289,8 @@ def run_parameter_search(
test_event_count=len(filtered_test), test_event_count=len(filtered_test),
) )
promotion_ready, promotion_reasons = _evaluate_promotion( promotion_ready, promotion_reasons = _evaluate_promotion(
result=base_result, criteria=criteria) result=base_result, criteria=criteria
)
completed_result = SweepResult( completed_result = SweepResult(
parameters=base_result.parameters, parameters=base_result.parameters,
train_report=base_result.train_report, train_report=base_result.train_report,
@@ -318,8 +315,7 @@ def run_parameter_search(
train_window: tuple[datetime, datetime] | None = None train_window: tuple[datetime, datetime] | None = None
test_window: tuple[datetime, datetime] | None = None test_window: tuple[datetime, datetime] | None = None
if train_events: if train_events:
train_window = (train_events[0].occurred_at, train_window = (train_events[0].occurred_at, train_events[-1].occurred_at)
train_events[-1].occurred_at)
if test_events: if test_events:
test_window = (test_events[0].occurred_at, test_events[-1].occurred_at) test_window = (test_events[0].occurred_at, test_events[-1].occurred_at)
@@ -392,5 +388,4 @@ def persist_sweep_results(path: Path, artifacts: SweepArtifacts) -> None:
} }
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps( path.write_bytes(orjson.dumps(payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
+79
View File
@@ -0,0 +1,79 @@
"""Sync available Kraken asset pairs into the config_pairings table."""
from __future__ import annotations
import asyncio
import structlog
from arbitrade.config.service import ConfigPairing
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ConfigPairingRepository
_LOG = structlog.get_logger(__name__)
async def sync_pairings_from_kraken(
kraken_client: KrakenRestClient,
store: PgStore,
) -> dict[str, int]:
"""Fetch all asset pairs from Kraken and upsert into config_pairings.
Returns a summary dict with 'added', 'updated', 'total' counts.
"""
asset_pairs = await kraken_client.asset_pairs()
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
repo = ConfigPairingRepository(store)
added = 0
updated = 0
total = 0
# Dedupe: pair_by_direction has entries for both (base,quote) and (quote,base).
seen_symbols: set[str] = set()
for (base, quote), symbol in graph.pair_by_direction.items():
if symbol in seen_symbols:
continue
seen_symbols.add(symbol)
existing = await repo.get_pairing(base, quote)
pairing = ConfigPairing(
base_asset=base,
quote_asset=quote,
enabled=existing.enabled if existing else False,
source="kraken",
)
try:
await repo.upsert_pairing(pairing)
total += 1
if existing:
updated += 1
else:
added += 1
except Exception:
_LOG.warning("sync_pairing_failed", base=base, quote=quote)
_LOG.info(
"pairing_sync_complete",
added=added,
updated=updated,
total=total,
)
return {"added": added, "updated": updated, "total": total}
async def run_pairing_sync_loop(
kraken_client: KrakenRestClient,
store: PgStore,
stop_event: asyncio.Event,
interval_seconds: int = 86400,
) -> None:
"""Periodically sync pairings from Kraken (default daily)."""
await sync_pairings_from_kraken(kraken_client, store)
try:
while not stop_event.is_set():
await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
await sync_pairings_from_kraken(kraken_client, store)
except (TimeoutError, asyncio.CancelledError):
pass
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import orjson
from pydantic import BaseModel
from arbitrade.config.settings import Settings
from arbitrade.storage.pg_store import PgStore
class ConfigSection(BaseModel):
id: int | None = None
name: str
description: str | None = None
updated_at: datetime | None = None
class ConfigSetting(BaseModel):
key: str
section: str
value_json: str
value_type: str
is_secret: bool = False
is_runtime_reloadable: bool = False
updated_at: datetime | None = None
updated_by: str | None = None
class ConfigPairing(BaseModel):
id: int | None = None
base_asset: str
quote_asset: str
enabled: bool = True
source: str
created_at: datetime | None = None
updated_at: datetime | None = None
class ConfigBacktestingDefaults(BaseModel):
starting_balances: dict[str, float] | None = None
trade_capital: float | None = None
min_profit_threshold: float | None = None
slippage_bps: int | None = None
execution_latency_ms: int | None = None
class ConfigurationService:
"""Manages application configuration from environment and database sources."""
def __init__(self, settings: Settings, store: PgStore, audit_repo: Any) -> None:
self._settings = settings
self._store = store
self._audit_repo = audit_repo
self._config_version = 0
self._loaded_settings: dict[str, Any] = {}
self._last_updated_at: datetime | None = None
async def load_database_settings(self) -> None:
"""Load user settings from database and merge with defaults."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Load all settings from database
db_settings = await setting_repo.list_settings()
# Convert to dictionary for easy access
for setting in db_settings:
# Parse JSON value based on type
if setting.value_type == "str":
parsed_value = setting.value_json
elif setting.value_type == "int":
parsed_value = int(setting.value_json) # type: ignore
elif setting.value_type == "float":
parsed_value = float(setting.value_json) # type: ignore
elif setting.value_type == "bool":
parsed_value = setting.value_json.lower() == "true" # type: ignore
elif setting.value_type == "list":
parsed_value = orjson.loads(setting.value_json)
elif setting.value_type == "dict":
parsed_value = orjson.loads(setting.value_json)
else:
parsed_value = setting.value_json
self._loaded_settings[setting.key] = parsed_value
# Track the latest update time
if db_settings:
latest_updated = max(
setting.updated_at for setting in db_settings if setting.updated_at
)
self._last_updated_at = latest_updated
# Initialize with default values from settings model
self._initialize_default_settings()
def _initialize_default_settings(self) -> None:
"""Initialize default settings from the Settings model."""
# This is a placeholder - in a real implementation we'd map
# the Settings model fields to config keys
pass
def get_setting(self, key: str, default: Any = None) -> Any:
"""Get a configuration setting value."""
return self._loaded_settings.get(key, default)
def get_config_version(self) -> int:
"""Get the current configuration version for hot-reloading."""
return self._config_version
def get_last_updated_at(self) -> datetime | None:
"""Get the timestamp of the last configuration update."""
return self._last_updated_at
async def is_config_outdated(self) -> bool:
"""Check if configuration has been updated since last load."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Get the latest update timestamp from database
latest_db_update = await setting_repo.get_latest_updated_at()
# Compare with our last loaded timestamp
if latest_db_update and self._last_updated_at:
return latest_db_update > self._last_updated_at
elif latest_db_update:
return True
return False
async def reload_if_changed(self) -> bool:
"""Reload configuration if it has been updated in the database."""
if await self.is_config_outdated():
await self.load_database_settings()
self._config_version += 1
return True
return False
async def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None:
"""Set a configuration setting value and persist to database."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Convert value to JSON string and determine type
if isinstance(value, str):
value_json = value
value_type = "str"
elif isinstance(value, int):
value_json = str(value)
value_type = "int"
elif isinstance(value, float):
value_json = str(value)
value_type = "float"
elif isinstance(value, bool):
value_json = str(value).lower()
value_type = "bool"
elif isinstance(value, list):
value_json = orjson.dumps(value).decode("utf-8")
value_type = "list"
elif isinstance(value, dict):
value_json = orjson.dumps(value).decode("utf-8")
value_type = "dict"
else:
value_json = str(value)
value_type = "str"
# Create or update setting
setting = ConfigSetting(
key=key,
section="general", # Default section
value_json=value_json,
value_type=value_type,
is_secret=False,
is_runtime_reloadable=False,
updated_by=updated_by,
)
# Check if setting exists
existing_setting = await setting_repo.get_setting(key)
if existing_setting:
# Update existing setting
updated_setting = await setting_repo.update_setting(key, setting)
else:
# Create new setting
updated_setting = await setting_repo.create_setting(setting)
# Update in-memory cache
self._loaded_settings[key] = value
# Update version for hot reloading
self._config_version += 1
# Update last updated timestamp
self._last_updated_at = updated_setting.updated_at
def get_all_settings(self) -> dict[str, Any]:
"""Get all configuration settings."""
return self._loaded_settings.copy()
# --- Pairing & Fee Management ---
def _pairing_repo(self): # type: ignore
from arbitrade.storage.repositories import ConfigPairingRepository
return ConfigPairingRepository(self._store)
async def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings."""
r = self._pairing_repo() # type: ignore[no-untyped-call]
p = await r.list_pairings()
return p # type: ignore[no-any-return]
async def create_pairing(
self, base_asset: str, quote_asset: str, source: str = "manual"
) -> ConfigPairing:
"""Create a new currency pairing."""
r = self._pairing_repo() # type: ignore[no-untyped-call]
e = await r.get_pairing(base_asset, quote_asset)
if e:
return e # type: ignore[no-any-return]
pairing = ConfigPairing(
base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source
)
p = r.create_pairing(pairing)
return p # type: ignore[no-any-return]
+46 -79
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -32,72 +31,56 @@ class Settings(BaseSettings):
) )
alerts_enabled: bool = Field(default=True, alias="ALERTS_ENABLED") alerts_enabled: bool = Field(default=True, alias="ALERTS_ENABLED")
alert_min_severity: str = Field( alert_min_severity: str = Field(default="warning", alias="ALERT_MIN_SEVERITY")
default="warning", alias="ALERT_MIN_SEVERITY") alert_dedup_seconds: float = Field(default=30.0, alias="ALERT_DEDUP_SECONDS")
alert_dedup_seconds: float = Field( alert_on_trade_events: bool = Field(default=True, alias="ALERT_ON_TRADE_EVENTS")
default=30.0, alias="ALERT_DEDUP_SECONDS") alert_on_error_events: bool = Field(default=True, alias="ALERT_ON_ERROR_EVENTS")
alert_on_trade_events: bool = Field( alert_on_threshold_events: bool = Field(default=True, alias="ALERT_ON_THRESHOLD_EVENTS")
default=True, alias="ALERT_ON_TRADE_EVENTS") alert_on_system_events: bool = Field(default=True, alias="ALERT_ON_SYSTEM_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( telegram_alerts_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTS_ENABLED")
default=False, alias="TELEGRAM_ALERTS_ENABLED") telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_bot_token: str | None = Field( telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(
default=None, alias="TELEGRAM_CHAT_ID")
discord_alerts_enabled: bool = Field( discord_alerts_enabled: bool = Field(default=False, alias="DISCORD_ALERTS_ENABLED")
default=False, alias="DISCORD_ALERTS_ENABLED") discord_webhook_url: str | None = Field(default=None, alias="DISCORD_WEBHOOK_URL")
discord_webhook_url: str | None = Field(
default=None, alias="DISCORD_WEBHOOK_URL")
email_alerts_enabled: bool = Field( email_alerts_enabled: bool = Field(default=False, alias="EMAIL_ALERTS_ENABLED")
default=False, alias="EMAIL_ALERTS_ENABLED")
email_smtp_host: str | None = Field(default=None, alias="EMAIL_SMTP_HOST") 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_port: int = Field(default=587, alias="EMAIL_SMTP_PORT")
email_smtp_username: str | None = Field( email_smtp_username: str | None = Field(default=None, alias="EMAIL_SMTP_USERNAME")
default=None, alias="EMAIL_SMTP_USERNAME") email_smtp_password: str | None = Field(default=None, alias="EMAIL_SMTP_PASSWORD")
email_smtp_password: str | None = Field( email_alert_from: str | None = Field(default=None, alias="EMAIL_ALERT_FROM")
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_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS") email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
duckdb_path: Path = Field(default=Path( # PostgreSQL connection settings
"./data/arbitrade.duckdb"), alias="DUCKDB_PATH") pg_host: str = Field(default="192.168.88.35", alias="PG_HOST")
pg_port: int = Field(default=5432, alias="PG_PORT")
pg_database: str = Field(default="arbitrade", alias="PG_DATABASE")
pg_user: str = Field(default="arbitrade", alias="PG_USER")
pg_password: str = Field(default="arbitrade", alias="PG_PASSWORD")
pg_min_connections: int = Field(default=2, alias="PG_MIN_CONNECTIONS")
pg_max_connections: int = Field(default=10, alias="PG_MAX_CONNECTIONS")
kraken_rest_url: str = Field( kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
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_ws_url: str = Field(
default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
kraken_private_rate_limit_seconds: float = Field( kraken_private_rate_limit_seconds: float = Field(
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS" default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
) )
kraken_http_timeout_seconds: float = Field( kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS") kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS")
kraken_retry_attempts: int = Field(
default=3, alias="KRAKEN_RETRY_ATTEMPTS")
kraken_retry_base_delay_seconds: float = Field( kraken_retry_base_delay_seconds: float = Field(
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS" default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
) )
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY") kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
kraken_api_secret: str | None = Field( kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET")
default=None, alias="KRAKEN_API_SECRET")
kraken_api_key_permissions: str = Field( kraken_api_key_permissions: str = Field(
default="query,trade", default="query,trade",
alias="KRAKEN_API_KEY_PERMISSIONS", alias="KRAKEN_API_KEY_PERMISSIONS",
) )
ws_heartbeat_timeout_seconds: float = Field( ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS") ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
ws_max_staleness_seconds: float = Field(
default=5.0, alias="WS_MAX_STALENESS_SECONDS")
strategy_enable_stat_arb_experiment: bool = Field( strategy_enable_stat_arb_experiment: bool = Field(
default=False, default=False,
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT", alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
@@ -120,29 +103,20 @@ class Settings(BaseSettings):
) )
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE") paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD") trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
max_trade_capital_usd: float = Field( max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
default=100.0, alias="MAX_TRADE_CAPITAL_USD") max_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES")
max_concurrent_trades: int | None = Field(
default=None, alias="MAX_CONCURRENT_TRADES")
max_exposure_per_asset_usd: float | None = Field( max_exposure_per_asset_usd: float | None = Field(
default=None, default=None,
alias="MAX_EXPOSURE_PER_ASSET_USD", alias="MAX_EXPOSURE_PER_ASSET_USD",
) )
quote_balance_asset: str = Field( quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET")
default="USD", alias="QUOTE_BALANCE_ASSET") min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD")
min_order_size_usd: float | None = Field(
default=None, alias="MIN_ORDER_SIZE_USD")
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE") kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
daily_loss_limit_usd: float | None = Field( daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD")
default=None, alias="DAILY_LOSS_LIMIT_USD") cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
cumulative_loss_limit_usd: float | None = Field( max_source_latency_ms: float | None = Field(default=None, alias="MAX_SOURCE_LATENCY_MS")
default=None, alias="CUMULATIVE_LOSS_LIMIT_USD") max_apply_latency_ms: float | None = Field(default=None, alias="MAX_APPLY_LATENCY_MS")
max_source_latency_ms: float | None = Field( max_consecutive_failures: int | None = Field(default=None, alias="MAX_CONSECUTIVE_FAILURES")
default=None, alias="MAX_SOURCE_LATENCY_MS")
max_apply_latency_ms: float | None = Field(
default=None, alias="MAX_APPLY_LATENCY_MS")
max_consecutive_failures: int | None = Field(
default=None, alias="MAX_CONSECUTIVE_FAILURES")
fernet_key: str | None = Field(default=None, alias="FERNET_KEY") fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
@@ -159,8 +133,7 @@ class Settings(BaseSettings):
def _validate_log_level(cls, value: str) -> str: def _validate_log_level(cls, value: str) -> str:
normalized = value.strip().upper() normalized = value.strip().upper()
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}: if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError( raise ValueError("LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
"LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
return normalized return normalized
@field_validator("alert_min_severity") @field_validator("alert_min_severity")
@@ -168,19 +141,16 @@ class Settings(BaseSettings):
def _validate_alert_severity(cls, value: str) -> str: def _validate_alert_severity(cls, value: str) -> str:
normalized = value.strip().lower() normalized = value.strip().lower()
if normalized not in {"info", "warning", "error", "critical"}: if normalized not in {"info", "warning", "error", "critical"}:
raise ValueError( raise ValueError("ALERT_MIN_SEVERITY must be one of: info, warning, error, critical")
"ALERT_MIN_SEVERITY must be one of: info, warning, error, critical")
return normalized return normalized
@model_validator(mode="after") @model_validator(mode="after")
def _validate_security_constraints(self) -> Settings: def _validate_security_constraints(self) -> Settings:
if bool(self.dashboard_auth_username) ^ bool(self.dashboard_auth_password): if bool(self.dashboard_auth_username) ^ bool(self.dashboard_auth_password):
raise ValueError( raise ValueError("dashboard auth requires both username and password")
"dashboard auth requires both username and password")
if bool(self.kraken_api_key) ^ bool(self.kraken_api_secret): if bool(self.kraken_api_key) ^ bool(self.kraken_api_secret):
raise ValueError( raise ValueError("Kraken API auth requires both API key and secret")
"Kraken API auth requires both API key and secret")
permissions = { permissions = {
token.strip().lower() token.strip().lower()
@@ -188,11 +158,9 @@ class Settings(BaseSettings):
if token.strip() if token.strip()
} }
if permissions and ("query" not in permissions or "trade" not in permissions): if permissions and ("query" not in permissions or "trade" not in permissions):
raise ValueError( raise ValueError("KRAKEN_API_KEY_PERMISSIONS must include query and trade")
"KRAKEN_API_KEY_PERMISSIONS must include query and trade")
if "withdraw" in permissions or "withdrawals" in permissions: if "withdraw" in permissions or "withdrawals" in permissions:
raise ValueError( raise ValueError("KRAKEN_API_KEY_PERMISSIONS must not include withdrawal scope")
"KRAKEN_API_KEY_PERMISSIONS must not include withdrawal scope")
if self.alert_dedup_seconds < 0.0: if self.alert_dedup_seconds < 0.0:
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0") raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
@@ -208,8 +176,7 @@ class Settings(BaseSettings):
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE" "STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
) )
if self.strategy_stat_arb_max_holding_seconds <= 0.0: if self.strategy_stat_arb_max_holding_seconds <= 0.0:
raise ValueError( raise ValueError("STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
"STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
return self return self
+1
View File
@@ -0,0 +1 @@
"""Dashboard module for monitoring and controlling the arbitrage bot."""
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import Request
from arbitrade.storage.repositories import (
BacktestJobRepository,
)
async def _recent_backtest_reports(request: Request) -> list[dict[str, object]]:
repo = BacktestJobRepository(request.app.state.store)
jobs = await repo.list_jobs(limit=5)
reports = []
for job in jobs:
report: dict[str, object] = {
"id": str(job.id),
"status": job.status,
}
if job.created_at is not None:
report["created_at"] = job.created_at.isoformat()
if job.finished_at is not None:
report["finished_at"] = job.finished_at.isoformat()
reports.append(report)
return reports
async 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 = {
"symbols": "",
"start_time": "",
"end_time": "",
"starting_balances": "USD=1000.0",
"trade_capital": "100.0",
"min_profit_threshold": "0.0005",
"fee_profile": "api",
"custom_fee_rate": "",
"slippage_bps": "4.0",
"execution_latency_ms": "20.0",
}
if defaults is not None:
default_values.update(defaults)
reports = await _recent_backtest_reports(request)
latest = latest_report or (reports[0] if reports else None)
return {
"status": status,
"message": message,
"flash_message": "",
"no_enabled_pairings": False,
"latest_report": latest,
"recent_reports": reports,
"run_endpoint": "/dashboard/backtesting/run",
"reports_endpoint": "/dashboard/api/backtesting/reports",
**default_values,
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -47,7 +47,7 @@ def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, Ord
detector = IncrementalCycleDetector( detector = IncrementalCycleDetector(
index, index,
fee_rate=0.001, fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
min_profit_threshold=0.001, min_profit_threshold=0.001,
max_depth_levels=5, max_depth_levels=5,
max_book_age_seconds=10.0, max_book_age_seconds=10.0,
+142
View File
@@ -0,0 +1,142 @@
"""Fee service -- fetch Kraken account fee tier, sync pair fees, persist snapshots."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
import orjson
import structlog
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
KrakenAccountSnapshot,
KrakenAccountSnapshotRepository,
)
_LOG = structlog.get_logger(__name__)
_FEE_REFRESH_INTERVAL_SECONDS = 86400 # 1 day
async def fetch_and_store_account_snapshot(
client: KrakenRestClient,
store: PgStore,
) -> KrakenAccountSnapshot | None:
"""Query TradeVolume + TradeBalance, persist as snapshot.
Returns the snapshot or None if either call failed.
"""
repo = KrakenAccountSnapshotRepository(store)
try:
volume_data = await client.trade_volume()
except Exception:
_LOG.exception("trade_volume_fetch_failed")
return None
try:
balance_data = await client.trade_balance()
except Exception:
_LOG.exception("trade_balance_fetch_failed")
return None
fee_tier = volume_data.get("fee_tier") if isinstance(volume_data, dict) else None
fees_dict = volume_data.get("fees") if isinstance(volume_data, dict) else None
fees_maker = volume_data.get("fees_maker") if isinstance(volume_data, dict) else None
currency = volume_data.get("currency")
thirty_day_volume_str = volume_data.get("volume")
maker_fee = None
taker_fee = None
fee_tier_str = str(fee_tier) if fee_tier is not None else None
# Extract current tier's maker/taker rates from fees dict
if isinstance(fees_dict, dict) and fee_tier_str is not None:
tier_fees = fees_dict.get(fee_tier_str)
if isinstance(tier_fees, dict):
maker_val = tier_fees.get("maker")
taker_val = tier_fees.get("taker")
maker_fee = float(maker_val) if maker_val is not None else None
taker_fee = float(taker_val) if taker_val is not None else None
# Build fee schedule as combined dict
fee_schedule: dict[str, object] = {}
if isinstance(fees_dict, dict):
fee_schedule["fees"] = fees_dict
if isinstance(fees_maker, dict):
fee_schedule["fees_maker"] = fees_maker
if currency is not None:
fee_schedule["currency"] = currency
thirty_day_volume = float(thirty_day_volume_str) if thirty_day_volume_str is not None else None
snapshot = KrakenAccountSnapshot(
snapshot_at=datetime.now(UTC),
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
thirty_day_volume=thirty_day_volume,
trade_balance_raw=balance_data if isinstance(balance_data, dict) else None,
fee_schedule_raw=fee_schedule if fee_schedule else None,
)
await repo.insert_snapshot(snapshot)
_LOG.info(
"account_snapshot_stored",
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
)
# Fetch wallet balances and write to portfolio_snapshots
try:
wallet_balances = await client.balances()
total_value = 0.0
if isinstance(balance_data, dict):
eb = balance_data.get("eb")
total_value = float(eb) if eb is not None else 0.0
async with store.pool.acquire() as conn:
await conn.execute(
"INSERT INTO portfolio_snapshots"
" (snapshot_at, balances, total_value_usd) VALUES ($1, $2, $3)",
datetime.now(UTC),
orjson.dumps(wallet_balances).decode("utf-8") if wallet_balances else None,
total_value,
)
_LOG.info("portfolio_snapshot_stored", total_value_usd=total_value)
except Exception:
_LOG.exception("balances_fetch_or_store_failed")
return snapshot
async def run_fee_sync_loop(
client: KrakenRestClient,
store: PgStore,
stop_event: asyncio.Event,
) -> None:
"""Periodic loop: fetch account snapshot every hour.
Runs until stop_event is set.
"""
_LOG.info("fee_sync_loop_started", interval_s=_FEE_REFRESH_INTERVAL_SECONDS)
while not stop_event.is_set():
try:
await fetch_and_store_account_snapshot(client, store)
except Exception:
_LOG.exception("fee_sync_loop_iteration_failed")
# Wait with stop_event check
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=_FEE_REFRESH_INTERVAL_SECONDS,
)
break # stop_event was set
except TimeoutError:
pass # timeout elapsed, loop again
_LOG.info("fee_sync_loop_stopped")
+31
View File
@@ -279,3 +279,34 @@ class KrakenRestClient:
"/0/private/CancelOrder", "/0/private/CancelOrder",
data={"txid": order_id}, data={"txid": order_id},
) )
async def trade_volume(self, *, pair: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeVolume for fee tier, 30d volume, and fee schedule.
Returns dict with keys: currency, volume, fees (dict of tiers),
fees_maker (dict of tier->fee mappings), fee_tier (current tier).
If pair provided, returns pair-specific fee info.
"""
data: dict[str, str] = {}
if pair is not None:
data["pair"] = pair
return await self._throttled_private_call(
"/0/private/TradeVolume",
data=data if data else None,
)
async def trade_balance(self, *, asset: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeBalance for equity, trade balance, margin info.
Returns dict with keys: eb (equivalent balance/equity),
tb (trade balance), m (margin amount), n (unrealized net P&L),
c (cost basis), v (current valuation), e (equity).
If asset provided, returns asset-class-specific balance.
"""
data: dict[str, str] = {}
if asset is not None:
data["asset"] = asset
return await self._throttled_private_call(
"/0/private/TradeBalance",
data=data if data else None,
)
+41 -6
View File
@@ -32,6 +32,7 @@ class KrakenWsClient:
self._alert_notifier = alert_notifier self._alert_notifier = alert_notifier
self._has_connected_once = False self._has_connected_once = False
self._was_disconnected = False self._was_disconnected = False
self._subscribed_symbols: list[str] = []
@property @property
def is_stale(self) -> bool: def is_stale(self) -> bool:
@@ -44,29 +45,63 @@ class KrakenWsClient:
async def stop(self) -> None: async def stop(self) -> None:
self._stop.set() self._stop.set()
def set_subscribed_symbols(self, symbols: list[str]) -> None:
"""Set the list of symbols to subscribe to on (re)connect."""
self._subscribed_symbols = list(symbols)
async def _subscribe(self, ws: Any) -> None:
"""Send Kraken WS v2 subscribe message for book channel."""
if not self._subscribed_symbols:
_LOG.warning("kraken_ws_no_symbols_to_subscribe")
return
depth = 10
if hasattr(self._settings, "kraken_ws_book_depth"):
depth = self._settings.kraken_ws_book_depth
msg = orjson.dumps(
{
"method": "subscribe",
"params": {
"channel": "book",
"symbol": self._subscribed_symbols,
"depth": depth,
},
}
)
await ws.send(msg)
_LOG.info(
"kraken_ws_subscribed",
symbol_count=len(self._subscribed_symbols),
symbols=self._subscribed_symbols,
)
async def connect_stream(self) -> AsyncIterator[WsMessage]: async def connect_stream(self) -> AsyncIterator[WsMessage]:
delay = 1.0 delay = 1.0
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
async with websockets.connect( url = self._settings.kraken_ws_url
self._settings.kraken_ws_url, max_size=2_000_000 async with websockets.connect(url, max_size=2_000_000) as ws:
) as ws: _LOG.info("kraken_ws_connected", url=url)
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
if self._has_connected_once and self._was_disconnected: if self._has_connected_once and self._was_disconnected:
await self._notify( await self._notify(
category="system", category="system",
severity="info", severity="info",
title="WebSocket reconnected", title="WebSocket reconnected",
message="Kraken WebSocket connection restored.", message="Kraken WebSocket connection restored.",
details={"url": self._settings.kraken_ws_url}, details={"url": url},
) )
self._has_connected_once = True self._has_connected_once = True
self._was_disconnected = False self._was_disconnected = False
delay = 1.0 delay = 1.0
await self._subscribe(ws)
async for raw in self._recv_loop(ws): async for raw in self._recv_loop(ws):
yield raw yield raw
except Exception as exc: except Exception as exc:
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay) log = (
"kraken_ws_disconnected_first_time"
if not self._has_connected_once
else "kraken_ws_disconnected"
)
_LOG.warning(log, error=str(exc), reconnect_in=delay)
self._was_disconnected = True self._was_disconnected = True
await self._notify( await self._notify(
category="system", category="system",
+2 -2
View File
@@ -158,7 +158,7 @@ class TriangularExecutionSequencer:
) )
except Exception as exc: except Exception as exc:
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -265,7 +265,7 @@ class TriangularExecutionSequencer:
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
+1
View File
@@ -0,0 +1 @@
"""Logging package — DB sink, maintenance tasks."""
+119
View File
@@ -0,0 +1,119 @@
"""DB sink — writes structlog events to app_logs table via background queue."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogRecord, LogRepository
_LOG = structlog.get_logger(__name__)
class DbSinkProcessor:
"""structlog processor that queues log events for DB writes.
Must be registered in the structlog processor chain. The consumer
task must be started on app init via ``start_consumer(store)``.
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=2000)
self._consumer_task: asyncio.Task[None] | None = None
def __call__(self, logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Processor — called for every structlog event. Non-blocking."""
try:
self._queue.put_nowait(dict(event_dict))
except asyncio.QueueFull:
pass # drop event if queue full, avoid backpressure
return event_dict
def start_consumer(self, store: PgStore) -> None:
"""Start background consumer task."""
if self._consumer_task is not None and not self._consumer_task.done():
return
self._consumer_task = asyncio.create_task(self._consume(store), name="log_db_sink")
async def stop_consumer(self) -> None:
"""Drain queue and cancel consumer."""
if self._consumer_task is None:
return
self._consumer_task.cancel()
try:
await self._consumer_task
except asyncio.CancelledError:
pass
self._consumer_task = None
# Flush remaining
await self._flush(store=None) # type: ignore[call-arg]
async def _consume(self, store: PgStore) -> None:
repo = LogRepository(store)
while True:
try:
event = await self._queue.get()
await self._write_one(repo, event)
except asyncio.CancelledError:
break
except Exception:
pass # swallow consumer errors, never crash
# Final flush
await self._flush(repo)
async def _write_one(self, repo: LogRepository, event: dict[str, Any]) -> None:
recorded_at = event.pop("timestamp", None)
if isinstance(recorded_at, str):
try:
recorded_at = datetime.fromisoformat(recorded_at)
except ValueError:
recorded_at = datetime.now(UTC)
elif not isinstance(recorded_at, datetime):
recorded_at = datetime.now(UTC)
level = str(event.pop("level", "info")).upper()
logger = str(event.pop("logger", "root"))
message = str(event.pop("event", event.pop("message", "")))
context = {k: v for k, v in event.items() if not k.startswith("_")} if event else None
record = LogRecord(
recorded_at=recorded_at,
level=level,
logger=logger,
message=message,
context=context if context else None,
)
try:
await repo.insert(record)
except Exception:
pass # never crash from DB write failure
async def _flush(self, repo: LogRepository | None) -> None:
drained = 0
while not self._queue.empty() and drained < 500:
try:
event = self._queue.get_nowait()
if repo is not None:
await self._write_one(repo, event)
drained += 1
except asyncio.QueueEmpty:
break
except Exception:
pass
# Module-level singleton
_db_sink = DbSinkProcessor()
def get_db_sink() -> DbSinkProcessor:
return _db_sink
def db_sink_processor(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Standalone processor function wrapping the singleton."""
return _db_sink(logger, method_name, event_dict)
+60
View File
@@ -0,0 +1,60 @@
"""Log maintenance — aggregation and archiving tasks."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogAggregationRepository, LogArchiveRepository
_LOG = structlog.get_logger(__name__)
_AGGREGATE_INTERVAL = 3600 # 1 hour
_ARCHIVE_INTERVAL = 86400 # 1 day
_RETENTION_DAYS = 30
async def run_log_aggregation(store: PgStore) -> None:
"""Aggregate log counts for the last 2 hours across all periods."""
repo = LogAggregationRepository(store)
since = datetime.now(UTC) - timedelta(hours=2)
periods = ["1h", "1d", "1w", "1mo"]
for period in periods:
try:
await repo.aggregate_since(since, period)
except Exception:
_LOG.exception("log_aggregation_failed", period=period)
_LOG.info("log_aggregation_complete", since=since.isoformat())
async def run_log_archive(store: PgStore, retention_days: int = _RETENTION_DAYS) -> int:
"""Archive log entries older than retention_days."""
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
repo = LogArchiveRepository(store)
count = await repo.archive_before(cutoff)
if count > 0:
_LOG.info("log_archive_complete", cutoff=cutoff.isoformat(), archived=count)
return count
async def run_log_aggregation_loop(store: PgStore) -> None:
"""Periodic aggregation loop."""
while True:
try:
await run_log_aggregation(store)
except Exception:
_LOG.exception("log_aggregation_loop_error")
await asyncio.sleep(_AGGREGATE_INTERVAL)
async def run_log_archive_loop(store: PgStore) -> None:
"""Periodic archive loop."""
while True:
try:
await run_log_archive(store)
except Exception:
_LOG.exception("log_archive_loop_error")
await asyncio.sleep(_ARCHIVE_INTERVAL)
+3
View File
@@ -6,6 +6,8 @@ from typing import Any
import structlog import structlog
from arbitrade.logging.db_sink import db_sink_processor
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None: def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
level = getattr(logging, log_level.upper(), logging.INFO) level = getattr(logging, log_level.upper(), logging.INFO)
@@ -17,6 +19,7 @@ def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
timestamper, timestamper,
db_sink_processor,
] ]
if json_logs: if json_logs:
+11 -11
View File
@@ -144,7 +144,7 @@ class MarketDataFeed:
symbol=delta.symbol, symbol=delta.symbol,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -172,7 +172,7 @@ class MarketDataFeed:
for event in opportunities: for event in opportunities:
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="detector", actor="detector",
@@ -207,7 +207,7 @@ class MarketDataFeed:
net_pct=event.net_pct, net_pct=event.net_pct,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -228,7 +228,7 @@ class MarketDataFeed:
updated_pair=event.updated_pair, updated_pair=event.updated_pair,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -250,7 +250,7 @@ class MarketDataFeed:
reason=self._kill_switch.reason, reason=self._kill_switch.reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -275,7 +275,7 @@ class MarketDataFeed:
reason=self._stop_conditions_guard.halted_reason, reason=self._stop_conditions_guard.halted_reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -298,7 +298,7 @@ class MarketDataFeed:
reason=self._loss_limit_guard.halted_reason, reason=self._loss_limit_guard.halted_reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -329,7 +329,7 @@ class MarketDataFeed:
required_by_asset=required_balances, required_by_asset=required_balances,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -358,7 +358,7 @@ class MarketDataFeed:
exposure_by_asset=exposure_by_asset, exposure_by_asset=exposure_by_asset,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -420,7 +420,7 @@ class MarketDataFeed:
updated_pair=event.updated_pair, updated_pair=event.updated_pair,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -459,7 +459,7 @@ class MarketDataFeed:
self._trade_limits_guard.close_trade(exposure_by_asset) self._trade_limits_guard.close_trade(exposure_by_asset)
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
+62
View File
@@ -0,0 +1,62 @@
"""Build production MarketDataFeed components from enabled pairings."""
from __future__ import annotations
import structlog
from arbitrade.detection.engine import IncrementalCycleDetector
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ConfigPairingRepository
_LOG = structlog.get_logger(__name__)
async def build_detector_from_enabled_pairings(
store: PgStore,
*,
fee_rate: float = 0.0,
max_depth_levels: int = 10,
min_profit_threshold: float = 0.0005,
) -> IncrementalCycleDetector | None:
"""Build an IncrementalCycleDetector using only enabled pairings from DB.
Returns None if no enabled pairings exist.
"""
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings(enabled_only=True)
if not pairings:
_LOG.warning("no_enabled_pairings_found_detector_not_created")
return None
# Build CurrencyGraph from enabled pairings and discover cycles
graph = CurrencyGraph()
for p in pairings:
symbol = f"{p.base_asset}/{p.quote_asset}"
graph.add_pair(p.base_asset, p.quote_asset, symbol)
cycles = graph.triangular_cycles()
if not cycles:
_LOG.warning("no_triangular_cycles_from_enabled_pairings")
return None
cycles_by_pair = graph.index_cycles_by_pair(cycles)
_LOG.info(
"detector_built_from_enabled_pairings",
enabled_count=len(pairings),
cycle_count=len(cycles),
)
return IncrementalCycleDetector(
cycles_by_pair,
fee_rate=fee_rate,
max_depth_levels=max_depth_levels,
min_profit_threshold=min_profit_threshold,
)
async def get_enabled_pair_symbols(store: PgStore) -> list[str]:
"""Return list of enabled pair symbols (e.g. ['BTC/USD', 'ETH/BTC'])."""
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings(enabled_only=True)
return [f"{p.base_asset}/{p.quote_asset}" for p in pairings if p.enabled]
+55 -34
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -19,57 +19,66 @@ class PerformanceMetrics:
class MetricsCalculator: class MetricsCalculator:
def __init__(self, store: DuckDBStore) -> None: def __init__(self, store: PgStore) -> None:
self._store = store self._store = store
def compute(self) -> PerformanceMetrics: async def compute(self) -> PerformanceMetrics:
with self._store.connect() as conn: async with self._store.pool.acquire() as conn:
tm = conn.execute(""" tm = await conn.fetchrow("""
SELECT SELECT
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd, COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
COUNT(*) AS total_trades, COUNT(*) AS total_trades,
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_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, AVG(EXTRACT(EPOCH FROM finished_at - started_at)) AS avg_trade_duration_seconds,
quantile_cont( PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p50_seconds,
EPOCH(finished_at) - EPOCH(started_at), PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p95_seconds,
0.50 PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p99_seconds
) 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 FROM trades
WHERE finished_at IS NOT NULL WHERE finished_at IS NOT NULL
""").fetchone() """)
om = conn.execute(""" om = await conn.fetchrow("""
SELECT SELECT
COUNT(*) AS opportunity_count, COUNT(*) AS opportunity_count,
MIN(detected_at) AS first_detected_at, MIN(detected_at) AS first_detected_at,
MAX(detected_at) AS last_detected_at MAX(detected_at) AS last_detected_at
FROM opportunities FROM opportunities
""").fetchone() """)
fm = conn.execute(""" fm = await conn.fetchrow("""
SELECT AVG(filled_volume / volume) AS fill_rate SELECT AVG(filled_volume / volume) AS fill_rate
FROM orders FROM orders
WHERE volume > 0 AND filled_volume IS NOT NULL 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 r_pnl_usd = (
tt = int(tm[1]) if tm and tm[1] is not None else 0 float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
wt = int(tm[2]) if tm and tm[2] is not None else 0 )
tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
wt = int(tm["winning_trades"]) if tm and tm["winning_trades"] is not None else 0
wr = wt / tt if tt > 0 else None wr = wt / tt if tt > 0 else None
atd = float(tm[3]) if tm and tm[3] is not None else None atd = (
float(tm["avg_trade_duration_seconds"])
if tm and tm["avg_trade_duration_seconds"] is not None
else None
)
oc = int(om[0]) if om is not None and om[0] is not None else 0 oc = (
fo = om[1] if om is not None and isinstance(om[1], datetime) else None int(om["opportunity_count"])
lo = om[2] if om is not None and isinstance(om[2], datetime) else None if om is not None and om["opportunity_count"] is not None
else 0
)
fo = (
om["first_detected_at"]
if om is not None and isinstance(om["first_detected_at"], datetime)
else None
)
lo = (
om["last_detected_at"]
if om is not None and isinstance(om["last_detected_at"], datetime)
else None
)
opportunities_per_minute: float | None opportunities_per_minute: float | None
if oc >= 2 and fo is not None and lo is not None: if oc >= 2 and fo is not None and lo is not None:
@@ -82,11 +91,23 @@ class MetricsCalculator:
else: else:
opportunities_per_minute = None opportunities_per_minute = None
fill_rate = float(fm[0]) if fm and fm[0] is not None else None fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
lp50 = float(tm[4]) if tm and tm[4] is not None else None lp50 = (
lp95 = float(tm[5]) if tm and tm[5] is not None else None float(tm["latency_p50_seconds"])
lp99 = float(tm[6]) if tm and tm[6] is not None else None if tm and tm["latency_p50_seconds"] is not None
else None
)
lp95 = (
float(tm["latency_p95_seconds"])
if tm and tm["latency_p95_seconds"] is not None
else None
)
lp99 = (
float(tm["latency_p99_seconds"])
if tm and tm["latency_p99_seconds"] is not None
else None
)
return PerformanceMetrics( return PerformanceMetrics(
realized_pnl_usd=r_pnl_usd, realized_pnl_usd=r_pnl_usd,
+26 -24
View File
@@ -8,7 +8,7 @@ from typing import Any, cast
from fastapi import FastAPI from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState from arbitrade.api.control_state import DashboardControlState
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ( from arbitrade.storage.repositories import (
AuditRecord, AuditRecord,
AuditRepository, AuditRepository,
@@ -29,8 +29,8 @@ def _controls(app: FastAPI) -> DashboardControlState:
return cast(DashboardControlState, app.state.dashboard_controls) return cast(DashboardControlState, app.state.dashboard_controls)
def _store(app: FastAPI) -> DuckDBStore: def _store(app: FastAPI) -> PgStore:
return cast(DuckDBStore, app.state.store) return cast(PgStore, app.state.store)
def _audit_repository(app: FastAPI) -> AuditRepository | None: def _audit_repository(app: FastAPI) -> AuditRepository | None:
@@ -43,34 +43,34 @@ def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
return repository if isinstance(repository, RuntimeStateRepository) else None return repository if isinstance(repository, RuntimeStateRepository) else None
def _open_trade_count(store: DuckDBStore) -> int: async def _open_trade_count(store: PgStore) -> int:
with store.connect() as conn: async with store.pool.acquire() as conn:
row = conn.execute(""" row = await conn.fetchrow("""
SELECT COUNT(*) SELECT COUNT(*)
FROM trades FROM trades
WHERE finished_at IS NULL WHERE finished_at IS NULL
""").fetchone() """)
return int(row[0]) if row is not None else 0 return int(row[0]) if row is not None else 0
def _latest_balances(store: DuckDBStore) -> dict[str, Any] | None: async def _latest_balances(store: PgStore) -> dict[str, Any] | None:
with store.connect() as conn: async with store.pool.acquire() as conn:
row = conn.execute(""" row = await conn.fetchrow("""
SELECT balances SELECT balances
FROM portfolio_snapshots FROM portfolio_snapshots
ORDER BY snapshot_at DESC ORDER BY snapshot_at DESC
LIMIT 1 LIMIT 1
""").fetchone() """)
if row is None or row[0] is None: if row is None or row["balances"] is None:
return None return None
raw_balances = row[0] raw_balances = row["balances"]
if isinstance(raw_balances, str): if isinstance(raw_balances, str):
return {"raw": raw_balances} return {"raw": raw_balances}
return {"raw": str(raw_balances)} return {"raw": str(raw_balances)}
def _record_audit( async def _record_audit(
app: FastAPI, app: FastAPI,
*, *,
event_type: str, event_type: str,
@@ -80,7 +80,7 @@ def _record_audit(
repository = _audit_repository(app) repository = _audit_repository(app)
if repository is None: if repository is None:
return return
repository.insert( await repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="runtime", actor="runtime",
@@ -106,7 +106,9 @@ async def _run_startup_reconciler(app: FastAPI) -> None:
await result await result
def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> RuntimeStateRecord | None: async def persist_runtime_snapshot(
app: FastAPI, *, note: str | None = None
) -> RuntimeStateRecord | None:
repository = _runtime_repository(app) repository = _runtime_repository(app)
if repository is None: if repository is None:
return None return None
@@ -118,11 +120,11 @@ def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> Runtim
is_running=controls.is_running, is_running=controls.is_running,
kill_switch_active=controls.kill_switch.is_active, kill_switch_active=controls.kill_switch.is_active,
kill_switch_reason=controls.kill_switch.reason, kill_switch_reason=controls.kill_switch.reason,
open_trade_count=_open_trade_count(store), open_trade_count=await _open_trade_count(store),
last_known_balances=_latest_balances(store), last_known_balances=await _latest_balances(store),
note=note, note=note,
) )
repository.insert(snapshot) await repository.insert(snapshot)
return snapshot return snapshot
@@ -134,7 +136,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
restored_from_snapshot = False restored_from_snapshot = False
snapshot_at: str | None = None snapshot_at: str | None = None
latest = repo.latest() if repo is not None else None latest = await repo.latest() if repo is not None else None
if latest is not None: if latest is not None:
restored_from_snapshot = True restored_from_snapshot = True
snapshot_at = latest.snapshot_at.isoformat() snapshot_at = latest.snapshot_at.isoformat()
@@ -146,7 +148,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
ctl.kill_switch.deactivate() ctl.kill_switch.deactivate()
ctl.mark_updated() ctl.mark_updated()
open_trades = _open_trade_count(store) open_trades = await _open_trade_count(store)
restart_guard_active = False restart_guard_active = False
if open_trades > 0: if open_trades > 0:
ctl.is_running = False ctl.is_running = False
@@ -163,7 +165,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
) )
app.state.recovery_report = report app.state.recovery_report = report
_record_audit( await _record_audit(
app, app,
event_type="runtime.startup_recovery", event_type="runtime.startup_recovery",
decision="applied", decision="applied",
@@ -212,7 +214,7 @@ async def graceful_shutdown(app: FastAPI) -> None:
controls.is_running = False controls.is_running = False
controls.mark_updated() controls.mark_updated()
_record_audit( await _record_audit(
app, app,
event_type="runtime.shutdown", event_type="runtime.shutdown",
decision="initiated", decision="initiated",
@@ -220,4 +222,4 @@ async def graceful_shutdown(app: FastAPI) -> None:
) )
await drain_background_workers(app) await drain_background_workers(app)
persist_runtime_snapshot(app, note="graceful_shutdown") await persist_runtime_snapshot(app, note="graceful_shutdown")
-128
View File
@@ -1,128 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import duckdb
import structlog
from arbitrade.config.settings import Settings
_LOG = structlog.get_logger(__name__)
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT uuid(),
detected_at TIMESTAMP NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE,
net_pct DOUBLE,
est_profit DOUBLE,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
status VARCHAR NOT NULL,
realized_pnl DOUBLE,
estimated_pnl DOUBLE,
capital_used DOUBLE,
cycle VARCHAR,
leg_count INTEGER
);
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
order_ref VARCHAR NOT NULL,
leg_index INTEGER NOT NULL,
pair VARCHAR NOT NULL,
side VARCHAR NOT NULL,
volume DOUBLE NOT NULL,
user_ref INTEGER,
status VARCHAR,
filled_volume DOUBLE,
avg_price DOUBLE,
raw_response JSON,
recorded_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT uuid(),
trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMP NOT NULL,
kind VARCHAR NOT NULL,
pnl_usd DOUBLE NOT NULL,
source VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMP NOT NULL,
balances JSON,
total_value_usd DOUBLE
);
CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMP NOT NULL,
symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL,
payload JSON NOT NULL,
latency_ms DOUBLE
);
CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT uuid(),
occurred_at TIMESTAMP NOT NULL,
actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL,
payload JSON,
correlation_id VARCHAR
);
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMP NOT NULL,
is_running BOOLEAN NOT NULL,
kill_switch_active BOOLEAN NOT NULL,
kill_switch_reason VARCHAR,
open_trade_count INTEGER NOT NULL,
last_known_balances JSON,
note VARCHAR
);
"""
class DuckDBStore:
def __init__(self, settings: Settings) -> None:
self._db_path = Path(settings.duckdb_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._use_memory_fallback = False
@contextmanager
def connect(self) -> Iterator[duckdb.DuckDBPyConnection]:
try:
conn = duckdb.connect(str(self._db_path))
except duckdb.IOException:
if not self._use_memory_fallback:
_LOG.warning(
"duckdb_path_unavailable_falling_back_to_memory", path=str(self._db_path)
)
self._use_memory_fallback = True
conn = duckdb.connect(":memory:")
try:
yield conn
finally:
conn.close()
def migrate(self) -> None:
with self.connect() as conn:
conn.execute(SCHEMA_SQL)
+3 -3
View File
@@ -55,11 +55,11 @@ class AsyncExecutionWriter:
try: try:
if isinstance(record, TradeRecord): if isinstance(record, TradeRecord):
self._trade_repository.insert(record) await self._trade_repository.insert(record)
elif isinstance(record, OrderRecord): elif isinstance(record, OrderRecord):
self._order_repository.insert(record) await self._order_repository.insert(record)
else: else:
self._pnl_repository.insert(record) await self._pnl_repository.insert(record)
except Exception as exc: except Exception as exc:
_LOG.error("execution_write_failed", error=str(exc)) _LOG.error("execution_write_failed", error=str(exc))
finally: finally:
+1 -1
View File
@@ -49,7 +49,7 @@ class AsyncMarketSnapshotWriter:
continue continue
try: try:
self._repository.insert( await self._repository.insert(
MarketSnapshotRecord( MarketSnapshotRecord(
snapshot_at=item.snapshot_at, snapshot_at=item.snapshot_at,
symbol=item.symbol, symbol=item.symbol,
+1 -1
View File
@@ -38,7 +38,7 @@ class AsyncOpportunityWriter:
continue continue
try: try:
self._repository.insert( await self._repository.insert(
OpportunityRecord( OpportunityRecord(
detected_at=event.detected_at, detected_at=event.detected_at,
cycle=event.cycle, cycle=event.cycle,
+132
View File
@@ -0,0 +1,132 @@
"""PostgreSQL store — async connection pool wrapper around asyncpg."""
from __future__ import annotations
from pathlib import Path
import asyncpg
import structlog
from arbitrade.config.settings import Settings
_LOG = structlog.get_logger(__name__)
SCHEMA_VERSION = 1
class PgStore:
"""Async PostgreSQL connection pool for the arbitrade bot.
Wraps an ``asyncpg.Pool`` with schema migration support.
"""
def __init__(self, settings: Settings) -> None:
self._dsn: str | None = None
self._pool: asyncpg.Pool | None = None
self._settings = settings
# ── lifecycle ────────────────────────────────────────────────
async def start(self) -> None:
"""Create the connection pool."""
s = self._settings
self._pool = await asyncpg.create_pool(
host=s.pg_host,
port=s.pg_port,
database=s.pg_database,
user=s.pg_user,
password=s.pg_password,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
_LOG.info(
"pg_pool_created",
host=s.pg_host,
database=s.pg_database,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
async def stop(self) -> None:
"""Close the connection pool."""
if self._pool is not None:
await self._pool.close()
self._pool = None
_LOG.info("pg_pool_closed")
@property
def pool(self) -> asyncpg.Pool:
"""Return the underlying connection pool.
Raises ``RuntimeError`` if ``start()`` has not been called yet.
"""
if self._pool is None:
raise RuntimeError("PgStore not started — call start() first")
return self._pool
# ── schema migration ─────────────────────────────────────────
async def migrate(self) -> None:
"""Apply the PostgreSQL schema.
Reads ``schema_pg.sql`` from the same package directory and
executes it, then records the migration version.
"""
schema_path = Path(__file__).with_name("schema_pg.sql")
schema_sql = schema_path.read_text(encoding="utf-8")
async with self.pool.acquire() as conn:
# Apply the full schema (CREATE TABLE IF NOT EXISTS …)
await conn.execute(schema_sql)
# Record the current schema version
await conn.execute(
"""
INSERT INTO schema_migrations (version, applied_at)
VALUES ($1, CURRENT_TIMESTAMP)
ON CONFLICT (version) DO UPDATE SET applied_at = CURRENT_TIMESTAMP
""",
SCHEMA_VERSION,
)
_LOG.info("pg_schema_migrated", version=SCHEMA_VERSION)
# ── helpers ──────────────────────────────────────────────────
async def table_exists(self, table_name: str) -> bool:
"""Check if a table exists in the current schema."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return bool(row and row["cnt"] > 0)
async def get_table_columns(self, table_name: str) -> set[str]:
"""Return the set of column names for *table_name*."""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return {str(r["column_name"]) for r in rows}
async def ensure_column(self, table_name: str, column_def: str) -> None:
"""Add a column to *table_name* if it does not already exist.
``column_def`` should be something like ``"my_col VARCHAR"``.
"""
existing = await self.get_table_columns(table_name)
col_name = column_def.split()[0]
if col_name not in existing:
async with self.pool.acquire() as conn:
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_def}")
_LOG.info("pg_column_added", table=table_name, column=col_name)
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
-- PostgreSQL schema for arbitrade bot
-- Requires pgcrypto extension for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ========================================
-- Schema version tracking
-- ========================================
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ========================================
-- Configuration
-- ========================================
CREATE TABLE IF NOT EXISTS config_sections (
id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS config_settings (
key VARCHAR PRIMARY KEY,
section VARCHAR NOT NULL,
value_json TEXT NOT NULL,
value_type VARCHAR NOT NULL,
is_secret BOOLEAN DEFAULT FALSE,
is_runtime_reloadable BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR
);
CREATE TABLE IF NOT EXISTS config_pairings (
id SERIAL PRIMARY KEY,
base_asset VARCHAR NOT NULL,
quote_asset VARCHAR NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
source VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(base_asset, quote_asset)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id SERIAL PRIMARY KEY,
starting_balances JSONB,
trade_capital DOUBLE PRECISION,
min_profit_threshold DOUBLE PRECISION,
slippage_bps INTEGER,
execution_latency_ms INTEGER,
fee_source VARCHAR DEFAULT 'api'
);
-- ========================================
-- Detection & Execution
-- ========================================
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
detected_at TIMESTAMPTZ NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE PRECISION,
net_pct DOUBLE PRECISION,
est_profit DOUBLE PRECISION,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
status VARCHAR NOT NULL,
realized_pnl DOUBLE PRECISION,
estimated_pnl DOUBLE PRECISION,
capital_used DOUBLE PRECISION,
cycle VARCHAR,
leg_count INTEGER
);
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
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 PRECISION NOT NULL,
user_ref INTEGER,
status VARCHAR,
filled_volume DOUBLE PRECISION,
avg_price DOUBLE PRECISION,
raw_response JSONB,
recorded_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
kind VARCHAR NOT NULL,
pnl_usd DOUBLE PRECISION NOT NULL,
source VARCHAR NOT NULL
);
-- ========================================
-- Snapshots & Monitoring
-- ========================================
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
balances JSONB,
total_value_usd DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL,
payload JSONB NOT NULL,
latency_ms DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL,
payload JSONB,
correlation_id VARCHAR
);
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMPTZ 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 JSONB,
note VARCHAR
);
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE PRECISION,
taker_fee DOUBLE PRECISION,
thirty_day_volume DOUBLE PRECISION,
trade_balance_raw JSONB,
fee_schedule_raw JSONB
);
-- ========================================
-- Backtesting
-- ========================================
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSONB,
report JSONB,
error VARCHAR,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
-- ========================================
-- Migration: convert legacy TIMESTAMP→TIMESTAMPTZ
-- for databases created before the fix.
-- These are idempotent (no-op when already TIMESTAMPTZ).
-- ========================================
ALTER TABLE audit_events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at AT TIME ZONE 'UTC';
ALTER TABLE runtime_state_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE schema_migrations ALTER COLUMN applied_at TYPE TIMESTAMPTZ USING applied_at AT TIME ZONE 'UTC';
ALTER TABLE config_sections ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_settings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE opportunities ALTER COLUMN detected_at TYPE TIMESTAMPTZ USING detected_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
ALTER TABLE orders ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE pnl_events ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE portfolio_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE market_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE kraken_account_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
-- ========================================
-- Logging tables
-- ========================================
CREATE TABLE IF NOT EXISTS app_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB
);
CREATE INDEX IF NOT EXISTS idx_app_logs_recorded_at ON app_logs (recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs (level);
CREATE TABLE IF NOT EXISTS app_log_archives (
id UUID PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB,
archived_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_log_archives_recorded_at ON app_log_archives (recorded_at DESC);
CREATE TABLE IF NOT EXISTS app_log_aggregates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
bucket_start TIMESTAMPTZ NOT NULL,
period VARCHAR NOT NULL,
level VARCHAR NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_start, period, level)
);
CREATE INDEX IF NOT EXISTS idx_app_log_aggregates_bucket ON app_log_aggregates (bucket_start DESC, period);
@@ -14,9 +14,8 @@
color: #e5eefb; color: #e5eefb;
} }
.shell { .shell {
max-width: 1120px; max-width: none;
margin: 0 auto; padding: 24px 32px 48px;
padding: 32px 20px 48px;
} }
.hero { .hero {
display: flex; display: flex;
@@ -141,7 +140,7 @@
</head> </head>
<body> <body>
<main class="{% block main_class %}shell{% endblock %}"> <main class="{% block main_class %}shell{% endblock %}">
{% block content %}{% endblock %} {% block header %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
+31
View File
@@ -0,0 +1,31 @@
<section class="hero">
<div>
<h1 class="title">{{ page_title }}</h1>
<p class="subtitle">{{ page_subtitle }}</p>
</div>
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
"secondary"}, {"url": "/dashboard/config/pairings", "label": "Pairings",
"class": "secondary"}, {"url": "/dashboard/backtesting", "label":
"Backtesting", "class": "secondary"}, {"url": "/dashboard/health", "label":
"Health", "class": "secondary"}, ] %}
<div class="toolbar">
{% for link in nav_links %}
<a
class="button{% if link.class %} {{ link.class }}{% endif %}"
href="{{ link.url }}"
{%
if
link.hx_get
%}hx-get="{{ link.hx_get }}"
hx-target="{{ link.hx_target }}"
hx-swap="{{ link.hx_swap | default('outerHTML') }}"
{%
endif
%}
>
{{ link.label }}
</a>
{% endfor %}
</div>
</section>
+16
View File
@@ -0,0 +1,16 @@
{% extends "_base.html" %} {% block title %}Audit Trail{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with page_title="Audit
Trail", page_subtitle="System activity, configuration changes, and execution
decisions." %} {% include "_header.html" %} {% endwith %} {% endblock %} {%
block content %}
<section
id="audit-shell"
hx-get="/dashboard/audit/fragment"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Backtesting", page_subtitle="Replay controls, run status, and recent
summary reports." %} {% include "_header.html" %} {% endwith %} {% endblock %}
{% block content %}
<section
id="backtesting-shell"
hx-get="{{ panel_endpoint }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/backtesting_panel.html" %}
</section>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Configuration", page_subtitle="Runtime settings, alerts, exchange,
risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock
%} {% block content %}
<section
id="config-shell"
hx-get="/dashboard/fragment/config"
hx-target="this"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
{% include "partials/config.html" %}
</section>
{% endblock %}
@@ -0,0 +1,192 @@
<div class="card">
<div class="label">Alerting</div>
<label class="field checkbox">
<input name="alerts_enabled" type="checkbox" {{ alerts_enabled }} />
<span>Alerts enabled</span>
</label>
<label class="field">
<span>Min severity</span>
<select name="alert_min_severity">
{% for sev in ["info", "warning", "error", "critical"] %} {% set sel =
"selected" if alert_min_severity == sev else "" %}
<option value="{{ sev }}" {{ sel }}>{{ sev }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Dedup seconds</span>
<input
name="alert_dedup_seconds"
type="number"
min="0"
step="1"
value="{{ alert_dedup_seconds }}"
/>
</label>
<label class="field checkbox">
<input
name="alert_on_trade_events"
type="checkbox"
{{
alert_on_trade_events
}}
/>
<span>Trade events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_error_events"
type="checkbox"
{{
alert_on_error_events
}}
/>
<span>Error events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_threshold_events"
type="checkbox"
{{
alert_on_threshold_events
}}
/>
<span>Threshold events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_system_events"
type="checkbox"
{{
alert_on_system_events
}}
/>
<span>System events</span>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="telegram_alerts_enabled"
type="checkbox"
{{
telegram_alerts_enabled
}}
/>
<span>Telegram</span>
</label>
<label class="field">
<span>Telegram bot token</span>
<input
name="telegram_bot_token"
type="password"
value="{{ telegram_bot_token }}"
placeholder="Bot token"
/>
</label>
<label class="field">
<span>Telegram chat ID</span>
<input
name="telegram_chat_id"
type="text"
value="{{ telegram_chat_id }}"
placeholder="Chat ID"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="discord_alerts_enabled"
type="checkbox"
{{
discord_alerts_enabled
}}
/>
<span>Discord</span>
</label>
<label class="field">
<span>Discord webhook URL</span>
<input
name="discord_webhook_url"
type="password"
value="{{ discord_webhook_url }}"
placeholder="Webhook URL"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="email_alerts_enabled"
type="checkbox"
{{
email_alerts_enabled
}}
/>
<span>Email</span>
</label>
<label class="field">
<span>SMTP host</span>
<input
name="email_smtp_host"
type="text"
value="{{ email_smtp_host }}"
placeholder="smtp.example.com"
/>
</label>
<label class="field">
<span>SMTP port</span>
<input
name="email_smtp_port"
type="number"
min="1"
max="65535"
value="{{ email_smtp_port }}"
/>
</label>
<label class="field">
<span>SMTP username</span>
<input
name="email_smtp_username"
type="text"
value="{{ email_smtp_username }}"
/>
</label>
<label class="field">
<span>SMTP password</span>
<input
name="email_smtp_password"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>From address</span>
<input name="email_alert_from" type="text" value="{{ email_alert_from }}" />
</label>
<label class="field">
<span>To address</span>
<input name="email_alert_to" type="text" value="{{ email_alert_to }}" />
</label>
<label class="field checkbox">
<input name="email_smtp_use_tls" type="checkbox" {{ email_smtp_use_tls }} />
<span>Use TLS</span>
</label>
</div>
@@ -0,0 +1,93 @@
<div class="card">
<div class="label">Kraken Exchange</div>
<label class="field">
<span>REST URL</span>
<input name="kraken_rest_url" type="text" value="{{ kraken_rest_url }}" />
</label>
<label class="field">
<span>WebSocket URL</span>
<input name="kraken_ws_url" type="text" value="{{ kraken_ws_url }}" />
</label>
<label class="field">
<span>Private rate limit (s)</span>
<input
name="kraken_private_rate_limit_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_private_rate_limit_seconds }}"
/>
</label>
<label class="field">
<span>HTTP timeout (s)</span>
<input
name="kraken_http_timeout_seconds"
type="number"
min="1"
step="0.5"
value="{{ kraken_http_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Retry attempts</span>
<input
name="kraken_retry_attempts"
type="number"
min="0"
step="1"
value="{{ kraken_retry_attempts }}"
/>
</label>
<label class="field">
<span>Retry base delay (s)</span>
<input
name="kraken_retry_base_delay_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_retry_base_delay_seconds }}"
/>
</label>
<label class="field">
<span>API key</span>
<input name="kraken_api_key" type="text" value="{{ kraken_api_key }}" />
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>API key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
disabled
/>
</label>
<label class="field">
<span>WS heartbeat timeout (s)</span>
<input
name="ws_heartbeat_timeout_seconds"
type="number"
min="1"
step="1"
value="{{ ws_heartbeat_timeout_seconds }}"
/>
</label>
<label class="field">
<span>WS max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="1"
step="1"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
@@ -0,0 +1,57 @@
<div class="card">
<div class="label">Risk & Guardrails</div>
<label class="field">
<span>Daily loss limit USD</span>
<input
name="daily_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ daily_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Cumulative loss limit USD</span>
<input
name="cumulative_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ cumulative_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Max source latency (ms)</span>
<input
name="max_source_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_source_latency_value }}"
/>
</label>
<label class="field">
<span>Max apply latency (ms)</span>
<input
name="max_apply_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_apply_latency_value }}"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="0"
step="1"
value="{{ max_consecutive_failures_value }}"
/>
</label>
<label class="field checkbox">
<input name="kill_switch_active" type="checkbox" {{ kill_switch_active }} />
<span>Kill switch active</span>
</label>
</div>
@@ -0,0 +1,140 @@
<div class="card">
<div class="label">Runtime</div>
<label class="field">
<span>App env</span>
<input type="text" value="{{ app_env }}" disabled />
</label>
<label class="field">
<span>App host</span>
<input name="app_host" type="text" value="{{ app_host }}" />
</label>
<label class="field">
<span>App port</span>
<input
name="app_port"
type="number"
min="1"
max="65535"
value="{{ app_port }}"
/>
</label>
<label class="field">
<span>Log level</span>
<select name="log_level">
{% for lvl in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] %} {% set
sel = "selected" if log_level == lvl else "" %}
<option value="{{ lvl }}" {{ sel }}>{{ lvl }}</option>
{% endfor %}
</select>
</label>
<label class="field checkbox">
<input name="log_json" type="checkbox" {{ log_json }} />
<span>JSON logs</span>
</label>
<label class="field checkbox">
<input name="paper_trading_mode" type="checkbox" {{ paper_trading_mode }} />
<span>Paper trading mode</span>
</label>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Max exposure per asset USD</span>
<input
name="max_exposure_per_asset_usd"
type="number"
min="0"
step="0.01"
value="{{ max_exposure_per_asset_value }}"
/>
</label>
<label class="field">
<span>Quote balance asset</span>
<input
name="quote_balance_asset"
type="text"
value="{{ quote_balance_asset }}"
/>
</label>
<label class="field">
<span>Min order size USD</span>
<input
name="min_order_size_usd"
type="number"
min="0"
step="0.01"
value="{{ min_order_size_usd_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs (comma-separated)</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>stat_arb_experiment</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
</div>
@@ -1,26 +1,11 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block {% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
head_scripts %} head_scripts %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %} {% endblock %} {% block header %} {% with page_title="Arbitrade Dashboard",
<section class="hero"> page_subtitle="Live execution, P&L, and system state." %} {% include
<div> "_header.html" %} {% endwith %} {% endblock %} {% block main_class %}shell{%
<h1 class="title">Arbitrade Dashboard</h1> endblock %} {% block content %}
<p class="subtitle">Live execution, P&amp;L, and system state.</p>
</div>
<div class="toolbar">
<a
class="button"
href="{{ metrics_endpoint }}"
hx-get="{{ metrics_endpoint }}"
hx-target="#metrics-panel"
hx-swap="outerHTML"
>Refresh metrics</a
>
<a class="button secondary" href="/health">Health</a>
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
</div>
</section>
<section <section
id="metrics-shell" id="metrics-shell"
@@ -62,15 +47,6 @@ head_scripts %}
{% include "partials/charts.html" %} {% include "partials/charts.html" %}
</section> </section>
<section
id="audit-shell"
hx-get="{{ audit_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
window.arbitradeRenderCharts = (payload) => { window.arbitradeRenderCharts = (payload) => {
@@ -0,0 +1,153 @@
{% extends "base.html" %} {% block title %}Currency Pairings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Currency Pairings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<!-- Create Pairing Form -->
<div class="card mb-4">
<div class="card-header">
<h5>Create New Pairing</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/pairs/create">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="base_asset" class="form-label">Base Asset</label>
<input
type="text"
class="form-control"
id="base_asset"
name="base_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="quote_asset" class="form-label"
>Quote Asset</label
>
<input
type="text"
class="form-control"
id="quote_asset"
name="quote_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="source" class="form-label">Source</label>
<input
type="text"
class="form-control"
id="source"
name="source"
placeholder="e.g., Kraken, Binance"
/>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input
type="checkbox"
class="form-check-input"
id="enabled"
name="enabled"
checked
/>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<button type="submit" class="btn btn-primary">
Create Pairing
</button>
</form>
</div>
</div>
<!-- Existing Pairings Table -->
<div class="card">
<div class="card-header">
<h5>Existing Pairings</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Base Asset</th>
<th>Quote Asset</th>
<th>Enabled</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>{{ pairing.base_asset }}</td>
<td>{{ pairing.quote_asset }}</td>
<td>
{% if pairing.enabled %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</td>
<td>{{ pairing.source or '—' }}</td>
<td>
<form
method="post"
action="/dashboard/config/pairs/delete"
style="display: inline"
>
<input
type="hidden"
name="base_asset"
value="{{ pairing.base_asset }}"
/>
<input
type="hidden"
name="quote_asset"
value="{{ pairing.quote_asset }}"
/>
<button
type="submit"
class="btn btn-sm btn-danger"
onclick="
return confirm(
'Are you sure you want to delete this pairing?',
);
"
>
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %} {% block title %}Application Settings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Application Settings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Settings</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/settings/save">
{% for section_name, settings in settings_by_section.items() %}
<div class="mb-4">
<h5>{{ section_name }}</h5>
{% for setting in settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label"
>{{ setting.key }}</label
>
<input
type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value_json }}"
/>
</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
{% block header %} {% with page_title="Arbitrade Health Check",
page_subtitle="Live system state and logs." %} {% include "_header.html" %} {%
endwith %} {% endblock %} {% block main_class %}shell{% endblock %} {% block
content %}
<section class="card" style="margin-bottom: 24px">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a
href="/health"
hx-get="/health"
hx-target="#health-json"
hx-swap="innerHTML"
>refresh</a
>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
<section class="card">
<h2>System Logs</h2>
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
<form
hx-post="/dashboard/api/logging/aggregate"
hx-target="#aggregate-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Aggregate Now
</button>
</form>
<form
hx-post="/dashboard/api/logging/archive"
hx-target="#archive-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Archive Old Logs
</button>
</form>
<span id="aggregate-result" style="font-size: 0.85rem; opacity: 0.6"></span>
<span id="archive-result" style="font-size: 0.85rem; opacity: 0.6"></span>
</div>
<div
id="log-table-container"
hx-get="/dashboard/fragment/logs"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
<div style="text-align: center; padding: 20px; opacity: 0.5">
Loading logs...
</div>
</div>
</section>
{% endblock %} {% block scripts %}{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Currency Pairings",
page_subtitle="Enable/disable pairings, search, and sync from Kraken." %}
{% include "_header.html" %} {% endwith %} {% endblock %} {% block content %}
<div class="toolbar" style="margin-bottom: 16px; display: flex; gap: 8px">
<input
id="pairing-search"
type="text"
placeholder="Search pairings…"
value="{{ search or '' }}"
style="flex: 1; max-width: 300px"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="keyup changed delay:300ms"
hx-include="#pairing-enabled-filter"
name="search"
/>
<select
id="pairing-enabled-filter"
name="enabled"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="change"
hx-include="#pairing-search"
>
<option value="all">All</option>
<option value="true" {{ 'selected' if enabled == 'true' else '' }}>
Enabled
</option>
<option value="false" {{ 'selected' if enabled == 'false' else '' }}>
Disabled
</option>
</select>
<button
class="button"
hx-post="/dashboard/api/pairings/sync"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
>
Sync from Kraken
</button>
</div>
<div id="pairings-table-container">
{% include "partials/pairings_table.html" %}
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% for p in pairings %}
<label
style="
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
"
>
<input
type="checkbox"
name="symbols"
value="{{ p.base_asset }}/{{ p.quote_asset }}"
{%
if
p.enabled
%}checked{%
endif
%}
/>
{{ p.base_asset }}/{{ p.quote_asset }}
</label>
{% endfor %} {% if not pairings %}
<span style="opacity: 0.5"
>No pairings available. Sync from Kraken in config page.</span
>
{% endif %}
@@ -0,0 +1,275 @@
<div id="backtesting-shell" class="panel">
<div
class="grid"
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"
>
<article class="card">
<div class="label">Run Status</div>
<div class="value">{{ status }}</div>
<div class="meta">{{ message }}</div>
</article>
<article class="card">
<div class="label">Latest Report</div>
{% if latest_report and latest_report.report and 'processed_events' in
latest_report.report %}
<div class="meta">Run at {{ latest_report.run_at }}</div>
<div class="meta">Events: {{ latest_report.events_path }}</div>
<div class="meta">
Processed: {{ latest_report.report.processed_events }}
</div>
<div class="meta">
Opportunities: {{ latest_report.report.opportunities_seen }}
</div>
<div class="meta">Trades: {{ latest_report.report.trades_executed }}</div>
<div class="meta">
Realized P&amp;L: {{
'%.4f'|format(latest_report.report.realized_pnl_usd) }} USD
</div>
<div class="meta">
Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }}
USD
</div>
{% elif latest_report %}
<div class="meta">
Job {{ latest_report.get('job_id','')[:8] }}... {{ latest_report.status
}}
</div>
<div class="meta">
{{ latest_report.get('error','Waiting for worker...') }}
</div>
{% else %}
<div class="meta">No runs yet.</div>
{% endif %}
</article>
</div>
<article class="card" style="margin-top: 16px">
<div class="label">Run Backtest</div>
{% if no_enabled_pairings %}
<div
class="flash"
style="
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
color: #ffe58f;
font-size: 0.9rem;
"
>
No enabled pairings found. Enable at least one pairing on the
<a href="/dashboard/config/pairings" style="color: #ffe58f"
>Pairings page</a
>
before running a backtest.
</div>
{% endif %} {% if flash_message %}
<div
class="flash"
style="
background: rgba(82, 196, 26, 0.15);
border: 1px solid rgba(82, 196, 26, 0.3);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
color: #b7eb8f;
font-size: 0.9rem;
"
hx-trigger="load delay:5s"
hx-target="this"
hx-swap="delete"
>
{{ flash_message }}
</div>
{% endif %}
<form
class="form-grid"
hx-post="{{ run_endpoint }}"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
>
<input type="hidden" name="source" value="db" />
<input type="hidden" name="symbols" value="" />
<div class="meta" style="margin-bottom: 12px">
Pairings managed in
<a href="/dashboard/config/pairings">Configuration → Pairings</a>. Only
enabled pairings are backtested.
</div>
<!-- Required fields -->
<label class="field">
<span>Starting balances <span style="color: #ff4d4f">*</span></span>
<input
name="starting_balances"
type="text"
value="{{ starting_balances }}"
placeholder="USD=1000.0,BTC=0.0"
/>
</label>
<label class="field">
<span>Start time <span style="color: #ff4d4f">*</span></span>
<input
name="start_time"
type="text"
value="{{ start_time | default('') }}"
placeholder="2025-01-01T00:00:00"
/>
</label>
<label class="field">
<span>End time <span style="color: #ff4d4f">*</span></span>
<input
name="end_time"
type="text"
value="{{ end_time | default('') }}"
placeholder="2025-01-02T00:00:00"
/>
</label>
<label class="field">
<span>Min profit threshold <span style="color: #ff4d4f">*</span></span>
<input
name="min_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ min_profit_threshold }}"
/>
</label>
<!-- Advanced -->
<details style="grid-column: 1 / -1; margin-top: 8px">
<summary style="cursor: pointer; opacity: 0.7; font-size: 0.85rem">
Advanced options (fee profile, slippage, latency)
</summary>
<div
class="form-grid"
style="
margin-top: 12px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
"
>
<label class="field">
<span>Fee profile</span>
<select name="fee_profile">
{% set sel = "selected" if fee_profile == "api" else "" %}
<option value="api" {{ sel }}>api (from Kraken)</option>
{% set sel = "selected" if fee_profile == "standard" else "" %}
<option value="standard" {{ sel }}>standard</option>
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
<option value="maker_heavy" {{ sel }}>maker_heavy</option>
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
<option value="taker_heavy" {{ sel }}>taker_heavy</option>
{% set sel = "selected" if fee_profile == "custom" else "" %}
<option value="custom" {{ sel }}>custom</option>
</select>
</label>
<label class="field">
<span>Custom fee rate (if custom profile)</span>
<input
name="custom_fee_rate"
type="number"
min="0"
step="0.0001"
value="{{ custom_fee_rate }}"
/>
</label>
<label class="field">
<span>Slippage (bps)</span>
<input
name="slippage_bps"
type="number"
min="0"
step="0.1"
value="{{ slippage_bps }}"
/>
</label>
<label class="field">
<span>Execution latency (ms)</span>
<input
name="execution_latency_ms"
type="number"
min="0"
step="0.1"
value="{{ execution_latency_ms }}"
/>
</label>
</div>
</details>
<button type="submit" class="button">Submit Job</button>
</form>
</article>
<article class="card" style="margin-top: 16px">
<div class="label">Recent Jobs</div>
{% if recent_reports %}
<div style="overflow-x: auto">
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem">
<thead>
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.14)">
<th style="text-align: left; padding: 8px; color: #9fb2d0">Job</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Status
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Events
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Trades
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">P&L</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0">
Created
</th>
<th style="text-align: left; padding: 8px; color: #9fb2d0"></th>
</tr>
</thead>
<tbody>
{% for item in recent_reports %}
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.06)">
<td style="padding: 8px">
<button
class="button secondary"
style="padding: 2px 8px; font-size: 0.8rem"
hx-get="/dashboard/backtesting/job/{{ item.job_id }}"
hx-target="#job-detail-{{ loop.index }}"
hx-swap="innerHTML"
>
{{ item.job_id[:8] }}...
</button>
</td>
<td style="padding: 8px">{{ item.status }}</td>
<td style="padding: 8px; color: #7f95b7">{{ item.events_path }}</td>
<td style="padding: 8px">
{{ item.report.trades_executed if item.report else "—" }}
</td>
<td style="padding: 8px">
{{ '%.2f'|format(item.report.realized_pnl_usd) if item.report and
item.report.realized_pnl_usd else "—" }}
</td>
<td style="padding: 8px; color: #7f95b7">{{ item.run_at[:19] }}</td>
<td style="padding: 8px">
<button
class="button danger"
style="padding: 2px 8px; font-size: 0.8rem"
hx-post="/dashboard/backtesting/job/{{ item.job_id }}/delete"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
onclick="return confirm('Delete job {{ item.job_id[:8] }}...?');"
>
Del
</button>
</td>
</tr>
<tr id="job-detail-{{ loop.index }}"></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="meta">No jobs submitted yet.</div>
{% endif %}
</article>
</div>
@@ -7,7 +7,7 @@
<div class="chart-head"> <div class="chart-head">
<div> <div>
<div class="label">Opportunity Trend</div> <div class="label">Opportunity Trend</div>
<div class="meta">Recent opportunities from DuckDB. Updated {{ generated_at }}</div> <div class="meta">Recent opportunities from PostgreSQL. Updated {{ generated_at }}</div>
</div> </div>
<button type="button" class="button secondary" x-on:click="expanded = !expanded"> <button type="button" class="button secondary" x-on:click="expanded = !expanded">
<span x-text="expanded ? 'Hide chart' : 'Show chart'"></span> <span x-text="expanded ? 'Hide chart' : 'Show chart'"></span>
@@ -0,0 +1,37 @@
<div id="config-panel" class="panel" style="margin-top: 16px">
{% if flash_message %}
<div
class="flash"
style="
background: rgba(82, 196, 26, 0.15);
border: 1px solid rgba(82, 196, 26, 0.3);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
color: #b7eb8f;
font-size: 0.9rem;
"
hx-trigger="load delay:3s"
hx-target="this"
hx-swap="delete"
>
{{ flash_message }}
</div>
{% endif %}
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#config-panel"
hx-swap="outerHTML"
style="
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
"
>
{% include "config/runtime.html" %} {% include "config/alerts.html" %} {%
include "config/kraken.html" %} {% include "config/risk.html" %}
<div style="grid-column: 1 / -1">
<button type="submit" class="button">Save Settings</button>
</div>
</form>
</div>
@@ -75,97 +75,5 @@
</form> </form>
</div> </div>
</article> </article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>
stat_arb_experiment
</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div> </div>
</div> </div>
@@ -0,0 +1,72 @@
<div id="log-table-container">
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
<select
name="level"
hx-get="/dashboard/fragment/logs"
hx-target="#log-table-container"
hx-trigger="change"
hx-swap="outerHTML"
>
<option value="" {{ 'selected' if current_level == 'all' else '' }}>All</option>
<option value="INFO" {{ 'selected' if current_level == 'INFO' else '' }}>INFO</option>
<option value="WARNING" {{ 'selected' if current_level == 'WARNING' else '' }}>WARNING</option>
<option value="ERROR" {{ 'selected' if current_level == 'ERROR' else '' }}>ERROR</option>
<option value="CRITICAL" {{ 'selected' if current_level == 'CRITICAL' else '' }}>CRITICAL</option>
</select>
<span style="opacity: 0.6; font-size: 0.85rem">{{ total }} entries</span>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left">
<th style="padding: 6px 8px">Time</th>
<th style="padding: 6px 8px">Level</th>
<th style="padding: 6px 8px">Logger</th>
<th style="padding: 6px 8px">Message</th>
</tr>
</thead>
<tbody>
{% for r in records %}
<tr style="border-bottom: 1px solid rgba(255,255,255,0.04)">
<td style="padding: 4px 8px; white-space: nowrap">
{{ r.recorded_at.strftime('%H:%M:%S') if r.recorded_at else '—' }}
</td>
<td style="padding: 4px 8px">
<span class="badge level-{{ r.level.lower() }}">{{ r.level }}</span>
</td>
<td style="padding: 4px 8px; opacity: 0.7; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ r.logger }}
</td>
<td style="padding: 4px 8px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ r.message }}
</td>
</tr>
{% endfor %}
{% if not records %}
<tr>
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">No log entries found.</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="toolbar" style="display: flex; gap: 8px; justify-content: center; margin-top: 12px">
{% if page > 1 %}
<button
class="button secondary"
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page - 1 }}"
hx-target="#log-table-container"
hx-swap="outerHTML"
>Previous</button>
{% endif %}
<span style="opacity: 0.6; font-size: 0.85rem; padding: 0 8px">Page {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<button
class="button secondary"
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page + 1 }}"
hx-target="#log-table-container"
hx-swap="outerHTML"
>Next</button>
{% endif %}
</div>
</div>
@@ -6,7 +6,7 @@
</article> </article>
<article class="card"> <article class="card">
<div class="label">Balances</div> <div class="label">Balances</div>
<div class="value">{{ balances }}</div> <div class="value">{{ balances | safe }}</div>
</article> </article>
<article class="card"> <article class="card">
<div class="label">Open Trades</div> <div class="label">Open Trades</div>
@@ -16,6 +16,13 @@
<div class="label">Realized P&amp;L</div> <div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl_total }}</div> <div class="value">{{ realized_pnl_total }}</div>
</article> </article>
<article class="card">
<div class="label">Fee Tier</div>
<div class="value">{{ fee_tier }}</div>
<div class="meta">
Maker {{ maker_fee }} / Taker {{ taker_fee }} &middot; {{ fee_source }}
</div>
</article>
</div> </div>
<div <div
@@ -44,9 +51,10 @@
class="value" class="value"
style="font-size: 1rem; font-weight: 500; word-break: break-word" style="font-size: 1rem; font-weight: 500; word-break: break-word"
> >
{{ balances }} {{ balances | safe }}
</div> </div>
<div class="meta">Total value {{ total_value }}</div> <div class="meta">Total value {{ total_value }}</div>
<div class="meta">Equity {{ equity }}</div>
</article> </article>
<article class="card"> <article class="card">
<div class="label">Opportunity Feed</div> <div class="label">Opportunity Feed</div>
@@ -0,0 +1,50 @@
<table
class="pairings-table"
style="width: 100%; border-collapse: collapse; font-size: 0.85rem"
>
<thead>
<tr
style="
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: left;
"
>
<th style="padding: 6px 8px">Base</th>
<th style="padding: 6px 8px">Quote</th>
<th style="padding: 6px 8px">Source</th>
<th style="padding: 6px 8px; text-align: center">Enabled</th>
</tr>
</thead>
<tbody>
{% for p in pairings %}
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.05)">
<td style="padding: 6px 8px">{{ p.base_asset }}</td>
<td style="padding: 6px 8px">{{ p.quote_asset }}</td>
<td style="padding: 6px 8px; opacity: 0.6">{{ p.source }}</td>
<td style="padding: 6px 8px; text-align: center">
<input
type="checkbox"
hx-post="/dashboard/api/pairings/toggle"
hx-vals='{"base_asset": "{{ p.base_asset }}", "quote_asset": "{{ p.quote_asset }}"}'
hx-trigger="change"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
hx-include="#pairing-search"
{%
if
p.enabled
%}checked{%
endif
%}
/>
</td>
</tr>
{% endfor %} {% if not pairings %}
<tr>
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">
No pairings found. Click "Sync from Kraken" to fetch available pairs.
</td>
</tr>
{% endif %}
</tbody>
</table>
+1
View File
@@ -0,0 +1 @@
"""End-to-end tests — require full app startup with PostgreSQL."""
+1
View File
@@ -0,0 +1 @@
"""Integration tests for PostgreSQL schema and connectivity."""
+27
View File
@@ -0,0 +1,27 @@
"""pytest configuration for integration tests.
Integration tests require a live PostgreSQL server at the configured host.
They are skipped automatically if the server is unreachable.
"""
from __future__ import annotations
import pathlib
import pytest
def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool:
"""Skip integration tests unless --integration is passed."""
if "integration" in str(collection_path) and not config.getoption("--integration", False):
return True
return False
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--integration",
action="store_true",
default=False,
help="Run integration tests (requires PostgreSQL)",
)
@@ -0,0 +1,51 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_audit_repository_inserts_and_lists_recent() -> None:
async with _pg() as store:
repository = AuditRepository(store)
await repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = await repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
@@ -170,7 +170,6 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert 'hx-get="/dashboard/fragment/metrics"' in page.text assert 'hx-get="/dashboard/fragment/metrics"' in page.text
assert 'hx-get="/dashboard/fragment/controls"' in page.text assert 'hx-get="/dashboard/fragment/controls"' in page.text
assert 'hx-get="/dashboard/fragment/charts"' in page.text assert 'hx-get="/dashboard/fragment/charts"' in page.text
assert 'hx-get="/dashboard/fragment/audit"' in page.text
assert fragment.status_code == 200 assert fragment.status_code == 200
assert "Realized P&amp;L" in fragment.text assert "Realized P&amp;L" in fragment.text
@@ -191,18 +190,16 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert "trade-open" in overview.text assert "trade-open" in overview.text
assert overview_stream.status_code == 200 assert overview_stream.status_code == 200
assert overview_stream.headers["content-type"].startswith( assert overview_stream.headers["content-type"].startswith("text/event-stream")
"text/event-stream")
assert "event: overview" in overview_stream.text assert "event: overview" in overview_stream.text
assert "trade-open" in overview_stream.text assert "trade-open" in overview_stream.text
assert controls.status_code == 200 assert controls.status_code == 200
assert "Runtime Status" in controls.text assert "Runtime Status" in controls.text
assert ">running<" in controls.text assert "running" in controls.text
assert "Alerting" in controls.text assert "Alerting" in controls.text
assert "Last result" in controls.text assert "Last result" in controls.text
assert "Paper trading mode" in controls.text assert "Paper trading" in controls.text
assert "Trade capital USD" in controls.text
assert "Tradable pairs" in controls.text assert "Tradable pairs" in controls.text
assert "Strategy mode" in controls.text assert "Strategy mode" in controls.text
@@ -262,8 +259,7 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
assert app.state.settings.max_trade_capital_usd == 300.0 assert app.state.settings.max_trade_capital_usd == 300.0
assert app.state.settings.max_concurrent_trades == 4 assert app.state.settings.max_concurrent_trades == 4
assert app.state.settings.paper_trading_mode is True assert app.state.settings.paper_trading_mode is True
assert app.state.dashboard_controls.tradable_pairs == [ assert app.state.dashboard_controls.tradable_pairs == ["BTC/USD", "ETH/BTC"]
"BTC/USD", "ETH/BTC"]
assert app.state.dashboard_controls.strategy_mode == "paper" assert app.state.dashboard_controls.strategy_mode == "paper"
assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025 assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025
assert app.state.dashboard_controls.strategy_max_depth_levels == 7 assert app.state.dashboard_controls.strategy_max_depth_levels == 7
@@ -275,14 +271,10 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
assert audit_recent.status_code == 200 assert audit_recent.status_code == 200
entries = audit_recent.json()["entries"] entries = audit_recent.json()["entries"]
assert len(entries) >= 4 assert len(entries) >= 4
assert any(entry["event_type"] == assert any(entry["event_type"] == "dashboard.control.stop" for entry in entries)
"dashboard.control.stop" for entry in entries) assert any(entry["event_type"] == "dashboard.control.start" for entry in entries)
assert any(entry["event_type"] == assert any(entry["event_type"] == "dashboard.control.kill_switch" for entry in entries)
"dashboard.control.start" for entry in entries) assert any(entry["event_type"] == "dashboard.control.config" for entry in entries)
assert any(entry["event_type"] ==
"dashboard.control.kill_switch" for entry in entries)
assert any(entry["event_type"] ==
"dashboard.control.config" for entry in entries)
async def test_dashboard_controls_emit_alerts(tmp_path) -> None: async def test_dashboard_controls_emit_alerts(tmp_path) -> None:
@@ -363,11 +355,11 @@ async def test_backtesting_page_run_and_recent_reports_api(tmp_path) -> None:
run = await client.post( run = await client.post(
"/dashboard/backtesting/run", "/dashboard/backtesting/run",
data={ data={
"events_path": str(events_file), "source": "db",
"starting_balances": "USD=1000.0", "starting_balances": "USD=1000.0",
"trade_capital": "100.0", "trade_capital": "100.0",
"min_profit_threshold": "0.0005", "min_profit_threshold": "0.0005",
"fee_profile": "standard", "fee_profile": "api",
"slippage_bps": "4.0", "slippage_bps": "4.0",
"execution_latency_ms": "20.0", "execution_latency_ms": "20.0",
}, },
@@ -380,13 +372,13 @@ async def test_backtesting_page_run_and_recent_reports_api(tmp_path) -> None:
assert fragment.status_code == 200 assert fragment.status_code == 200
assert "Run Backtest" in fragment.text assert "Run Backtest" in fragment.text
assert "Recent Runs" in fragment.text assert "Recent Jobs" in fragment.text
assert run.status_code == 200 assert run.status_code == 200
assert "completed" in run.text assert "submitted" in run.text
assert "Processed:" in run.text assert "queued" in run.text
assert reports.status_code == 200 assert reports.status_code == 200
payload = reports.json() payload = reports.json()
assert len(payload["reports"]) >= 1 assert len(payload["reports"]) >= 1
assert payload["reports"][0]["status"] == "completed" assert payload["reports"][0]["status"] == "pending"
@@ -0,0 +1,103 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
pytestmark = pytest.mark.integration
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl() -> None:
async with _pg() as store:
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
async with store.pool.acquire() as conn:
trades = await conn.fetch(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
)
orders = await conn.fetch(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
)
pnls = await conn.fetch("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events")
assert len(trades) == 1
assert trades[0]["status"] == "filled"
assert trades[0]["estimated_pnl"] == 0.03
assert trades[0]["capital_used"] == 1.0
assert trades[0]["cycle"] == "USD->BTC->ETH->USD"
assert trades[0]["leg_count"] == 3
assert len(orders) == 3
assert orders[0]["leg_index"] == 0
assert orders[1]["leg_index"] == 1
assert orders[2]["leg_index"] == 2
assert orders[0]["status"] == "submitted"
assert len(pnls) == 1
assert pnls[0]["kind"] == "estimated"
assert pnls[0]["pnl_usd"] == 0.03
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_metrics_calculator_summarizes_execution_data() -> None:
async with _pg() as store:
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
async with store.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO trades (
trade_ref, started_at, finished_at, status,
realized_pnl, estimated_pnl, capital_used, cycle, leg_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9),
($10, $11, $12, $13, $14, $15, $16, $17, $18)
""",
"trade-1",
started,
finished,
"filled",
12.5,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
"trade-2",
started_two,
finished_two,
"filled",
-4.5,
-2.0,
200.0,
"USD->ETH->BTC->USD",
3,
)
await conn.execute(
"""
INSERT INTO opportunities (detected_at, cycle, gross_pct, net_pct, est_profit, executed)
VALUES ($1, $2, $3, $4, $5, $6),
($7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18)
""",
started,
"USD->BTC->ETH->USD",
4.0,
3.0,
0.03,
True,
started_two,
"USD->ETH->BTC->USD",
2.0,
1.0,
0.01,
False,
started_two + timedelta(seconds=30),
"USD->BTC->ETH->USD",
5.0,
4.0,
0.04,
True,
)
await 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
""",
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
101,
"closed",
2.0,
100.0,
"{}",
started,
"trade-2",
"order-2",
0,
"ETH/USD",
"sell",
4.0,
202,
"closed",
3.0,
200.0,
"{}",
started_two,
)
metrics = await MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
@@ -0,0 +1,61 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import OpportunityRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events() -> None:
async with _pg() as store:
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
)
assert len(rows) == 1
assert rows[0]["cycle"] == "USD->BTC->ETH->USD"
assert rows[0]["gross_pct"] == 4.0
assert rows[0]["net_pct"] == 3.0
assert rows[0]["est_profit"] == 0.03
assert rows[0]["executed"] is False
+424
View File
@@ -0,0 +1,424 @@
"""Integration tests: verify PostgreSQL schema and connection.
These tests connect to the PostgreSQL server at 192.168.88.35 and
validate that all expected tables, columns, and constraints exist.
They are skipped if the server is unreachable.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import pytest
import pytest_asyncio
from arbitrade.config.settings import get_settings
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
# ── expected schema ──────────────────────────────────────────────────────────
EXPECTED_TABLES: dict[str, list[str]] = {
"schema_migrations": ["version", "applied_at"],
"config_sections": ["id", "name", "description", "updated_at"],
"config_settings": [
"key",
"section",
"value_json",
"value_type",
"is_secret",
"is_runtime_reloadable",
"updated_at",
"updated_by",
],
"config_pairings": [
"id",
"base_asset",
"quote_asset",
"enabled",
"source",
"created_at",
"updated_at",
],
"config_backtesting_defaults": [
"id",
"starting_balances",
"trade_capital",
"min_profit_threshold",
"slippage_bps",
"execution_latency_ms",
"fee_source",
],
"opportunities": [
"id",
"detected_at",
"cycle",
"gross_pct",
"net_pct",
"est_profit",
"executed",
],
"trades": [
"id",
"trade_ref",
"started_at",
"finished_at",
"status",
"realized_pnl",
"estimated_pnl",
"capital_used",
"cycle",
"leg_count",
],
"orders": [
"id",
"trade_ref",
"order_ref",
"leg_index",
"pair",
"side",
"volume",
"user_ref",
"status",
"filled_volume",
"avg_price",
"raw_response",
"recorded_at",
],
"pnl_events": [
"id",
"trade_ref",
"recorded_at",
"kind",
"pnl_usd",
"source",
],
"portfolio_snapshots": ["snapshot_at", "balances", "total_value_usd"],
"market_snapshots": ["snapshot_at", "symbol", "source", "payload", "latency_ms"],
"audit_events": [
"id",
"occurred_at",
"actor",
"event_type",
"decision",
"payload",
"correlation_id",
],
"runtime_state_snapshots": [
"snapshot_at",
"is_running",
"kill_switch_active",
"kill_switch_reason",
"open_trade_count",
"last_known_balances",
"note",
],
"kraken_account_snapshots": [
"snapshot_at",
"fee_tier",
"maker_fee",
"taker_fee",
"thirty_day_volume",
"trade_balance_raw",
"fee_schedule_raw",
],
"backtest_jobs": [
"id",
"status",
"events_path",
"config",
"report",
"error",
"created_at",
"started_at",
"finished_at",
],
}
# Tables that should have a primary key
TABLES_WITH_PRIMARY_KEY: dict[str, str | list[str]] = {
"schema_migrations": "version",
"config_sections": "id",
"config_settings": "key",
"config_pairings": "id",
"config_backtesting_defaults": "id",
"opportunities": "id",
"trades": "id",
"orders": "id",
"pnl_events": "id",
"audit_events": "id",
"backtest_jobs": "id",
}
# Tables with a UNIQUE constraint beyond the primary key
TABLES_WITH_UNIQUE_CONSTRAINTS: dict[str, list[str]] = {
"config_sections": ["name"],
"config_pairings": ["base_asset, quote_asset"],
}
# ── fixtures ────────────────────────────────────────────────────────────────
@asynccontextmanager
async def _pg_lifecycle() -> AsyncIterator[PgStore]:
"""Connect, yield store, then disconnect."""
settings = get_settings()
store = PgStore(settings)
try:
await store.start()
yield store
finally:
await store.stop()
@pytest_asyncio.fixture(name="pg")
async def pg_fixture() -> AsyncIterator[PgStore]:
async with _pg_lifecycle() as store:
yield store
# ── helpers ─────────────────────────────────────────────────────────────────
async def _get_actual_tables(store: PgStore) -> dict[str, list[str]]:
"""Return {table_name: [column_name, ...]} for the public schema."""
actual: dict[str, list[str]] = {}
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT table_name, column_name FROM information_schema.columns "
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position"
)
for row in rows:
tbl: str = row["table_name"]
col: str = row["column_name"]
actual.setdefault(tbl, []).append(col)
return actual
async def _table_row_count(store: PgStore, table: str) -> int:
async with store.pool.acquire() as conn:
row = await conn.fetchrow(f"SELECT COUNT(*) AS cnt FROM {table}")
return int(row["cnt"]) if row else 0
# ── tests ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pg_connect(pg: PgStore) -> None:
"""Can connect to PostgreSQL and ping the server."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT 1 AS val")
assert val == 1
@pytest.mark.asyncio
async def test_pgcrypto_extension(pg: PgStore) -> None:
"""The pgcrypto extension is available (gen_random_uuid)."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT gen_random_uuid()")
assert val is not None
# The result should be a UUID object
assert len(str(val)) == 36 # UUID string length
@pytest.mark.asyncio
async def test_schema_migration_applies(pg: PgStore) -> None:
"""Migrate creates all expected tables."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual, (
f"Table '{table}' missing after migration. " f"Found tables: {sorted(actual)}"
)
@pytest.mark.asyncio
async def test_migration_is_idempotent(pg: PgStore) -> None:
"""Running migrate twice does not raise."""
await pg.migrate()
await pg.migrate() # second call should be a no-op
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual
@pytest.mark.asyncio
async def test_table_columns(pg: PgStore) -> None:
"""Every expected table has the correct columns."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table, expected_cols in EXPECTED_TABLES.items():
actual_cols = actual.get(table, [])
for col in expected_cols:
assert col in actual_cols, (
f"Column '{col}' missing from table '{table}'. " f"Actual columns: {actual_cols}"
)
@pytest.mark.asyncio
async def test_primary_keys(pg: PgStore) -> None:
"""Tables that should have primary keys do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_pk in TABLES_WITH_PRIMARY_KEY.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'PRIMARY KEY' "
"ORDER BY kcu.ordinal_position",
table,
)
pk_columns = [r["column_name"] for r in rows]
expected_list = [expected_pk] if isinstance(expected_pk, str) else expected_pk
for col in expected_list:
assert col in pk_columns, (
f"Table '{table}' should have PK column '{col}'. "
f"Actual PK columns: {pk_columns}"
)
@pytest.mark.asyncio
async def test_unique_constraints(pg: PgStore) -> None:
"""Tables that should have UNIQUE constraints do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_ucs in TABLES_WITH_UNIQUE_CONSTRAINTS.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'UNIQUE'",
table,
)
uc_columns = {r["column_name"] for r in rows}
for expected_cols in expected_ucs:
cols = [c.strip() for c in expected_cols.split(",")]
for col in cols:
assert col in uc_columns, (
f"Table '{table}' should have UNIQUE column '{col}'. "
f"Actual UNIQUE columns: {uc_columns}"
)
@pytest.mark.asyncio
async def test_table_row_count_is_zero(pg: PgStore) -> None:
"""All tables start empty after migration."""
await pg.migrate()
for table in EXPECTED_TABLES:
count = await _table_row_count(pg, table)
assert count == 0, (
f"Table '{table}' should be empty after migration, " f"but has {count} rows"
)
@pytest.mark.asyncio
async def test_schema_migration_version_recorded(pg: PgStore) -> None:
"""schema_migrations has the expected version after migrate."""
from arbitrade.storage.pg_store import SCHEMA_VERSION
await pg.migrate()
async with pg.pool.acquire() as conn:
row = await conn.fetchrow("SELECT MAX(version) AS v FROM schema_migrations")
assert row is not None
assert row["v"] == SCHEMA_VERSION, (
f"Expected schema version {SCHEMA_VERSION}, " f"got {row['v']}"
)
@pytest.mark.asyncio
async def test_create_and_query_row(pg: PgStore) -> None:
"""Can INSERT a row and SELECT it back (round-trip for a simple table)."""
await pg.migrate()
async with pg.pool.acquire() as conn:
# ConfigSections round-trip
await conn.execute(
"INSERT INTO config_sections (name, description) VALUES ($1, $2)",
"test_section",
"A test section for integration test",
)
row = await conn.fetchrow(
"SELECT name, description FROM config_sections WHERE name = $1",
"test_section",
)
assert row is not None
assert row["name"] == "test_section"
assert row["description"] == "A test section for integration test"
# Clean up
await conn.execute(
"DELETE FROM config_sections WHERE name = $1",
"test_section",
)
@pytest.mark.asyncio
async def test_config_pairings_upsert(pg: PgStore) -> None:
"""ON CONFLICT ... DO UPDATE works on config_pairings (unique constraint)."""
await pg.migrate()
from arbitrade.config.service import ConfigPairing
from arbitrade.storage.repositories import ConfigPairingRepository
repo = ConfigPairingRepository(pg)
# Insert
p1 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=True, source="kraken")
)
assert p1.id is not None
assert p1.base_asset == "XBT"
assert p1.enabled is True
# Upsert (update)
p2 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=False, source="manual")
)
assert p2.id == p1.id # same row
assert p2.enabled is False
assert p2.source == "manual"
# Clean up
deleted = await repo.delete_pairing("XBT", "USD")
assert deleted is True
@pytest.mark.asyncio
async def test_audit_list_recent(pg: PgStore) -> None:
"""AuditRepository.list_recent returns records in desc order."""
await pg.migrate()
from datetime import UTC, datetime
from arbitrade.storage.repositories import AuditRecord, AuditRepository
repo = AuditRepository(pg)
now = datetime.now(UTC)
# Insert a few records
for i in range(3):
await repo.insert(
AuditRecord(
occurred_at=now,
actor="test",
event_type="integration_test",
decision=f"decision_{i}",
payload={"index": i},
correlation_id=f"corr_{i}",
)
)
recent = await repo.list_recent(limit=5)
assert len(recent) >= 3
assert recent[0].decision in ("decision_2", "decision_1", "decision_0")
# Verify payload serialization worked
first = recent[0]
if first.payload:
assert "index" in first.payload
-34
View File
@@ -1,34 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from arbitrade.config.settings import Settings
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
def test_audit_repository_inserts_and_lists_recent(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "audit.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = AuditRepository(store)
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
+61
View File
@@ -0,0 +1,61 @@
"""End-to-end test for configuration management system."""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from arbitrade.config.service import ConfigurationService
from arbitrade.config.settings import Settings
from arbitrade.storage.repositories import AuditRepository
@pytest.mark.asyncio
async def test_end_to_end_config_workflow():
"""Test complete configuration workflow."""
# Create mocks
settings = Mock(spec=Settings)
cursor = Mock()
cursor.fetchone.return_value = None
cursor.fetchall.return_value = []
cursor.execute.return_value = cursor
cntx = MagicMock()
cntx.__enter__.return_value = cursor
store = Mock()
store.connect.return_value = cntx
audit_repo = Mock(spec=AuditRepository)
# Create service
service = ConfigurationService(settings, store, audit_repo)
# Test initial state
assert service.get_config_version() == 0
assert service.get_last_updated_at() is None
# Test setting a value
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
# Mock the setting creation
mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting = AsyncMock(return_value=None)
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
mock_repo_instance.list_settings = AsyncMock(return_value=[])
# Set a setting
await service.set_setting("test_key", "test_value", "test_user")
# Verify setting was retrieved
result = service.get_setting("test_key", "default")
assert result == "test_value"
# Verify version incremented
assert service.get_config_version() == 1
assert service.get_last_updated_at() is not None
if __name__ == "__main__":
test_end_to_end_config_workflow()
print("End-to-end test passed!")
+205
View File
@@ -0,0 +1,205 @@
"""Unit tests for configuration repositories."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from arbitrade.config.service import (
ConfigPairing,
ConfigSetting,
)
from arbitrade.storage.repositories import (
ConfigBacktestingDefaultsRepository,
ConfigPairingRepository,
ConfigSettingRepository,
)
@pytest.fixture
def mock_store():
"""Create a mock database store with async pool."""
store = MagicMock()
conn = AsyncMock()
conn.fetchone = AsyncMock(return_value=None)
conn.fetchall = AsyncMock(return_value=[])
conn.fetch = AsyncMock(return_value=[])
conn.execute = AsyncMock(return_value=conn)
store.pool = MagicMock()
cm = AsyncMock()
cm.__aenter__.return_value = conn
store.pool.acquire.return_value = cm
return store
def _make_row(mapping: dict):
row = MagicMock()
row.__getitem__.side_effect = lambda k: mapping[k]
return row
SETTING_ROW = {
"key": "test_key",
"section": "test_section",
"value_json": "test_value",
"value_type": "str",
"is_secret": False,
"is_runtime_reloadable": False,
"updated_at": "2023-01-01T00:00:00",
"updated_by": "test_user",
}
PAIRING_ROW = {
"id": 1,
"base_asset": "BTC",
"quote_asset": "USD",
"enabled": True,
"source": "Kraken",
"created_at": "2023-01-01T00:00:00",
"updated_at": "2023-01-01T00:00:00",
}
def test_config_setting_repository_initialization(mock_store):
"""Test ConfigSettingRepository initialization."""
repo = ConfigSettingRepository(mock_store)
assert repo._store == mock_store
@pytest.mark.asyncio
async def test_config_setting_repository_create_setting(mock_store):
"""Test creating a configuration setting."""
repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
setting = ConfigSetting(
key="test_key",
section="test_section",
value_json="test_value",
value_type="str",
is_secret=False,
is_runtime_reloadable=False,
updated_by="test_user",
)
result = await repo.create_setting(setting)
assert result.key == "test_key"
assert result.section == "test_section"
assert result.value_json == "test_value"
assert result.value_type == "str"
assert result.updated_by == "test_user"
@pytest.mark.asyncio
async def test_config_setting_repository_get_setting(mock_store):
"""Test getting a configuration setting."""
repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
result = await repo.get_setting("test_key")
assert result is not None
assert result.key == "test_key"
assert result.section == "test_section"
assert result.value_json == "test_value"
assert result.value_type == "str"
assert result.updated_by == "test_user"
@pytest.mark.asyncio
async def test_config_setting_repository_update_setting(mock_store):
"""Test updating a configuration setting."""
repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
setting = ConfigSetting(
key="test_key",
section="test_section",
value_json="updated_value",
value_type="str",
is_secret=False,
is_runtime_reloadable=False,
updated_by="test_user",
)
result = await repo.update_setting("test_key", setting)
assert result.key == "test_key"
@pytest.mark.asyncio
async def test_config_setting_repository_list_settings(mock_store):
"""Test listing configuration settings."""
repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
row1 = _make_row({**SETTING_ROW, "key": "test_key1", "value_json": "test_value1"})
row2 = _make_row({**SETTING_ROW, "key": "test_key2", "value_json": "test_value2"})
conn.fetch = AsyncMock(return_value=[row1, row2])
result = await repo.list_settings()
assert len(result) == 2
assert result[0].key == "test_key1"
assert result[1].key == "test_key2"
@pytest.mark.asyncio
async def test_config_setting_repository_get_latest_updated_at(mock_store):
"""Test getting latest updated timestamp."""
repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
row = _make_row({"latest_updated_at": "2023-01-01T00:00:00"})
conn.fetchrow = AsyncMock(return_value=row)
result = await repo.get_latest_updated_at()
assert result is not None
def test_config_pairing_repository_initialization(mock_store):
"""Test ConfigPairingRepository initialization."""
repo = ConfigPairingRepository(mock_store)
assert repo._store == mock_store
@pytest.mark.asyncio
async def test_config_pairing_repository_create_pairing(mock_store):
"""Test creating a currency pairing."""
repo = ConfigPairingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
pairing = ConfigPairing(base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken")
result = await repo.create_pairing(pairing)
assert result.base_asset == "BTC"
assert result.quote_asset == "USD"
assert result.enabled is True
assert result.source == "Kraken"
@pytest.mark.asyncio
async def test_config_pairing_repository_get_pairing(mock_store):
"""Test getting a currency pairing."""
repo = ConfigPairingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
result = await repo.get_pairing("BTC", "USD")
assert result.base_asset == "BTC"
assert result.quote_asset == "USD"
assert result.enabled is True
assert result.source == "Kraken"
def test_config_backtesting_defaults_repository_initialization(mock_store):
"""Test ConfigBacktestingDefaultsRepository initialization."""
repo = ConfigBacktestingDefaultsRepository(mock_store)
assert repo._store == mock_store
+152
View File
@@ -0,0 +1,152 @@
"""Unit tests for configuration management system."""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from arbitrade.config.service import ConfigurationService
from arbitrade.config.settings import Settings
from arbitrade.storage.repositories import AuditRepository
@pytest.fixture
def mock_settings():
"""Create a mock settings object."""
settings = Mock(spec=Settings)
settings.app_env = "test"
return settings
@pytest.fixture
def mock_store():
"""Create a mock database store (sync — repos are patched)."""
store = MagicMock()
store.pool = MagicMock()
return store
@pytest.fixture
def mock_audit_repo():
"""Create a mock audit repository."""
audit_repo = Mock(spec=AuditRepository)
return audit_repo
def test_configuration_service_initialization(mock_settings, mock_store, mock_audit_repo):
"""Test that ConfigurationService initializes correctly."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
assert service._settings == mock_settings
assert service._store == mock_store
assert service._audit_repo == mock_audit_repo
assert service._config_version == 0
assert isinstance(service._loaded_settings, dict)
def test_configuration_service_get_setting(mock_settings, mock_store, mock_audit_repo):
"""Test getting configuration settings."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
service._loaded_settings = {"test_key": "test_value"}
assert service.get_setting("test_key", "default") == "test_value"
assert service.get_setting("non_existing", "default") == "default"
@pytest.mark.asyncio
async def test_configuration_service_set_setting(mock_settings, mock_store, mock_audit_repo):
"""Test setting configuration settings."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting = AsyncMock(return_value=None)
await service.set_setting("test_key", "test_value", "test_user")
mock_repo_instance.create_setting.assert_awaited_once()
@pytest.mark.asyncio
async def test_configuration_service_hot_reload_detection(
mock_settings, mock_store, mock_audit_repo
):
"""Test hot-reload detection functionality."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
assert await service.is_config_outdated() is False
from datetime import datetime
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
assert await service.is_config_outdated() is True
@pytest.mark.asyncio
async def test_configuration_service_reload_if_changed(mock_settings, mock_store, mock_audit_repo):
"""Test hot-reload functionality."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
mock_repo_instance.list_settings = AsyncMock(return_value=[])
from datetime import datetime
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
result = await service.reload_if_changed()
assert result is True
assert service.get_config_version() == 1
@pytest.mark.asyncio
async def test_configuration_service_get_config_version(mock_settings, mock_store, mock_audit_repo):
"""Test getting configuration version."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
assert service.get_config_version() == 0
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting = AsyncMock(return_value=None)
await service.set_setting("test_key", "test_value", "test_user")
assert service.get_config_version() == 1
@pytest.mark.asyncio
async def test_configuration_service_get_last_updated_at(
mock_settings, mock_store, mock_audit_repo
):
"""Test getting last updated timestamp."""
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
assert service.get_last_updated_at() is None
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting = AsyncMock(return_value=None)
await service.set_setting("test_key", "test_value", "test_user")
assert service.get_last_updated_at() is not None
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "exec.duckdb")
store = DuckDBStore(settings)
store.migrate()
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
with store.connect() as conn:
trades = conn.execute(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
).fetchall()
orders = conn.execute(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
).fetchall()
pnls = conn.execute("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
assert len(trades) == 1
assert trades[0][1] == "filled"
assert trades[0][2] == 0.03
assert trades[0][3] == 1.0
assert trades[0][4] == "USD->BTC->ETH->USD"
assert trades[0][5] == 3
assert len(orders) == 3
assert orders[0][2] == 0
assert orders[1][2] == 1
assert orders[2][2] == 2
assert orders[0][6] == "submitted"
assert len(pnls) == 1
assert pnls[0][1] == "estimated"
assert pnls[0][2] == 0.03
-144
View File
@@ -1,144 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.db import DuckDBStore
def test_metrics_calculator_summarizes_execution_data(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "metrics.duckdb")
store = DuckDBStore(settings)
store.migrate()
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
with 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 (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
started,
finished,
"filled",
12.5,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
"trade-2",
started_two,
finished_two,
"filled",
-4.5,
-2.0,
200.0,
"USD->ETH->BTC->USD",
3,
],
)
conn.execute(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
""",
[
started,
"USD->BTC->ETH->USD",
4.0,
3.0,
0.03,
True,
started_two,
"USD->ETH->BTC->USD",
2.0,
1.0,
0.01,
False,
started_two + timedelta(seconds=30),
"USD->BTC->ETH->USD",
5.0,
4.0,
0.04,
True,
],
)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
101,
"closed",
2.0,
100.0,
"{}",
started,
"trade-2",
"order-2",
0,
"ETH/USD",
"sell",
4.0,
202,
"closed",
3.0,
200.0,
"{}",
started_two,
],
)
metrics = MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import OpportunityRepository
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "test.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
with store.connect() as conn:
rows = conn.execute(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
).fetchall()
assert len(rows) == 1
assert rows[0][0] == "USD->BTC->ETH->USD"
assert rows[0][1] == 4.0
assert rows[0][2] == 3.0
assert rows[0][3] == 0.03
assert rows[0][4] is False
+62 -54
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@@ -31,38 +32,67 @@ class _FakeStartupReconciler:
self.called = True self.called = True
@pytest.mark.asyncio def _mock_pg_store():
async def test_persist_runtime_snapshot_writes_record(tmp_path) -> None: """Create a PgStore-alike with an async pool returning an AsyncMock conn."""
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "runtime.duckdb")) store = MagicMock()
conn = AsyncMock()
conn.fetchrow = AsyncMock()
conn.fetch = AsyncMock(return_value=[])
conn.execute = AsyncMock(return_value=conn)
pool_cm = AsyncMock()
pool_cm.__aenter__.return_value = conn
store.pool = MagicMock()
store.pool.acquire.return_value = pool_cm
return store
@pytest.fixture
def app():
"""Create a test app with a mocked PgStore and audit repository."""
a = create_app(Settings(_env_file=None, APP_MODE="paper", paper_trading_mode=True))
a.state.store = _mock_pg_store()
a.state.runtime_state_repository.insert = AsyncMock()
a.state.runtime_state_repository.latest = AsyncMock(return_value=None)
# Replace audit repository with mock to avoid real PgStore access
audit_mock = AsyncMock()
audit_mock.insert = AsyncMock()
a.state.audit_repository = audit_mock
return a
@pytest.mark.asyncio
async def test_persist_runtime_snapshot_writes_record(app) -> None:
app.state.dashboard_controls.is_running = True app.state.dashboard_controls.is_running = True
app.state.dashboard_controls.kill_switch.deactivate() app.state.dashboard_controls.kill_switch.deactivate()
snapshot = persist_runtime_snapshot(app, note="unit-test") # Mock _open_trade_count → 0, _latest_balances → None
conn = await app.state.store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=MagicMock(**{"__getitem__": lambda s, k: 0}))
snapshot = await persist_runtime_snapshot(app, note="unit-test")
assert snapshot is not None assert snapshot is not None
assert snapshot.note == "unit-test" assert snapshot.note == "unit-test"
latest = app.state.runtime_state_repository.latest() app.state.runtime_state_repository.latest = AsyncMock(return_value=snapshot)
latest = await app.state.runtime_state_repository.latest()
assert latest is not None assert latest is not None
assert latest.note == "unit-test" assert latest.note == "unit-test"
assert latest.is_running is True assert latest.is_running is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_applies_snapshot(tmp_path) -> None: async def test_restore_runtime_state_applies_snapshot(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "restore.duckdb")) seed = RuntimeStateRecord(
app.state.runtime_state_repository.insert( snapshot_at=datetime.now(UTC),
RuntimeStateRecord( is_running=False,
snapshot_at=datetime.now(UTC), kill_switch_active=True,
is_running=False, kill_switch_reason="manual-stop",
kill_switch_active=True, open_trade_count=0,
kill_switch_reason="manual-stop", last_known_balances={"USD": 100.0},
open_trade_count=0, note="seed",
last_known_balances={"USD": 100.0},
note="seed",
)
) )
app.state.runtime_state_repository.latest = AsyncMock(return_value=seed)
report = await restore_runtime_state(app) report = await restore_runtime_state(app)
@@ -73,36 +103,12 @@ async def test_restore_runtime_state_applies_snapshot(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_enables_restart_guard_for_open_trades(tmp_path) -> None: async def test_restore_runtime_state_enables_restart_guard_for_open_trades(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "open-trades.duckdb")) # Simulate 1 open trade
conn = await app.state.store.pool.acquire().__aenter__()
with app.state.store.connect() as conn: row = MagicMock()
conn.execute( row.__getitem__.return_value = 1
""" conn.fetchrow = AsyncMock(return_value=row)
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"open-trade-1",
datetime.now(UTC),
None,
"open",
None,
1.0,
100.0,
"USD->BTC->ETH->USD",
3,
],
)
report = await restore_runtime_state(app) report = await restore_runtime_state(app)
@@ -114,24 +120,26 @@ async def test_restore_runtime_state_enables_restart_guard_for_open_trades(tmp_p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_graceful_shutdown_drains_workers_and_persists_snapshot(tmp_path) -> None: async def test_graceful_shutdown_drains_workers_and_persists_snapshot(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "shutdown.duckdb"))
worker = _FakeWorker() worker = _FakeWorker()
app.state.background_workers = [worker] app.state.background_workers = [worker]
app.state.dashboard_controls.is_running = True app.state.dashboard_controls.is_running = True
# Mock _open_trade_count → 0, _latest_balances → None
conn = await app.state.store.pool.acquire().__aenter__()
row = MagicMock()
row.__getitem__.return_value = 0
conn.fetchrow = AsyncMock(return_value=row)
await graceful_shutdown(app) await graceful_shutdown(app)
assert worker.stopped is True assert worker.stopped is True
assert app.state.dashboard_controls.is_running is False assert app.state.dashboard_controls.is_running is False
latest = app.state.runtime_state_repository.latest() app.state.runtime_state_repository.insert.assert_called()
assert latest is not None
assert latest.note == "graceful_shutdown"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_calls_startup_reconciler(tmp_path) -> None: async def test_restore_runtime_state_calls_startup_reconciler(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "reconciler.duckdb"))
reconciler = _FakeStartupReconciler() reconciler = _FakeStartupReconciler()
app.state.startup_reconciler = reconciler app.state.startup_reconciler = reconciler
+17
View File
@@ -0,0 +1,17 @@
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()
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
content %}
<section class="hero">
<div>
<h1 class="title">Backtesting</h1>
<p class="subtitle">
Replay controls, run status, and recent summary reports.
</p>
</div>
<div class="toolbar">
<a class="button secondary" href="{{ dashboard_endpoint }}">Dashboard</a>
</div>
</section>
<section
id="backtesting-shell"
hx-get="{{ panel_endpoint }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/backtesting_panel.html" %}
</section>
{% endblock %}
-14
View File
@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a href="/health" hx-get="/health" hx-target="#health-json" hx-swap="innerHTML">refresh</a>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
{% endblock %}
@@ -1,142 +0,0 @@
<div id="backtesting-shell" class="panel">
<div
class="grid"
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"
>
<article class="card">
<div class="label">Run Status</div>
<div class="value">{{ status }}</div>
<div class="meta">{{ message }}</div>
</article>
<article class="card">
<div class="label">Latest Report</div>
{% if latest_report %}
<div class="meta">Run at {{ latest_report.run_at }}</div>
<div class="meta">Events: {{ latest_report.events_path }}</div>
<div class="meta">
Processed: {{ latest_report.report.processed_events }}
</div>
<div class="meta">
Opportunities: {{ latest_report.report.opportunities_seen }}
</div>
<div class="meta">Trades: {{ latest_report.report.trades_executed }}</div>
<div class="meta">
Realized P&amp;L: {{
'%.4f'|format(latest_report.report.realized_pnl_usd) }} USD
</div>
<div class="meta">
Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }}
USD
</div>
{% else %}
<div class="meta">No runs yet.</div>
{% endif %}
</article>
</div>
<article class="card" style="margin-top: 16px">
<div class="label">Run Backtest</div>
<form
class="form-grid"
hx-post="{{ run_endpoint }}"
hx-target="#backtesting-shell"
hx-swap="outerHTML"
>
<label class="field">
<span>Replay events path (JSONL)</span>
<input
name="events_path"
type="text"
value="{{ events_path }}"
placeholder="data/replay.jsonl"
/>
</label>
<label class="field">
<span>Starting balances</span>
<input
name="starting_balances"
type="text"
value="{{ starting_balances }}"
placeholder="USD=1000.0,BTC=0.0"
/>
</label>
<label class="field">
<span>Trade capital</span>
<input
name="trade_capital"
type="number"
min="0"
step="0.01"
value="{{ trade_capital }}"
/>
</label>
<label class="field">
<span>Min profit threshold</span>
<input
name="min_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ min_profit_threshold }}"
/>
</label>
<label class="field">
<span>Fee profile</span>
<select name="fee_profile">
{% set sel = "selected" if fee_profile == "standard" else "" %}
<option value="standard" {{ sel }}>standard</option>
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
<option value="maker_heavy" {{ sel }}>maker_heavy</option>
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
<option value="taker_heavy" {{ sel }}>taker_heavy</option>
{% set sel = "selected" if fee_profile == "custom" else "" %}
<option value="custom" {{ sel }}>custom</option>
</select>
</label>
<label class="field">
<span>Custom fee rate (if fee profile = custom)</span>
<input
name="custom_fee_rate"
type="number"
min="0"
step="0.0001"
value="{{ custom_fee_rate }}"
/>
</label>
<label class="field">
<span>Slippage (bps)</span>
<input
name="slippage_bps"
type="number"
min="0"
step="0.1"
value="{{ slippage_bps }}"
/>
</label>
<label class="field">
<span>Execution latency (ms)</span>
<input
name="execution_latency_ms"
type="number"
min="0"
step="0.1"
value="{{ execution_latency_ms }}"
/>
</label>
<button type="submit" class="button">Run backtest</button>
</form>
</article>
<article class="card" style="margin-top: 16px">
<div class="label">Recent Runs</div>
{% if recent_reports %} {% for item in recent_reports %}
<div class="meta">
{{ item.run_at }} | {{ item.events_path }} | trades={{
item.report.trades_executed }} | pnl={{
'%.4f'|format(item.report.realized_pnl_usd) }} USD
</div>
{% endfor %} {% else %}
<div class="meta">No recent reports yet.</div>
{% endif %}
</article>
</div>