Compare commits

...

59 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
zwitschi 38e1d64437 feat: add backtesting functionality with UI and API endpoints
CI / lint-test-build (push) Successful in 2m31s
- Introduced backtesting page and fragment in the dashboard for running backtests and viewing recent reports.
- Implemented backtest run logic with configuration options including event path, starting balances, trade capital, and fee profiles.
- Added recent backtest reports storage and retrieval.
- Created a new strategy module for statistical arbitrage experiments with validation on configuration parameters.
- Updated settings to include parameters for the statistical arbitrage strategy.
- Enhanced dashboard controls to support the new strategy mode.
- Added unit tests for backtesting functionality and strategy validation.
- Updated templates for backtesting UI integration.
2026-06-02 09:28:22 +02:00
zwitschi f612c8533a feat: Add backtesting parameter sweep support and related functionality 2026-06-02 08:44:10 +02:00
zwitschi 8ef8dc801d fix: Update CI workflow to publish Docker image and document Coolify deployment process
CI / lint-test-build (push) Successful in 2m36s
2026-06-01 17:48:13 +02:00
zwitschi df55953d31 fix: Update application port from 8000 to 9090 in configuration and deployment files
CI / lint-test-build (push) Failing after 43s
2026-06-01 17:23:59 +02:00
zwitschi 5051f2de83 fix: Update Dockerfile and docker-compose.yml for dependency installation and image reference
CI / lint-test-build (push) Successful in 2m35s
2026-06-01 17:13:30 +02:00
zwitschi 2845721797 refactor: Simplify strategy mode selection logic in controls template
CI / lint-test-build (push) Successful in 54s
2026-06-01 16:56:43 +02:00
zwitschi 9e2f08d40a fix: Update pip-audit command to specify requirements file for dependency audit
CI / lint-test-build (push) Failing after 43s
2026-06-01 16:50:02 +02:00
zwitschi 778b41910a chore: Update project dependencies and restructure requirements files
CI / lint-test-build (push) Failing after 29s
2026-06-01 16:30:55 +02:00
zwitschi 065dcbda20 style: Comment out Black code formatting step in CI workflow
CI / lint-test-build (push) Failing after 24s
2026-06-01 15:46:22 +02:00
zwitschi c3c524724e fix: Correctly assign alerts_last_channel_results in dashboard controls
CI / lint-test-build (push) Failing after 13s
2026-06-01 15:45:21 +02:00
zwitschi 6c7dd0a715 refactor: Simplify variable names and improve readability in metrics and lifecycle modules
CI / lint-test-build (push) Failing after 11s
2026-06-01 15:44:17 +02:00
103 changed files with 8921 additions and 1728 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
+10
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
@@ -49,3 +54,8 @@ CUMULATIVE_LOSS_LIMIT_USD=10.0
MAX_SOURCE_LATENCY_MS= MAX_SOURCE_LATENCY_MS=
MAX_APPLY_LATENCY_MS= MAX_APPLY_LATENCY_MS=
MAX_CONSECUTIVE_FAILURES= MAX_CONSECUTIVE_FAILURES=
STRATEGY_ENABLE_STAT_ARB_EXPERIMENT=false
STRATEGY_STAT_ARB_LOOKBACK_WINDOW=120
STRATEGY_STAT_ARB_ENTRY_ZSCORE=2.0
STRATEGY_STAT_ARB_EXIT_ZSCORE=0.5
STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS=900.0
+4 -4
View File
@@ -28,14 +28,14 @@ jobs:
- name: Ruff - name: Ruff
run: ruff check . run: ruff check .
- name: Black # - name: Black
run: black --check . # run: black --check .
- name: MyPy - name: MyPy
run: mypy src run: mypy src
- name: Dependency audit - name: Dependency audit
run: pip-audit --skip-editable run: pip-audit -r requirements/latest-runtime.in
- name: Secret scan (worktree + git history) - name: Secret scan (worktree + git history)
run: python scripts/security_scan.py run: python scripts/security_scan.py
@@ -64,4 +64,4 @@ jobs:
with: with:
context: . context: .
push: true push: true
tags: git.allucanget.biz/${{ secrets.REGISTRY_NAMESPACE }}/arbitrade:${{ github.sha }} tags: git.allucanget.biz/allucanget/arbitrade:latest
+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
+11
View File
@@ -18,7 +18,15 @@
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks. - Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement. - Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay. - Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
- Added backtesting parameter sweep support (`scripts/backtest_sweep.py`) for theta, trade-capital, pair-universe, and staleness-threshold grid search.
- Added persisted sweep artifacts with ranked in-sample/out-of-sample results and promotion-ready candidate reporting.
- Added out-of-sample overfit guards via train/test time-window split and generalization-gap checks.
- Added dashboard controls for tradable pair universe selection and strategy mode/parameter configuration. - Added dashboard controls for tradable pair universe selection and strategy mode/parameter configuration.
- Added feature-flagged statistical arbitrage experiment scaffold (`src/arbitrade/strategy/stat_arb.py`) with mean-reversion signal lifecycle.
- Added strategy feature-flag settings for statistical arbitrage experiment activation and z-score/holding-window controls.
- Added unit coverage for statistical arbitrage experiment behavior and new strategy settings validation rules.
- Added dedicated backtesting dashboard page (`/dashboard/backtesting`) with run controls for replay path, fee profile, balances, slippage, and execution latency.
- Added backtesting run/report endpoints (`/dashboard/backtesting/run`, `/dashboard/api/backtesting/reports`) and recent-report history surfaced in UI.
### Changed ### Changed
@@ -29,6 +37,9 @@
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans. - Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
- Added backtesting usage and replay format documentation to README. - Added backtesting usage and replay format documentation to README.
- Dashboard controls now surface tradable pairs and strategy config snapshot values. - Dashboard controls now surface tradable pairs and strategy config snapshot values.
- CI now publishes `git.allucanget.biz/allucanget/arbitrade:latest`, and README now documents Coolify image deployment with runtime environment variables managed in Coolify.
- Dashboard strategy-mode validation now allows `stat_arb_experiment` only when feature flag is enabled.
- README now documents deferred cross-exchange architecture requirements, risk assumptions, and promotion milestones for strategy expansion.
### Removed ### Removed
+5 -3
View File
@@ -7,12 +7,14 @@ WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip RUN pip install --no-cache-dir --upgrade pip
COPY requirements /app/requirements
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 . RUN pip install --no-cache-dir --no-deps .
EXPOSE 8000 EXPOSE 9090
CMD ["python", "-m", "arbitrade.main"] CMD ["python", "-m", "arbitrade.main"]
+148 -129
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+
@@ -87,6 +131,12 @@ Install app + dev dependencies:
uv pip install -e .[dev] uv pip install -e .[dev]
``` ```
Dependency source of truth:
- Runtime dependencies live in `requirements/latest-runtime.in`.
- Dev dependencies live in `requirements/latest-dev.in`.
- `pyproject.toml` reads both files dynamically during package install.
Create local env file: Create local env file:
```powershell ```powershell
@@ -98,10 +148,14 @@ Minimum `.env` values:
```env ```env
APP_ENV=dev APP_ENV=dev
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 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=
@@ -126,20 +180,24 @@ python -m arbitrade.main
Health endpoints: Health endpoints:
- HTML: `http://localhost:8000/` - HTML: `http://localhost:9090/`
- JSON: `http://localhost:8000/health` - JSON: `http://localhost:9090/health`
## 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:
@@ -169,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
@@ -201,7 +259,7 @@ mypy src
Run dependency vulnerability audit: Run dependency vulnerability audit:
```powershell ```powershell
pip-audit --skip-editable pip-audit -r requirements/latest-runtime.in
``` ```
Run secret scan (worktree + git history): Run secret scan (worktree + git history):
@@ -242,6 +300,11 @@ Build locally:
docker build -t arbitrade:local . docker build -t arbitrade:local .
``` ```
Container dependency install flow:
- Docker installs runtime dependencies from `requirements/latest-runtime.in`.
- Docker then installs the package with `--no-deps` so dependency resolution is driven by requirements files.
Run with compose: Run with compose:
```powershell ```powershell
@@ -252,8 +315,72 @@ Compose mounts local `data/` folder into container at `/app/data`.
Important: Important:
- [docker-compose.yml](docker-compose.yml) still uses registry image placeholder format. - [docker-compose.yml](docker-compose.yml) uses `git.allucanget.biz/allucanget/arbitrade:latest` as the default image reference.
- Replace `git.allucanget.biz/OWNER/arbitrade:latest` with actual namespace image, likely `git.allucanget.biz/allucanget/arbitrade:latest`.
## Coolify Deployment (Prebuilt Image)
Use this when deploying from the image published by CI instead of building from Git inside Coolify.
### 1) Create application in Coolify
- In Coolify, create a new `Application` using `Docker Image` / `Public Image` / `Private Registry Image`.
- Image: `git.allucanget.biz/allucanget/arbitrade:latest`
- Registry: `git.allucanget.biz`
- If registry auth is required, configure the same registry credentials in Coolify.
### 2) Configure build and start behavior
Set these in Coolify application settings:
- Build Command: leave empty.
- Install Command: leave empty.
- Start Command: leave empty unless you explicitly want to override the image default.
- Port: `9090` (coolify uses `8000` internally)
### 3) Configure health check and networking
- Health Check Path: `/health`
- Exposed Port: `9090`
- Use Coolify-generated domain or attach your own domain.
### 4) Configure persistent storage
Add a persistent volume in Coolify:
- Mount Path: `/app/data`
This preserves PostgreSQL data and other runtime artifacts across restarts/redeploys.
### 5) Configure environment variables
Add runtime environment variables in Coolify (UI: Environment Variables):
- `APP_ENV=prod`
- `APP_HOST=0.0.0.0`
- `APP_PORT=9090`
- `PG_HOST=postgres`
`PG_PORT=5432`
`PG_DATABASE=arbitrade`
`PG_USER=arbitrade`
`PG_PASSWORD=arbitrade`
- `LOG_LEVEL=INFO`
- `LOG_JSON=true`
- `KRAKEN_API_KEY=...`
- `KRAKEN_API_SECRET=...`
- `KRAKEN_API_KEY_PERMISSIONS=query,trade`
Recommended:
- Configure `FERNET_KEY` in Coolify secrets (do not commit it).
- Keep all exchange keys/secrets in Coolify secret variables only.
Coolify should own runtime configuration through environment variables. CI only publishes the image.
### 6) Deploy and verify
- Trigger deploy in Coolify after CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
- Verify app boot logs show startup completed.
- Verify `GET /health` returns success on deployed URL.
## Gitea CI / Registry Setup ## Gitea CI / Registry Setup
@@ -265,13 +392,6 @@ Required Gitea Actions secrets:
- `REGISTRY_USERNAME` - `REGISTRY_USERNAME`
- `REGISTRY_TOKEN` - `REGISTRY_TOKEN`
- `REGISTRY_NAMESPACE`
Expected namespace now likely:
```text
allucanget
```
Example registry login: Example registry login:
@@ -282,115 +402,14 @@ docker login git.allucanget.biz
Example pushed image tag shape: Example pushed image tag shape:
```text ```text
git.allucanget.biz/allucanget/arbitrade:<tag> git.allucanget.biz/allucanget/arbitrade:latest
``` ```
## Project Layout ## Architecture Docs
```text Implementation detail moved into docs:
arbitrade/
├── .gitea/workflows/ci.yml
├── .github/instructions/TODO.md
├── PLAN.md
├── pyproject.toml
├── src/arbitrade/
│ ├── api/
│ ├── config/
│ ├── storage/
│ ├── logging_setup.py
│ └── main.py
├── tests/
└── web/templates/
```
## Next Work - [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.
Next planned implementation slice: For navigation from README, use the docs above instead of this file for deep architecture detail.
- Kraken REST client skeleton
- native Kraken WebSocket client
- in-memory order book cache
- latency instrumentation
## Troubleshooting
PowerShell blocks activation script:
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
```
Then activate again:
```powershell
.\.venv\Scripts\Activate.ps1
```
If app import fails, confirm editable install ran:
```powershell
uv pip install -e .[dev]
```
If DuckDB file missing, start app once or create `data/` directory manually.
## Security Hardening
Threat model notes:
- Primary risk surfaces: environment secrets, dashboard auth credentials, exchange API key scope, and dependency supply chain.
- Assumed attacker model: leaked repository content, leaked CI logs/artifacts, or unauthorized dashboard access.
- High-impact outcomes to prevent: credential exfiltration, unauthorized withdrawals, and unsafe live-trading control changes.
Hardening checklist:
- Use least-privilege Kraken API keys: query + trade only; never enable withdrawal.
- Rotate API keys immediately if secret scan flags a potential exposure.
- Keep dashboard auth enabled in non-local environments and avoid default/shared credentials.
- Run `pip-audit --skip-editable` in CI; treat vulnerability findings as release blockers.
- Run `python scripts/security_scan.py` before release and after major merges.
- Store secrets in environment/secret manager; never commit `.env` or key material.
## Performance Hardening
Profile scenarios:
- `book_update_burst`
- `execution_spike`
- `reconnect_storm`
## Backtesting
Run a deterministic replay backtest from a JSONL event stream:
```powershell
python scripts/backtest_replay.py --events path\to\replay.jsonl --starting-balances USD=1000.0
```
Replay event format:
```json
{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[100.0,1.0]],"asks":[[101.0,1.0]]}
```
Notes:
- Events are replayed in timestamp order.
- The replay engine reuses the production detector, pre-trade validation, trade limits, and execution sequencer.
- The simulated execution path applies configurable slippage and execution latency so reports include deterministic trade/miss statistics.
Latency baseline and threshold artifacts:
- `ops/performance/latency_baseline.json`
- `ops/performance/latency_thresholds.json`
CI guardrail:
- `.gitea/workflows/ci.yml` runs `scripts/check_latency_regression.py` and fails on regression.
Measured optimization impact (2026-06-01):
- `MetricsCalculator.compute()` switched from Python row scans to DuckDB SQL aggregates/quantiles.
- Benchmark (`scripts/benchmark_metrics_compute.py`):
- Python scan avg: `12.623 ms`
- SQL aggregate avg: `11.039 ms`
- Speedup: `1.14x`
+2 -2
View File
@@ -1,13 +1,13 @@
services: services:
arbitrade: arbitrade:
image: git.allucanget.biz/OWNER/arbitrade:latest image: git.allucanget.biz/allucanget/arbitrade:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
env_file: env_file:
- .env - .env
ports: ports:
- "8000:8000" - "9090:9090"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
+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.
+159
View File
@@ -0,0 +1,159 @@
# Arbitrade Architecture Overview (arc42)
## 1. Introduction and Goals
Arbitrade is a Python 3.12+ cryptocurrency arbitrage bot for Kraken, focused on triangular arbitrage on a single exchange. The system is designed for low-latency detection, configurable risk control, replayable backtesting, and operator-visible dashboard control.
Primary goals:
- 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.
- Persist operational data in PostgreSQL for all environments.
- Provide operator controls, audit trail, and alerting through a server-rendered dashboard.
- Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags.
## 2. Constraints
- Python 3.12+ runtime.
- Native Kraken WebSocket on the hot path.
- HTMX + Jinja2 UI, no SPA build step.
- PostgreSQL everywhere.
- Self-hosted Gitea Actions CI and Gitea registry.
- Windows development support.
- Secrets must stay out of the repository.
## 3. Context and Scope
### 3.1 Business Context
The bot consumes Kraken market data, detects opportunities, and executes trades or paper-trades depending on configuration. Operators monitor and control the system through a dashboard and alerting channels.
### 3.2 Technical Context
- Kraken REST + WebSocket provide market data and execution.
- FastAPI serves HTML fragments, JSON endpoints, and SSE streams.
- PostgreSQL stores trades, opportunities, snapshots, audit events, and runtime state.
- Coolify can deploy the published image using environment variables and persistent storage.
## 4. Solution Strategy
- Use an incremental currency graph to re-score only cycles touched by a changed pair.
- Use top-of-book plus depth-aware pricing and configurable fee/slippage buffers.
- Use a single-process asyncio model with uvloop where available.
- Keep strategy, risk, execution, and backtesting logic reusable across paper and replay modes.
- Expose configuration through the dashboard and environment variables.
## 5. Building Block View
### 5.1 Runtime Blocks
- `api/` - FastAPI app, routes, dashboard fragments, backtesting endpoints.
- `exchange/` - Kraken REST and WebSocket integration.
- `market_data/` - live order-book state and ingestion.
- `detection/` - triangular graph and incremental detector.
- `risk/` - pre-trade and trade-limit guards.
- `execution/` - multi-leg trade sequencing.
- `backtesting/` - replay engine, parameter sweep, experiment scaffolds. See [backtesting.md](backtesting.md).
- `strategy/` - experimental strategy modules such as stat-arb.
- `storage/` - PostgreSQL schema and repositories.
- `alerting/` - multi-channel notifications.
- `runtime/` - startup recovery and graceful shutdown.
### 5.2 Important Dependencies
- `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates.
- `orjson` for low-alloc parsing.
- `sortedcontainers` for book state.
- `asyncpg` for PostgreSQL persistence.
- `pydantic` / `pydantic-settings` for typed configuration.
- `cryptography` / keyring for secret handling.
## 6. Runtime View
### 6.1 Live Trading Flow
1. Kraken WS delivers book updates.
2. Order book updates in memory.
3. Incremental detector scores impacted cycles.
4. Risk manager validates the opportunity.
5. Execution sequencer places legs if approved.
6. Trades and snapshots persist to PostgreSQL.
7. Dashboard and alerts reflect state changes.
### 6.2 Dashboard Control Flow
- `/dashboard/control/*` mutates runtime state.
- `/dashboard/fragment/*` renders HTMX partials.
- `/dashboard/stream/*` provides SSE live updates.
- `/dashboard/backtesting` provides a dedicated replay control page.
### 6.3 Backtesting Flow
See [backtesting.md](backtesting.md) for full design and implementation details.
1. User picks currency pairs (from config/pairings page, or all enabled).
2. User sets starting balances (required), time range (required), min profit threshold (required).
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.1 Local Development
- `uv venv` for environment creation.
- `uv pip install -e .[dev]` for editable install.
- `docker compose up --build` for local container workflow.
### 7.2 CI/CD
- Gitea Actions runs lint, tests, security checks, latency guards, and image publish.
- CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
### 7.3 Coolify
- Deploy from the published image.
- Configure runtime via environment variables.
- Connect to PostgreSQL at configured `PG_HOST`.
## 8. Cross-Cutting Concepts
- Staleness checks prevent stale book execution.
- Kill switch halts execution immediately.
- Audit trail records dashboard and runtime decisions.
- Alerting spans Telegram, Discord, and email.
- Feature flags gate experimental strategy code.
- Config is environment-driven and validated at startup.
## 9. Architecture Decisions
- Native Kraken WS instead of a generic exchange abstraction on the hot path.
- PostgreSQL as the single database engine.
- HTMX + Jinja2 instead of SPA frontend.
- Backtesting reuses production detector/risk/execution logic.
- Experimental stat-arb stays behind a feature flag.
- Published image is the deployment artifact; Coolify owns runtime env vars.
## 10. Quality Requirements
- Low latency on book-update-to-decision path.
- Safe startup and restart behavior.
- Strong operator visibility.
- Reproducible backtests and sweeps.
- Secrets protection and strict validation.
## 11. Risks and Technical Debt
- Exchange API schema changes.
- Spread decay and execution slippage.
- Cross-venue strategy complexity if/when enabled.
- UI and backtesting paths can drift if not kept aligned with production logic.
## 12. Glossary
- WS: WebSocket.
- HTMX: HTML-over-the-wire UI library.
- SSE: Server-Sent Events.
- PGSQL: PostgreSQL database used for all environments.
- 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` |
@@ -0,0 +1,61 @@
# Current Implementation Snapshot
This document summarizes the code that exists now, not the original plan.
## Runtime
- FastAPI app starts from [src/arbitrade/api/app.py](../../src/arbitrade/api/app.py).
- Settings come from `pydantic-settings` in [src/arbitrade/config/settings.py](../../src/arbitrade/config/settings.py).
- DuckDB is initialized and migrated on startup.
- 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
- Kraken market data is handled by native WS and thin REST code.
- Incremental triangular detector is implemented in [src/arbitrade/detection/engine.py](../../src/arbitrade/detection/engine.py).
- Currency graph and cycle indexing live in [src/arbitrade/detection/graph.py](../../src/arbitrade/detection/graph.py).
## Execution and Risk
- Multi-leg execution sequencer exists for triangular cycles.
- Pre-trade validation and trade-limit guards are wired into execution flow.
- Kill switch and stop conditions are supported.
## Dashboard
- Server-rendered dashboard uses FastAPI + Jinja2 + HTMX.
- Live metrics, overview, controls, charts, and audit fragments are exposed as separate endpoints.
- Dedicated backtesting page exists at `/dashboard/backtesting`.
## Backtesting
- Replay engine lives in [src/arbitrade/backtesting/replay.py](../../src/arbitrade/backtesting/replay.py).
- Parameter sweep runner lives in [src/arbitrade/backtesting/sweep.py](../../src/arbitrade/backtesting/sweep.py).
- Backtesting UI runs replay from a JSONL file, stores recent reports in app state, and exposes a recent-reports API.
- Experimental stat-arb scaffold lives in [src/arbitrade/strategy/stat_arb.py](../../src/arbitrade/strategy/stat_arb.py) and is gated by feature flag.
## Deployment
- Dockerfile installs runtime dependencies from `requirements/latest-runtime.in`.
- CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
- Coolify deploys the prebuilt image and owns runtime env vars and persistent storage.
## Current Gaps
- Cross-exchange arbitrage remains deferred.
- Stat-arb is experimental, not part of default live strategy.
- Backtesting UI is functional but still a single-run/report workflow, not a full job queue.
+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`):
+25 -34
View File
@@ -1,6 +1,6 @@
[build-system] [build-system]
requires = ["hatchling>=1.25.0"] requires = ["setuptools>=69.0.0", "wheel>=0.43.0"]
build-backend = "hatchling.build" build-backend = "setuptools.build_meta"
[project] [project]
name = "arbitrade" name = "arbitrade"
@@ -8,42 +8,33 @@ version = "0.1.0"
description = "Low-latency Kraken arbitrage bot" description = "Low-latency Kraken arbitrage bot"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dynamic = ["dependencies", "optional-dependencies"]
"cryptography>=43.0.0",
"duckdb>=1.1.0",
"fastapi>=0.115.0",
"httptools>=0.6.1",
"httpx>=0.28.0",
"jinja2>=3.1.0",
"keyring>=25.0.0",
"orjson>=3.10.0",
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"structlog>=24.4.0",
"sortedcontainers>=2.4.0",
"uvicorn[standard]>=0.31.0",
"uvloop>=0.20.0; platform_system != 'Windows'",
"websockets>=13.0",
]
[project.optional-dependencies] [tool.setuptools.dynamic]
dev = [ dependencies = {file = ["requirements/latest-runtime.in"]}
"black>=24.8.0",
"mypy>=1.11.0", [tool.setuptools.dynamic.optional-dependencies]
"pre-commit>=3.8.0", dev = {file = ["requirements/latest-dev.in"]}
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"respx>=0.21.1",
"ruff>=0.6.0",
"vcrpy>=6.0.0",
]
[project.scripts] [project.scripts]
arbitrade = "arbitrade.main:main" arbitrade = "arbitrade.main:main"
arbitrade-bench-detection = "arbitrade.detection.benchmark:main" arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
[tool.hatch.build.targets.wheel] [tool.setuptools]
packages = ["src/arbitrade"] 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]
where = ["src"]
[tool.black] [tool.black]
line-length = 100 line-length = 100
@@ -55,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"
@@ -66,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]
+10
View File
@@ -0,0 +1,10 @@
# Unpinned dev dependencies (latest available)
asyncpg-stubs
black
mypy
pre-commit
pytest
pytest-asyncio
respx
ruff
vcrpy
+16
View File
@@ -0,0 +1,16 @@
# Unpinned runtime dependencies (latest available)
asyncpg
cryptography
fastapi
httptools
httpx
jinja2
keyring
orjson
pydantic
pydantic-settings
sortedcontainers
structlog
uvicorn[standard]
uvloop ; platform_system != "Windows"
websockets
+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
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import argparse
from collections.abc import Mapping, Sequence
from pathlib import Path
from arbitrade.backtesting import load_replay_events
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepResult,
build_parameter_grid,
persist_sweep_results,
run_parameter_search,
)
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
def _parse_balances(raw: str) -> Mapping[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
stripped = entry.strip()
if not stripped:
continue
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances
def _parse_float_list(raw: str) -> list[float]:
values = [item.strip() for item in raw.split(",") if item.strip()]
if not values:
raise ValueError("expected at least one numeric value")
return [float(value) for value in values]
def _parse_pair_universes(raw: str) -> list[tuple[str, ...]]:
universes: list[tuple[str, ...]] = []
for chunk in raw.split(";"):
symbols = tuple(item.strip().upper() for item in chunk.split("|") if item.strip())
if symbols:
universes.append(symbols)
if not universes:
raise ValueError("at least one pair universe must be provided")
return universes
def _build_graph_from_symbols(symbols: Sequence[str]) -> dict[str, list[TriangularCycle]]:
graph = CurrencyGraph()
for symbol in symbols:
normalized = symbol.upper()
if "/" not in normalized:
continue
base, quote = normalized.split("/", 1)
graph.add_pair(base, quote, normalized)
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles)
def _print_top_results(results: Sequence[SweepResult], *, limit: int = 5) -> None:
print(f"Top {min(limit, len(results))} result(s) by out-of-sample score:")
for index, result in enumerate(results[:limit], start=1):
print(
"- "
f"#{index} "
f"theta={result.parameters.min_profit_threshold:.6f}, "
f"capital={result.parameters.trade_capital:.2f}, "
f"pairs={','.join(result.parameters.pair_universe)}, "
f"staleness={result.parameters.staleness_threshold_seconds:.2f}s, "
f"test_score={result.test_score:.4f}, "
f"promotion_ready={result.promotion_ready}"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Run backtesting parameter sweep with train/test split."
)
parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--theta-values", type=str, default="0.0003,0.0005,0.0008")
parser.add_argument("--trade-capital-values", type=str, default="50,100,150")
parser.add_argument(
"--pair-universes",
type=str,
default="BTC/USD|ETH/BTC|ETH/USD",
help="Semicolon-separated universes, each with | delimited pairs",
)
parser.add_argument("--staleness-threshold-values", type=str, default="3,5,8")
parser.add_argument("--train-ratio", type=float, default=0.7)
parser.add_argument(
"--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-win-rate", type=float, default=0.5)
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-generalization-gap-ratio", type=float, default=0.5)
args = parser.parse_args()
events = load_replay_events(args.events)
symbols = sorted({event.symbol.upper() for event in events})
cycles_by_pair = _build_graph_from_symbols(symbols)
if not cycles_by_pair:
raise SystemExit("No triangular cycles found in supplied replay events")
grid = build_parameter_grid(
theta_values=_parse_float_list(args.theta_values),
trade_capital_values=_parse_float_list(args.trade_capital_values),
pair_universes=_parse_pair_universes(args.pair_universes),
staleness_threshold_values=_parse_float_list(args.staleness_threshold_values),
)
artifacts = run_parameter_search(
events=events,
cycles_by_pair=cycles_by_pair,
parameter_grid=grid,
starting_balances=_parse_balances(args.starting_balances),
train_ratio=args.train_ratio,
promotion_criteria=PromotionCriteria(
min_test_realized_pnl_usd=args.min_test_realized_pnl_usd,
min_test_win_rate=args.min_test_win_rate,
min_test_fill_rate=args.min_test_fill_rate,
max_test_drawdown_usd=args.max_test_drawdown_usd,
max_generalization_gap_ratio=args.max_generalization_gap_ratio,
),
)
persist_sweep_results(args.output, artifacts)
print(f"Completed sweep combinations: {len(artifacts.results)}")
print(f"Promotion-ready combinations: {len(artifacts.promoted)}")
print(f"Results written: {args.output}")
_print_top_results(artifacts.results)
if artifacts.promoted:
print("Promotion candidates (paper-trading canary):")
_print_top_results(artifacts.promoted)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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
+164 -4
View File
@@ -1,40 +1,200 @@
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.dashboard_controls = DashboardControlState( app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active, is_running=not settings.kill_switch_active,
) )
+347 -497
View File
@@ -1,369 +1,82 @@
from __future__ import annotations from __future__ import annotations
import json import json
from asyncio import Lock
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from importlib import resources
from pathlib import Path from pathlib import Path
from typing import cast
from urllib.parse import parse_qs
import duckdb from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus
from arbitrade.api.auth import require_dashboard_auth from arbitrade.api.auth import require_dashboard_auth
from arbitrade.api.control_state import DashboardControlState from arbitrade.config.pairing_sync import sync_pairings_from_kraken
from arbitrade.storage.repositories import AuditRecord, AuditRepository from arbitrade.dashboard.context import (
_backtesting_panel_context,
_recent_backtest_reports,
)
from arbitrade.dashboard.dashboard import (
_alert_status_snapshot,
_dashboard_audit,
_dashboard_backtesting_handler,
_dashboard_backtesting_job_export,
_dashboard_backtesting_job_handler,
_dashboard_charts,
_dashboard_config_context,
_dashboard_controls,
_dashboard_ctl_cfg,
_dashboard_ctl_kill,
_dashboard_ctl_start,
_dashboard_ctl_stop,
_dashboard_metrics,
_dashboard_overview,
_dashboard_pairings_response,
_dashboard_response,
_pairing_repo,
_toggle_pairing,
)
from arbitrade.storage.repositories import (
BacktestJobRepository,
ConfigPairingRepository,
LogRepository,
)
router = APIRouter(dependencies=[Depends(require_dashboard_auth)]) router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
public_router = APIRouter() public_router = APIRouter()
templates = Jinja2Templates(
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
)
def _format_metric(value: float | None, *, precision: int = 2, suffix: str = "") -> str: def _resolve_templates_directory() -> str:
if value is None: # Support source layout, Docker runtime (/app), and installed package data.
return "" source_layout_path = Path(__file__).resolve().parents[3] / "web" / "templates"
return f"{value:.{precision}f}{suffix}" if source_layout_path.is_dir():
return str(source_layout_path)
docker_runtime_path = Path.cwd() / "web" / "templates"
if docker_runtime_path.is_dir():
return str(docker_runtime_path)
try:
package_path = resources.files("arbitrade").joinpath("web", "templates")
if package_path.is_dir():
return str(package_path)
except (ModuleNotFoundError, AttributeError):
pass
return str(source_layout_path)
def _dashboard_metrics(request: Request) -> dict[str, str]: templates = Jinja2Templates(directory=_resolve_templates_directory())
metrics = request.app.state.metrics.compute() _BACKTEST_ROOT = Path(__file__).resolve().parents[3]
return { _BACKTEST_RUN_LOCK = Lock()
"realized_pnl": _format_metric(metrics.realized_pnl_usd, precision=2, suffix=" USD"),
"win_rate": _format_metric(
metrics.win_rate * 100.0 if metrics.win_rate is not None else None,
precision=1,
suffix="%",
),
"avg_trade_duration": _format_metric(
metrics.avg_trade_duration_seconds, precision=1, suffix=" s"
),
"opportunities_per_minute": _format_metric(
metrics.opportunities_per_minute, precision=1, suffix=" /min"
),
"fill_rate": _format_metric(
metrics.fill_rate * 100.0 if metrics.fill_rate is not None else None,
precision=1,
suffix="%",
),
"latency_p50": _format_metric(metrics.latency_p50_seconds, precision=3, suffix=" s"),
"latency_p95": _format_metric(metrics.latency_p95_seconds, precision=3, suffix=" s"),
"latency_p99": _format_metric(metrics.latency_p99_seconds, precision=3, suffix=" s"),
"generated_at": datetime.now(UTC).isoformat(),
}
def _table_columns(conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]: async def _health_response(request: Request) -> HTMLResponse:
rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall()
return {str(row[1]) for row in rows}
def _dashboard_overview(request: Request) -> dict[str, object]:
store = request.app.state.store
with store.connect() as conn:
trade_columns = _table_columns(conn, "trades")
trade_ref_expr = "trade_ref" if "trade_ref" in trade_columns else "CAST(id AS VARCHAR)"
cycle_expr = "cycle" if "cycle" in trade_columns else "NULL"
if "finished_at" in trade_columns:
open_trade_filter = "finished_at IS NULL"
else:
open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')"
portfolio_row = conn.execute("""
SELECT balances, total_value_usd
FROM portfolio_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
open_trades = conn.execute(f"""
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
FROM trades
WHERE {open_trade_filter}
ORDER BY started_at DESC
LIMIT 5
""").fetchall()
pnl_total_row = conn.execute("""
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
FROM trades
""").fetchone()
latest_opportunities = conn.execute("""
SELECT cycle, net_pct, est_profit, detected_at
FROM opportunities
ORDER BY detected_at DESC
LIMIT 5
""").fetchall()
balances_value = ""
total_value = ""
if portfolio_row is not None:
balances_raw, total_value_raw = portfolio_row
balances_value = str(balances_raw) if balances_raw is not None else ""
if total_value_raw is not None:
total_value = f"{float(total_value_raw):.2f} USD"
open_trade_rows = [
{
"trade_ref": str(row[0]),
"status": str(row[1]),
"started_at": row[2].isoformat() if isinstance(row[2], datetime) else "",
"cycle": str(row[3]) if row[3] is not None else "",
}
for row in open_trades
]
opportunity_rows = [
{
"cycle": str(row[0]),
"net_pct": f"{float(row[1]):.2f}%" if row[1] is not None else "",
"est_profit": f"{float(row[2]):.2f} USD" if row[2] is not None else "",
"detected_at": row[3].isoformat() if isinstance(row[3], datetime) else "",
}
for row in latest_opportunities
]
return {
"status": "live",
"generated_at": datetime.now(UTC).isoformat(),
"balances": balances_value,
"total_value": total_value,
"open_trade_count": len(open_trade_rows),
"open_trades": open_trade_rows,
"realized_pnl_total": f"{float(pnl_total_row[0]):.2f} USD" if pnl_total_row else "",
"opportunities": opportunity_rows,
}
def _dashboard_charts(request: Request) -> dict[str, object]:
store = request.app.state.store
with store.connect() as conn:
opportunity_rows = conn.execute("""
SELECT detected_at, cycle, net_pct, est_profit
FROM opportunities
ORDER BY detected_at DESC
LIMIT 10
""").fetchall()
chart_rows = list(reversed(opportunity_rows))
labels = [
row[0].isoformat() if isinstance(row[0], datetime) else f"opportunity-{index + 1}"
for index, row in enumerate(chart_rows)
]
net_pct_values = [float(row[2]) if row[2] is not None else 0.0 for row in chart_rows]
est_profit_values = [float(row[3]) if row[3] is not None else 0.0 for row in chart_rows]
cycles = [str(row[1]) for row in chart_rows]
return {
"labels": labels,
"net_pct_values": net_pct_values,
"est_profit_values": est_profit_values,
"cycles": cycles,
"has_chart_data": bool(chart_rows),
"generated_at": datetime.now(UTC).isoformat(),
}
def _dashboard_controls_state(request: Request) -> DashboardControlState:
return cast(DashboardControlState, request.app.state.dashboard_controls)
def _audit_repository(request: Request) -> AuditRepository | None:
repository = getattr(request.app.state, "audit_repository", None)
return cast(AuditRepository | None, repository)
def _record_audit(
request: Request,
*,
actor: str,
event_type: str,
decision: str,
payload: dict[str, object] | None = None,
) -> None:
repository = _audit_repository(request)
if repository is None:
return
correlation_id = request.headers.get("x-request-id")
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor=actor,
event_type=event_type,
decision=decision,
payload=None if payload is None else {str(key): payload[key] for key in payload},
correlation_id=correlation_id,
)
)
def _dashboard_audit(request: Request, *, limit: int = 15) -> dict[str, object]:
repository = _audit_repository(request)
if repository is None:
return {
"entries": [],
"generated_at": datetime.now(UTC).isoformat(),
}
records = repository.list_recent(limit=limit)
entries: list[dict[str, str]] = []
for record in records:
payload_text = ""
if record.payload:
payload_text = json.dumps(record.payload)
entries.append(
{
"occurred_at": record.occurred_at.isoformat(),
"actor": record.actor,
"event_type": record.event_type,
"decision": record.decision,
"payload": payload_text,
"correlation_id": record.correlation_id or "",
}
)
return {
"entries": entries,
"generated_at": datetime.now(UTC).isoformat(),
}
def _alert_notifier(request: Request) -> SupportsAlerts | None:
notifier = getattr(request.app.state, "alert_notifier", None)
return cast(SupportsAlerts | None, notifier)
def _alert_status_snapshot(request: Request) -> dict[str, object]:
notifier = getattr(request.app.state, "alert_notifier", None)
if isinstance(notifier, SupportsAlertStatus):
return notifier.status_snapshot()
return {
"enabled": False,
"has_channels": False,
"configured_channels": [],
"min_severity": "",
"dedup_seconds": 0.0,
"last_result": "unavailable",
"last_attempted_at": None,
"last_success_at": None,
"last_error": None,
"last_event": None,
"last_channel_results": [],
}
def _dashboard_controls(request: Request) -> dict[str, object]:
controls = _dashboard_controls_state(request)
settings = request.app.state.settings
alert_status = _alert_status_snapshot(request)
last_event = alert_status.get("last_event")
last_event_title = ""
if isinstance(last_event, dict):
title_value = last_event.get("title")
if isinstance(title_value, str):
last_event_title = title_value
configured_channels = alert_status.get("configured_channels")
channels_display = ""
if isinstance(configured_channels, list) and configured_channels:
channels_display = ", ".join(str(channel) for channel in configured_channels)
dedup_seconds_raw = alert_status.get("dedup_seconds", 0.0)
dedup_seconds = float(dedup_seconds_raw) if isinstance(dedup_seconds_raw, int | float) else 0.0
tradable_pairs_display = (
", ".join(controls.tradable_pairs) if controls.tradable_pairs else "All"
)
return {
"execution_status": "running" if controls.is_running else "stopped",
"kill_switch_status": "active" if controls.kill_switch.is_active else "inactive",
"kill_switch_reason": controls.kill_switch.reason or "",
"paper_trading_mode": "enabled" if settings.paper_trading_mode else "disabled",
"trade_capital_usd": f"{float(settings.trade_capital_usd):.2f} USD",
"trade_capital_usd_value": f"{float(settings.trade_capital_usd):.2f}",
"max_trade_capital_usd": (
""
if settings.max_trade_capital_usd is None
else f"{float(settings.max_trade_capital_usd):.2f} USD"
),
"max_trade_capital_usd_value": (
""
if settings.max_trade_capital_usd is None
else f"{float(settings.max_trade_capital_usd):.2f}"
),
"max_concurrent_trades": (
"" if settings.max_concurrent_trades is None else str(settings.max_concurrent_trades)
),
"max_concurrent_trades_value": (
"" if settings.max_concurrent_trades is None else str(settings.max_concurrent_trades)
),
"alerts_enabled": "enabled" if bool(alert_status.get("enabled", False)) else "disabled",
"alerts_channels": channels_display,
"alerts_min_severity": str(alert_status.get("min_severity", "")),
"alerts_dedup_seconds": f"{dedup_seconds:.0f}",
"alerts_last_result": str(alert_status.get("last_result", "unavailable")),
"alerts_last_attempted_at": str(alert_status.get("last_attempted_at") or ""),
"alerts_last_success_at": str(alert_status.get("last_success_at") or ""),
"alerts_last_event_title": last_event_title,
"alerts_last_error": str(alert_status.get("last_error") or ""),
"alerts_last_channel_results": [
str(item) for item in cast(list[object], alert_status.get("last_channel_results", []))
],
"tradable_pairs_display": tradable_pairs_display,
"tradable_pairs_value": ", ".join(controls.tradable_pairs),
"strategy_mode": controls.strategy_mode,
"strategy_profit_threshold": f"{controls.strategy_profit_threshold:.6f}",
"strategy_max_depth_levels": str(controls.strategy_max_depth_levels),
"updated_at": controls.updated_at.isoformat(),
"start_endpoint": "/dashboard/control/start",
"stop_endpoint": "/dashboard/control/stop",
"kill_switch_endpoint": "/dashboard/control/kill-switch",
"config_endpoint": "/dashboard/control/config",
"chart_endpoint": "/dashboard/fragment/charts",
}
def _parse_form_body(body: bytes) -> dict[str, str]:
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
return {key: values[-1] for key, values in parsed.items() if values}
def _form_bool(value: str | None) -> bool:
if value is None:
return False
return value.lower() in {"1", "true", "yes", "on"}
def _parse_comma_separated_list(value: str | None) -> list[str]:
if value is None:
return []
items: list[str] = []
for raw_item in value.split(","):
item = raw_item.strip().upper()
if item and item not in items:
items.append(item)
return items
async def _dashboard_response(
request: Request, template_name: str = "dashboard.html"
) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name=template_name, name="health.html",
context={ context={"title": "Arbitrade Health Check"},
"title": "Arbitrade Dashboard",
"request": request,
"metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls",
"charts_endpoint": "/dashboard/fragment/charts",
"audit_endpoint": "/dashboard/fragment/audit",
"stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview",
},
) )
@@ -377,12 +90,60 @@ async def dashboard(request: Request) -> HTMLResponse:
return await _dashboard_response(request) return await _dashboard_response(request)
@router.get("/dashboard/health", response_class=HTMLResponse)
async def dashboard_health_page(request: Request) -> HTMLResponse:
return await _health_response(request)
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="backtesting.html",
context={
"title": "Arbitrade Backtesting",
"request": request,
"panel_endpoint": "/dashboard/fragment/backtesting",
"dashboard_endpoint": "/dashboard",
},
)
@router.get("/dashboard/fragment/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse:
ctx = await _backtesting_panel_context(request)
ctx["flash_message"] = ""
# Check if any pairings are enabled
repo = ConfigPairingRepository(request.app.state.store)
enabled = await repo.list_pairings(enabled_only=True)
ctx["no_enabled_pairings"] = len(enabled) == 0
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **ctx},
)
@router.get("/dashboard/fragment/backtesting-pairings", response_class=HTMLResponse)
async def dashboard_backtesting_pairings_fragment(request: Request) -> HTMLResponse:
"""HTMX fragment: pairing checkboxes for backtest form."""
store = request.app.state.store
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings()
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/backtesting_pairings.html",
context={"request": request, "pairings": pairings},
)
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse) @router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
async def dashboard_metrics(request: Request) -> HTMLResponse: async def dashboard_metrics(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="partials/metrics.html", name="partials/metrics.html",
context={"request": request, **_dashboard_metrics(request)}, context={"request": request, **(await _dashboard_metrics(request))},
) )
@@ -391,7 +152,7 @@ async def dashboard_overview(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="partials/overview.html", name="partials/overview.html",
context={"request": request, **_dashboard_overview(request)}, context={"request": request, **(await _dashboard_overview(request))},
) )
@@ -409,7 +170,29 @@ async def dashboard_charts(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="partials/charts.html", name="partials/charts.html",
context={"request": request, **_dashboard_charts(request)}, context={"request": request, **(await _dashboard_charts(request))},
)
@router.get("/dashboard/audit", response_class=HTMLResponse)
async def dashboard_audit_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="audit.html",
context={
"title": "Arbitrade Audit Trail",
"request": request,
**(await _dashboard_audit(request)),
},
)
@router.get("/dashboard/audit/fragment", response_class=HTMLResponse)
async def dashboard_audit_fragment(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/audit.html",
context={"request": request, **(await _dashboard_audit(request))},
) )
@@ -418,7 +201,51 @@ async def dashboard_audit(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="partials/audit.html", name="partials/audit.html",
context={"request": request, **_dashboard_audit(request)}, context={"request": request, **(await _dashboard_audit(request))},
)
@router.get("/dashboard/config/pairings", response_class=HTMLResponse)
async def dashboard_config_pairings_page(
request: Request,
search: str | None = None,
enabled: str | None = None,
) -> HTMLResponse:
"""Standalone pairings management page."""
return templates.TemplateResponse(
request=request,
name="pairings.html",
context={
"title": "Currency Pairings",
"request": request,
"search": search or "",
"enabled": enabled or "all",
},
)
@router.get("/dashboard/config", response_class=HTMLResponse)
async def dashboard_config_page(request: Request) -> HTMLResponse:
d_context = await _dashboard_config_context(request)
return templates.TemplateResponse(
request=request,
name="config.html",
context={
"title": "Arbitrade Configuration",
"request": request,
"config_endpoint": "/dashboard/control/config",
**d_context,
},
)
@router.get("/dashboard/fragment/config", response_class=HTMLResponse)
async def dashboard_config_fragment(request: Request) -> HTMLResponse:
d_context = await _dashboard_config_context(request)
return templates.TemplateResponse(
request=request,
name="partials/config.html",
context={"request": request, **d_context},
) )
@@ -429,178 +256,76 @@ async def dashboard_alert_status(request: Request) -> JSONResponse:
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse) @router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
async def dashboard_audit_recent(request: Request) -> JSONResponse: async def dashboard_audit_recent(request: Request) -> JSONResponse:
return JSONResponse(_dashboard_audit(request, limit=25)) return JSONResponse(await _dashboard_audit(request, limit=25))
@router.get("/dashboard/api/backtesting/reports", response_class=JSONResponse)
async def dashboard_backtesting_reports(request: Request) -> JSONResponse:
return JSONResponse(
{
"generated_at": datetime.now(UTC).isoformat(),
"reports": await _recent_backtest_reports(request),
}
)
@router.post("/dashboard/backtesting/run", response_class=HTMLResponse)
async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
"""Submit a backtest job to the async queue. Returns panel with job list."""
return await _dashboard_backtesting_handler(request)
@router.post("/dashboard/backtesting/job/{job_id}/delete", response_class=HTMLResponse)
async def dashboard_backtesting_delete(request: Request, job_id: str) -> HTMLResponse:
store = request.app.state.store
repo = BacktestJobRepository(store)
context = await _backtesting_panel_context(request)
await repo.delete_job(job_id)
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **context},
)
@router.get("/dashboard/backtesting/job/{job_id}", response_class=HTMLResponse)
async def dashboard_backtesting_job_detail(request: Request, job_id: str) -> HTMLResponse:
return await _dashboard_backtesting_job_handler(request, job_id=job_id)
@router.get("/dashboard/backtesting/job/{job_id}/export", response_class=Response)
async def dashboard_backtesting_export(request: Request, job_id: str) -> Response:
return await _dashboard_backtesting_job_export(request, job_id=job_id)
@router.post("/dashboard/control/start", response_class=HTMLResponse) @router.post("/dashboard/control/start", response_class=HTMLResponse)
async def dashboard_control_start(request: Request) -> HTMLResponse: async def dashboard_control_start(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request) return await _dashboard_ctl_start(request)
controls.is_running = True
controls.mark_updated()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="info",
title="Execution started",
message="Dashboard control started execution.",
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
)
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/stop", response_class=HTMLResponse) @router.post("/dashboard/control/stop", response_class=HTMLResponse)
async def dashboard_control_stop(request: Request) -> HTMLResponse: async def dashboard_control_stop(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request) return await _dashboard_ctl_stop(request)
controls.is_running = False
controls.mark_updated()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="warning",
title="Execution stopped",
message="Dashboard control stopped execution.",
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.stop",
decision="approved",
payload={"execution_status": "stopped"},
)
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse) @router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse: async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request) return await _dashboard_ctl_kill(request)
form = _parse_form_body(await request.body())
reason = form.get("reason") or "manual"
controls.kill_switch.activate(reason=reason)
controls.is_running = False
controls.mark_updated()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="threshold",
severity="critical",
title="Kill switch activated",
message="Kill switch triggered from dashboard control.",
details={"reason": reason},
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.kill_switch",
decision="approved",
payload={"reason": reason},
)
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/config", response_class=HTMLResponse) @router.post("/dashboard/control/config", response_class=HTMLResponse)
async def dashboard_control_config(request: Request) -> HTMLResponse: async def dashboard_control_config(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request) return await _dashboard_ctl_cfg(request)
settings = request.app.state.settings
form = _parse_form_body(await request.body())
if "trade_capital_usd" in form and form["trade_capital_usd"]:
settings.trade_capital_usd = float(form["trade_capital_usd"])
if "max_trade_capital_usd" in form:
max_trade_capital_value = form["max_trade_capital_usd"].strip()
settings.max_trade_capital_usd = (
float(max_trade_capital_value) if max_trade_capital_value else None
)
if "max_concurrent_trades" in form:
max_concurrent_value = form["max_concurrent_trades"].strip()
settings.max_concurrent_trades = int(max_concurrent_value) if max_concurrent_value else None
controls.tradable_pairs = _parse_comma_separated_list(form.get("tradable_pairs"))
if "strategy_mode" in form and form["strategy_mode"].strip():
strategy_mode = form["strategy_mode"].strip().lower()
if strategy_mode not in {"incremental", "paper", "live"}:
raise ValueError("strategy_mode must be one of: incremental, paper, live")
controls.strategy_mode = strategy_mode
if "strategy_profit_threshold" in form and form["strategy_profit_threshold"].strip():
controls.strategy_profit_threshold = float(form["strategy_profit_threshold"])
if "strategy_max_depth_levels" in form and form["strategy_max_depth_levels"].strip():
controls.strategy_max_depth_levels = int(form["strategy_max_depth_levels"])
settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode"))
controls.mark_updated()
notifier = _alert_notifier(request)
if notifier is not None:
await notifier.notify(
category="system",
severity="info",
title="Runtime config updated",
message="Dashboard control updated runtime risk and execution settings.",
details={
"trade_capital_usd": f"{settings.trade_capital_usd}",
"max_trade_capital_usd": (
"none"
if settings.max_trade_capital_usd is None
else f"{settings.max_trade_capital_usd}"
),
"max_concurrent_trades": (
"none"
if settings.max_concurrent_trades is None
else f"{settings.max_concurrent_trades}"
),
"paper_trading_mode": "true" if settings.paper_trading_mode else "false",
},
)
_record_audit(
request,
actor="dashboard_user",
event_type="dashboard.control.config",
decision="approved",
payload={
"trade_capital_usd": settings.trade_capital_usd,
"max_trade_capital_usd": settings.max_trade_capital_usd,
"max_concurrent_trades": settings.max_concurrent_trades,
"paper_trading_mode": settings.paper_trading_mode,
"tradable_pairs": controls.tradable_pairs,
"strategy_mode": controls.strategy_mode,
"strategy_profit_threshold": controls.strategy_profit_threshold,
"strategy_max_depth_levels": controls.strategy_max_depth_levels,
},
)
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.get("/dashboard/stream/metrics") @router.get("/dashboard/stream/metrics")
async def dashboard_metrics_stream(request: Request) -> StreamingResponse: async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
metrics = await _dashboard_metrics(request)
fragment = ( fragment = (
templates.get_template("partials/metrics.html") templates.get_template("partials/metrics.html")
.render( .render(
request=request, request=request,
**_dashboard_metrics(request), **metrics,
) )
.strip() .strip()
.replace("\n", "") .replace("\n", "")
@@ -615,9 +340,10 @@ async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
@router.get("/dashboard/stream/overview") @router.get("/dashboard/stream/overview")
async def dashboard_overview_stream(request: Request) -> StreamingResponse: async def dashboard_overview_stream(request: Request) -> StreamingResponse:
overview = await _dashboard_overview(request)
fragment = ( fragment = (
templates.get_template("partials/overview.html") templates.get_template("partials/overview.html")
.render(request=request, **_dashboard_overview(request)) .render(request=request, **overview)
.strip() .strip()
.replace("\n", "") .replace("\n", "")
) )
@@ -632,3 +358,127 @@ async def dashboard_overview_stream(request: Request) -> StreamingResponse:
@public_router.get("/health", response_class=JSONResponse) @public_router.get("/health", response_class=JSONResponse)
async def health() -> JSONResponse: async def health() -> JSONResponse:
return JSONResponse({"status": "ok", "service": "arbitrade"}) return JSONResponse({"status": "ok", "service": "arbitrade"})
# ── Pairing API ─────────────────────────────────────────────────────────────
@router.get("/dashboard/api/pairings", response_class=JSONResponse)
async def dashboard_api_pairings(
request: Request,
search: str | None = None,
enabled: str | None = None,
source: str | None = None,
base: str | None = None,
quote: str | None = None,
sort: str = "base_asset",
order: str = "asc",
) -> JSONResponse:
"""List pairings with optional filters."""
return await _dashboard_pairings_response(
request,
search=search,
enabled=enabled,
source=source,
base=base,
quote=quote,
sort=sort,
order=order,
)
@router.get("/dashboard/fragment/pairings", response_class=HTMLResponse)
async def dashboard_pairings_fragment(
request: Request,
search: str | None = None,
enabled: str | None = None,
) -> HTMLResponse:
"""HTMX fragment: pairing table for config page."""
repo = _pairing_repo(request)
pairings = await repo.list_pairings()
# Apply search filter
if search:
sl = search.lower()
pairings = [
p for p in pairings if sl in p.base_asset.lower() or sl in p.quote_asset.lower()
]
if enabled is not None and enabled.lower() != "all":
eb = enabled.lower() == "true"
pairings = [p for p in pairings if p.enabled == eb]
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/pairings_table.html",
context={"request": request, "pairings": pairings},
)
@router.post("/dashboard/api/pairings/toggle")
async def dashboard_api_pairings_toggle(request: Request) -> HTMLResponse:
return await _toggle_pairing(request)
@router.post("/dashboard/api/pairings/sync", response_class=HTMLResponse)
async def dashboard_api_pairings_sync(request: Request) -> HTMLResponse:
"""Sync pairings from Kraken and return refreshed table."""
store = request.app.state.store
kraken = getattr(request.app.state, "kraken_client", None)
if kraken is not None:
await sync_pairings_from_kraken(kraken, store)
repo = _pairing_repo(request)
pairings = await repo.list_pairings()
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/pairings_table.html",
context={"request": request, "pairings": pairings},
)
# ── Log routes ──────────────────────────────────────────────────────────────
@router.get("/dashboard/fragment/logs", response_class=HTMLResponse)
async def dashboard_logs_fragment(
request: Request,
level: str | None = None,
page: int = 1,
per_page: int = 50,
) -> HTMLResponse:
"""HTMX fragment: log table for health page."""
repo = LogRepository(request.app.state.store)
offset = (page - 1) * per_page
records = await repo.query(level=level, limit=per_page, offset=offset)
total = await repo.count_filtered(level=level)
total_pages = max(1, (total + per_page - 1) // per_page)
return templates.TemplateResponse(
request=request,
name="partials/log_table.html",
context={
"request": request,
"records": records,
"page": page,
"total_pages": total_pages,
"total": total,
"current_level": level or "all",
},
)
@router.post("/dashboard/api/logging/aggregate", response_class=JSONResponse)
async def dashboard_logging_aggregate(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_aggregation
await run_log_aggregation(request.app.state.store)
return JSONResponse({"status": "ok"})
@router.post("/dashboard/api/logging/archive", response_class=JSONResponse)
async def dashboard_logging_archive(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_archive
count = await run_log_archive(request.app.state.store)
return JSONResponse({"status": "ok", "archived": count})
+18
View File
@@ -6,6 +6,16 @@ from arbitrade.backtesting.replay import (
ReplayClock, ReplayClock,
load_replay_events, load_replay_events,
) )
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepArtifacts,
SweepParameters,
SweepResult,
build_parameter_grid,
persist_sweep_results,
run_parameter_search,
split_events_time_windows,
)
__all__ = [ __all__ = [
"ReplayClock", "ReplayClock",
@@ -14,4 +24,12 @@ __all__ = [
"BacktestReport", "BacktestReport",
"BacktestReplayEngine", "BacktestReplayEngine",
"load_replay_events", "load_replay_events",
"SweepParameters",
"SweepResult",
"SweepArtifacts",
"PromotionCriteria",
"split_events_time_windows",
"build_parameter_grid",
"run_parameter_search",
"persist_sweep_results",
] ]
+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")
+391
View File
@@ -0,0 +1,391 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import orjson
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
BacktestReport,
ReplayBookEvent,
)
from arbitrade.detection.graph import TriangularCycle
@dataclass(frozen=True, slots=True)
class SweepParameters:
min_profit_threshold: float
trade_capital: float
pair_universe: tuple[str, ...]
staleness_threshold_seconds: float
@dataclass(frozen=True, slots=True)
class PromotionCriteria:
min_test_realized_pnl_usd: float = 0.0
min_test_win_rate: float = 0.5
min_test_fill_rate: float = 0.9
max_test_drawdown_usd: float = 25.0
max_generalization_gap_ratio: float = 0.5
@dataclass(frozen=True, slots=True)
class SweepResult:
parameters: SweepParameters
train_report: BacktestReport
test_report: BacktestReport
train_score: float
test_score: float
generalization_gap_ratio: float
overfit_detected: bool
promotion_ready: bool
promotion_reasons: tuple[str, ...]
train_event_count: int
test_event_count: int
@dataclass(frozen=True, slots=True)
class SweepArtifacts:
results: tuple[SweepResult, ...]
promoted: tuple[SweepResult, ...]
train_window: tuple[datetime, datetime] | None
test_window: tuple[datetime, datetime] | None
def split_events_time_windows(
events: Sequence[ReplayBookEvent],
*,
train_ratio: float,
) -> tuple[list[ReplayBookEvent], list[ReplayBookEvent]]:
if train_ratio <= 0.0 or train_ratio >= 1.0:
raise ValueError("train_ratio must be between 0 and 1")
if len(events) < 2:
raise ValueError("at least two events are required for time split")
split_index = max(1, min(len(events) - 1, int(len(events) * train_ratio)))
return list(events[:split_index]), list(events[split_index:])
def build_parameter_grid(
*,
theta_values: Sequence[float],
trade_capital_values: Sequence[float],
pair_universes: Sequence[Sequence[str]],
staleness_threshold_values: Sequence[float],
) -> list[SweepParameters]:
if not theta_values:
raise ValueError("theta_values must not be empty")
if not trade_capital_values:
raise ValueError("trade_capital_values must not be empty")
if not pair_universes:
raise ValueError("pair_universes must not be empty")
if not staleness_threshold_values:
raise ValueError("staleness_threshold_values must not be empty")
grid: list[SweepParameters] = []
for theta in theta_values:
for trade_capital in trade_capital_values:
for pair_universe in pair_universes:
normalized_universe = tuple(sorted({pair.upper() for pair in pair_universe}))
for staleness_threshold in staleness_threshold_values:
grid.append(
SweepParameters(
min_profit_threshold=float(theta),
trade_capital=float(trade_capital),
pair_universe=normalized_universe,
staleness_threshold_seconds=float(staleness_threshold),
)
)
return grid
def _filter_events_for_parameters(
events: Sequence[ReplayBookEvent],
*,
pair_universe: set[str],
staleness_threshold_seconds: float,
) -> list[ReplayBookEvent]:
if staleness_threshold_seconds <= 0.0:
raise ValueError("staleness_threshold_seconds must be > 0")
filtered: list[ReplayBookEvent] = []
last_seen_by_symbol: dict[str, datetime] = {}
for event in events:
symbol = event.symbol.upper()
if symbol not in pair_universe:
continue
previous = last_seen_by_symbol.get(symbol)
last_seen_by_symbol[symbol] = event.occurred_at
if previous is None:
filtered.append(event)
continue
gap_seconds = (event.occurred_at - previous).total_seconds()
if gap_seconds <= staleness_threshold_seconds:
filtered.append(event)
return filtered
def _restrict_cycles_by_pair(
cycles_by_pair: Mapping[str, list[TriangularCycle]],
*,
pair_universe: set[str],
) -> dict[str, list[TriangularCycle]]:
restricted: dict[str, list[TriangularCycle]] = {}
for pair_symbol, cycles in cycles_by_pair.items():
normalized_pair = pair_symbol.upper()
if normalized_pair not in pair_universe:
continue
kept = [
cycle for cycle in cycles if all(pair.upper() in pair_universe for pair in cycle.pairs)
]
if kept:
restricted[normalized_pair] = kept
return restricted
def _score_report(report: BacktestReport) -> float:
win_rate_bonus = (report.win_rate or 0.0) * 100.0
fill_rate_bonus = (report.fill_rate or 0.0) * 50.0
return report.realized_pnl_usd + win_rate_bonus + fill_rate_bonus - report.max_drawdown_usd
def _safe_ratio(numerator: float, denominator: float) -> float:
if denominator <= 0.0:
return 0.0 if numerator <= 0.0 else 1.0
return max(0.0, numerator / denominator)
def _evaluate_promotion(
*,
result: SweepResult,
criteria: PromotionCriteria,
) -> tuple[bool, tuple[str, ...]]:
reasons: list[str] = []
test = result.test_report
if test.realized_pnl_usd < criteria.min_test_realized_pnl_usd:
reasons.append("test_realized_pnl_below_threshold")
if (test.win_rate or 0.0) < criteria.min_test_win_rate:
reasons.append("test_win_rate_below_threshold")
if (test.fill_rate or 0.0) < criteria.min_test_fill_rate:
reasons.append("test_fill_rate_below_threshold")
if test.max_drawdown_usd > criteria.max_test_drawdown_usd:
reasons.append("test_drawdown_above_threshold")
if result.generalization_gap_ratio > criteria.max_generalization_gap_ratio:
reasons.append("generalization_gap_above_threshold")
return (not reasons), tuple(reasons)
def _run_backtest(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
available_pairs: Sequence[str],
config: BacktestConfig,
starting_balances: Mapping[str, float],
) -> BacktestReport:
started_at = events[0].occurred_at if events else datetime.now(UTC)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=config,
started_at=started_at,
)
return asyncio.run(engine.run(events, starting_balances=starting_balances))
def run_parameter_search(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
parameter_grid: Sequence[SweepParameters],
starting_balances: Mapping[str, float],
train_ratio: float,
promotion_criteria: PromotionCriteria | None = None,
max_concurrent_trades: int = 1,
max_depth_levels: int = 10,
quote_asset: str = "USD",
) -> SweepArtifacts:
criteria = promotion_criteria or PromotionCriteria()
train_events, test_events = split_events_time_windows(events, train_ratio=train_ratio)
results: list[SweepResult] = []
promoted: list[SweepResult] = []
for parameters in parameter_grid:
allowed_pairs = set(parameters.pair_universe)
filtered_train = _filter_events_for_parameters(
train_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
filtered_test = _filter_events_for_parameters(
test_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
if not filtered_train or not filtered_test:
continue
restricted_cycles = _restrict_cycles_by_pair(
cycles_by_pair,
pair_universe=allowed_pairs,
)
if not restricted_cycles:
continue
config = BacktestConfig(
min_profit_threshold=parameters.min_profit_threshold,
trade_capital=parameters.trade_capital,
max_concurrent_trades=max_concurrent_trades,
max_depth_levels=max_depth_levels,
quote_asset=quote_asset,
)
train_report = _run_backtest(
events=filtered_train,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
test_report = _run_backtest(
events=filtered_test,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
train_score = _score_report(train_report)
test_score = _score_report(test_report)
score_drop = max(0.0, train_score - test_score)
generalization_gap_ratio = _safe_ratio(score_drop, abs(train_score))
overfit_detected = generalization_gap_ratio > criteria.max_generalization_gap_ratio
base_result = SweepResult(
parameters=parameters,
train_report=train_report,
test_report=test_report,
train_score=train_score,
test_score=test_score,
generalization_gap_ratio=generalization_gap_ratio,
overfit_detected=overfit_detected,
promotion_ready=False,
promotion_reasons=(),
train_event_count=len(filtered_train),
test_event_count=len(filtered_test),
)
promotion_ready, promotion_reasons = _evaluate_promotion(
result=base_result, criteria=criteria
)
completed_result = SweepResult(
parameters=base_result.parameters,
train_report=base_result.train_report,
test_report=base_result.test_report,
train_score=base_result.train_score,
test_score=base_result.test_score,
generalization_gap_ratio=base_result.generalization_gap_ratio,
overfit_detected=base_result.overfit_detected,
promotion_ready=promotion_ready,
promotion_reasons=promotion_reasons,
train_event_count=base_result.train_event_count,
test_event_count=base_result.test_event_count,
)
results.append(completed_result)
if completed_result.promotion_ready:
promoted.append(completed_result)
results.sort(key=lambda item: item.test_score, reverse=True)
promoted.sort(key=lambda item: item.test_score, reverse=True)
train_window: tuple[datetime, datetime] | None = None
test_window: tuple[datetime, datetime] | None = None
if train_events:
train_window = (train_events[0].occurred_at, train_events[-1].occurred_at)
if test_events:
test_window = (test_events[0].occurred_at, test_events[-1].occurred_at)
return SweepArtifacts(
results=tuple(results),
promoted=tuple(promoted),
train_window=train_window,
test_window=test_window,
)
def _report_to_dict(report: BacktestReport) -> dict[str, object]:
return {
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
"processed_events": report.processed_events,
"opportunities_seen": report.opportunities_seen,
"trades_executed": report.trades_executed,
"win_rate": report.win_rate,
"fill_rate": report.fill_rate,
"realized_pnl_usd": report.realized_pnl_usd,
"max_drawdown_usd": report.max_drawdown_usd,
"miss_reasons": dict(report.miss_reasons),
"execution_latency_p50_ms": report.execution_latency_p50_ms,
"execution_latency_p95_ms": report.execution_latency_p95_ms,
"execution_latency_p99_ms": report.execution_latency_p99_ms,
}
def persist_sweep_results(path: Path, artifacts: SweepArtifacts) -> None:
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"train_window": (
{
"started_at": artifacts.train_window[0].isoformat(),
"finished_at": artifacts.train_window[1].isoformat(),
}
if artifacts.train_window is not None
else None
),
"test_window": (
{
"started_at": artifacts.test_window[0].isoformat(),
"finished_at": artifacts.test_window[1].isoformat(),
}
if artifacts.test_window is not None
else None
),
"results": [
{
"parameters": {
"min_profit_threshold": result.parameters.min_profit_threshold,
"trade_capital": result.parameters.trade_capital,
"pair_universe": list(result.parameters.pair_universe),
"staleness_threshold_seconds": result.parameters.staleness_threshold_seconds,
},
"train_report": _report_to_dict(result.train_report),
"test_report": _report_to_dict(result.test_report),
"train_score": result.train_score,
"test_score": result.test_score,
"generalization_gap_ratio": result.generalization_gap_ratio,
"overfit_detected": result.overfit_detected,
"promotion_ready": result.promotion_ready,
"promotion_reasons": list(result.promotion_reasons),
"train_event_count": result.train_event_count,
"test_event_count": result.test_event_count,
}
for result in artifacts.results
],
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
+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]
+42 -3
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
@@ -17,7 +16,7 @@ class Settings(BaseSettings):
app_env: str = Field(default="dev", alias="APP_ENV") app_env: str = Field(default="dev", alias="APP_ENV")
app_host: str = Field(default="0.0.0.0", alias="APP_HOST") app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
app_port: int = Field(default=8000, alias="APP_PORT") app_port: int = Field(default=9090, alias="APP_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL") log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_json: bool = Field(default=True, alias="LOG_JSON") log_json: bool = Field(default=True, alias="LOG_JSON")
@@ -55,7 +54,14 @@ class Settings(BaseSettings):
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("./data/arbitrade.duckdb"), alias="DUCKDB_PATH") # PostgreSQL connection settings
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(default="https://api.kraken.com", alias="KRAKEN_REST_URL") kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL") kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
@@ -75,6 +81,26 @@ class Settings(BaseSettings):
) )
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS") ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS") ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
strategy_enable_stat_arb_experiment: bool = Field(
default=False,
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
)
strategy_stat_arb_lookback_window: int = Field(
default=120,
alias="STRATEGY_STAT_ARB_LOOKBACK_WINDOW",
)
strategy_stat_arb_entry_zscore: float = Field(
default=2.0,
alias="STRATEGY_STAT_ARB_ENTRY_ZSCORE",
)
strategy_stat_arb_exit_zscore: float = Field(
default=0.5,
alias="STRATEGY_STAT_ARB_EXIT_ZSCORE",
)
strategy_stat_arb_max_holding_seconds: float = Field(
default=900.0,
alias="STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS",
)
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE") 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(default=100.0, alias="MAX_TRADE_CAPITAL_USD") max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
@@ -139,6 +165,19 @@ class Settings(BaseSettings):
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")
if self.strategy_stat_arb_lookback_window < 2:
raise ValueError("STRATEGY_STAT_ARB_LOOKBACK_WINDOW must be >= 2")
if self.strategy_stat_arb_entry_zscore <= 0.0:
raise ValueError("STRATEGY_STAT_ARB_ENTRY_ZSCORE must be > 0")
if self.strategy_stat_arb_exit_zscore < 0.0:
raise ValueError("STRATEGY_STAT_ARB_EXIT_ZSCORE must be >= 0")
if self.strategy_stat_arb_entry_zscore <= self.strategy_stat_arb_exit_zscore:
raise ValueError(
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
)
if self.strategy_stat_arb_max_holding_seconds <= 0.0:
raise ValueError("STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
return self 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]
+59 -70
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,114 +19,103 @@ 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:
trade_metrics = 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() """)
opportunity_metrics = 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() """)
fill_metrics = 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() """)
realized_pnl_usd = ( r_pnl_usd = (
float(trade_metrics[0]) if trade_metrics and trade_metrics[0] is not None else 0.0 float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
) )
total_trades = ( tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
int(trade_metrics[1]) if trade_metrics and trade_metrics[1] 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
winning_trades = (
int(trade_metrics[2]) if trade_metrics and trade_metrics[2] is not None else 0
)
win_rate = winning_trades / total_trades if total_trades > 0 else None
avg_trade_duration_seconds = ( atd = (
float(trade_metrics[3]) if trade_metrics and trade_metrics[3] is not None else None float(tm["avg_trade_duration_seconds"])
) if tm and tm["avg_trade_duration_seconds"] is not None
opportunity_count = (
int(opportunity_metrics[0])
if opportunity_metrics is not None and opportunity_metrics[0] is not None
else 0
)
first_detected_at = (
opportunity_metrics[1]
if opportunity_metrics is not None and isinstance(opportunity_metrics[1], datetime)
else None else None
) )
last_detected_at = (
opportunity_metrics[2] oc = (
if opportunity_metrics is not None and isinstance(opportunity_metrics[2], datetime) int(om["opportunity_count"])
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 else None
) )
opportunities_per_minute: float | None opportunities_per_minute: float | None
if ( if oc >= 2 and fo is not None and lo is not None:
opportunity_count >= 2 span_seconds = (lo - fo).total_seconds()
and first_detected_at is not None
and last_detected_at is not None
):
span_seconds = (last_detected_at - first_detected_at).total_seconds()
opportunities_per_minute = ( opportunities_per_minute = (
opportunity_count / (span_seconds / 60.0) oc / (span_seconds / 60.0) if span_seconds > 0.0 else float(oc)
if span_seconds > 0.0
else float(opportunity_count)
) )
elif opportunity_count == 1: elif oc == 1:
opportunities_per_minute = 60.0 opportunities_per_minute = 60.0
else: else:
opportunities_per_minute = None opportunities_per_minute = None
fill_rate = float(fill_metrics[0]) if fill_metrics and fill_metrics[0] is not None else None fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
latency_p50_seconds = ( lp50 = (
float(trade_metrics[4]) if trade_metrics and trade_metrics[4] is not None else None float(tm["latency_p50_seconds"])
if tm and tm["latency_p50_seconds"] is not None
else None
) )
latency_p95_seconds = ( lp95 = (
float(trade_metrics[5]) if trade_metrics and trade_metrics[5] is not None else None float(tm["latency_p95_seconds"])
if tm and tm["latency_p95_seconds"] is not None
else None
) )
latency_p99_seconds = ( lp99 = (
float(trade_metrics[6]) if trade_metrics and trade_metrics[6] is not None else None float(tm["latency_p99_seconds"])
if tm and tm["latency_p99_seconds"] is not None
else None
) )
return PerformanceMetrics( return PerformanceMetrics(
realized_pnl_usd=realized_pnl_usd, realized_pnl_usd=r_pnl_usd,
win_rate=win_rate, win_rate=wr,
avg_trade_duration_seconds=avg_trade_duration_seconds, avg_trade_duration_seconds=atd,
opportunities_per_minute=opportunities_per_minute, opportunities_per_minute=opportunities_per_minute,
fill_rate=fill_rate, fill_rate=fill_rate,
latency_p50_seconds=latency_p50_seconds, latency_p50_seconds=lp50,
latency_p95_seconds=latency_p95_seconds, latency_p95_seconds=lp95,
latency_p99_seconds=latency_p99_seconds, latency_p99_seconds=lp99,
) )
+37 -34
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,40 +120,41 @@ 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
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport: async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
controls = _controls(app) ctl = _controls(app)
store = _store(app) store = _store(app)
runtime_repository = _runtime_repository(app) repo = _runtime_repository(app)
restored_from_snapshot = False restored_from_snapshot = False
snapshot_at: str | None = None snapshot_at: str | None = None
latest = runtime_repository.latest() if runtime_repository 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()
controls.is_running = latest.is_running ctl.is_running = latest.is_running
if latest.kill_switch_active: if latest.kill_switch_active:
controls.kill_switch.activate(reason=latest.kill_switch_reason or "recovered") r = latest.kill_switch_reason or "recovered"
ctl.kill_switch.activate(reason=r)
else: else:
controls.kill_switch.deactivate() ctl.kill_switch.deactivate()
controls.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:
controls.is_running = False ctl.is_running = False
if not controls.kill_switch.is_active: if not ctl.kill_switch.is_active:
controls.kill_switch.activate(reason="recovery_open_trades_detected") ctl.kill_switch.activate(reason="recovery_open_trades_detected")
controls.mark_updated() ctl.mark_updated()
restart_guard_active = True restart_guard_active = True
report = RuntimeRecoveryReport( report = RuntimeRecoveryReport(
@@ -162,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",
@@ -211,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",
@@ -219,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);
+5
View File
@@ -0,0 +1,5 @@
"""Experimental strategy modules."""
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig, StatArbSignal
__all__ = ["StatArbExperiment", "StatArbExperimentConfig", "StatArbSignal"]
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from statistics import fmean, pstdev
from typing import Literal
@dataclass(frozen=True, slots=True)
class StatArbExperimentConfig:
pair_a: str
pair_b: str
lookback_window: int = 120
entry_zscore: float = 2.0
exit_zscore: float = 0.5
max_holding_seconds: float = 900.0
@dataclass(frozen=True, slots=True)
class StatArbSignal:
action: Literal[
"warmup",
"hold",
"enter_long_spread",
"enter_short_spread",
"exit_position",
]
observed_at: datetime
spread: float
zscore: float | None
position: Literal["long", "short", "flat"]
class StatArbExperiment:
"""Simple mean-reversion experiment scaffold behind feature flags."""
def __init__(self, config: StatArbExperimentConfig) -> None:
if config.lookback_window < 2:
raise ValueError("lookback_window must be >= 2")
if config.entry_zscore <= 0.0:
raise ValueError("entry_zscore must be > 0")
if config.exit_zscore < 0.0:
raise ValueError("exit_zscore must be >= 0")
if config.entry_zscore <= config.exit_zscore:
raise ValueError("entry_zscore must be > exit_zscore")
if config.max_holding_seconds <= 0.0:
raise ValueError("max_holding_seconds must be > 0")
self._config = config
self._spreads: deque[float] = deque(maxlen=config.lookback_window)
self._position: Literal["long", "short", "flat"] = "flat"
self._position_opened_at: datetime | None = None
@property
def config(self) -> StatArbExperimentConfig:
return self._config
def reset(self) -> None:
self._spreads.clear()
self._position = "flat"
self._position_opened_at = None
def observe(
self,
*,
price_a: float,
price_b: float,
observed_at: datetime,
) -> StatArbSignal:
if price_a <= 0.0 or price_b <= 0.0:
raise ValueError("prices must be > 0")
at = observed_at.astimezone(UTC)
spread = price_a - price_b
self._spreads.append(spread)
if len(self._spreads) < self._config.lookback_window:
return StatArbSignal(
action="warmup",
observed_at=at,
spread=spread,
zscore=None,
position=self._position,
)
mean_spread = fmean(self._spreads)
std_spread = pstdev(self._spreads)
if std_spread == 0.0:
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=0.0,
position=self._position,
)
zscore = (spread - mean_spread) / std_spread
if self._position == "flat":
if zscore >= self._config.entry_zscore:
self._position = "short"
self._position_opened_at = at
return StatArbSignal(
action="enter_short_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
if zscore <= -self._config.entry_zscore:
self._position = "long"
self._position_opened_at = at
return StatArbSignal(
action="enter_long_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
assert self._position_opened_at is not None
held_seconds = (at - self._position_opened_at).total_seconds()
should_exit = abs(zscore) <= self._config.exit_zscore
if held_seconds >= self._config.max_holding_seconds:
should_exit = True
if should_exit:
self._position = "flat"
self._position_opened_at = None
return StatArbSignal(
action="exit_position",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
@@ -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,25 +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>
</div>
</section>
<section <section
id="metrics-shell" id="metrics-shell"
@@ -61,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>
@@ -0,0 +1,79 @@
<div id="controls-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Runtime Status</div>
<div class="value">{{ execution_status }}</div>
<div class="meta">Updated {{ updated_at }}</div>
</article>
<article class="card">
<div class="label">Kill Switch</div>
<div class="value">{{ kill_switch_status }}</div>
<div class="meta">Reason {{ kill_switch_reason }}</div>
</article>
<article class="card">
<div class="label">Config Snapshot</div>
<div class="meta">Paper trading: {{ paper_trading_mode }}</div>
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
<div class="meta">Tradable pairs: {{ tradable_pairs_display }}</div>
<div class="meta">Strategy mode: {{ strategy_mode }}</div>
<div class="meta">Profit threshold: {{ strategy_profit_threshold }}</div>
<div class="meta">Max depth levels: {{ strategy_max_depth_levels }}</div>
</article>
<article class="card">
<div class="label">Alerting</div>
<div class="meta">Status: {{ alerts_enabled }}</div>
<div class="meta">Channels: {{ alerts_channels }}</div>
<div class="meta">Min severity: {{ alerts_min_severity }}</div>
<div class="meta">Dedup window: {{ alerts_dedup_seconds }}s</div>
<div class="meta">Last result: {{ alerts_last_result }}</div>
<div class="meta">Last attempted: {{ alerts_last_attempted_at }}</div>
<div class="meta">Last success: {{ alerts_last_success_at }}</div>
<div class="meta">Last event: {{ alerts_last_event_title }}</div>
<div class="meta">Last error: {{ alerts_last_error }}</div>
{% if alerts_last_channel_results %} {% for item in
alerts_last_channel_results %}
<div class="meta">{{ item }}</div>
{% endfor %} {% endif %}
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Execution Controls</div>
<div class="control-actions">
<form
hx-post="{{ start_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button">Start</button>
</form>
<form
hx-post="{{ stop_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button secondary">Stop</button>
</form>
<form
hx-post="{{ kill_switch_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="reason" value="manual" />
<button type="submit" class="button danger">
Trigger Kill Switch
</button>
</form>
</div>
</article>
</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
@@ -197,11 +196,10 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
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
@@ -333,3 +331,54 @@ async def test_dashboard_alert_status_api_exposes_notifier_snapshot(tmp_path) ->
assert payload["enabled"] is True assert payload["enabled"] is True
assert "configured_channels" in payload assert "configured_channels" in payload
assert "last_result" in payload assert "last_result" in payload
async def test_backtesting_page_run_and_recent_reports_api(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "backtesting-ui.duckdb"))
events_file = tmp_path / "replay.jsonl"
events_file.write_text(
"\n".join(
[
'{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[99.5,10.0]],"asks":[[100.0,10.0]]}',
'{"timestamp":"2026-06-01T12:00:01Z","symbol":"ETH/BTC","bids":[[0.051,10.0]],"asks":[[0.050,10.0]]}',
'{"timestamp":"2026-06-01T12:00:02Z","symbol":"ETH/USD","bids":[[110.0,10.0]],"asks":[[110.5,10.0]]}',
]
),
encoding="utf-8",
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
page = await client.get("/dashboard/backtesting")
fragment = await client.get("/dashboard/fragment/backtesting")
run = await client.post(
"/dashboard/backtesting/run",
data={
"source": "db",
"starting_balances": "USD=1000.0",
"trade_capital": "100.0",
"min_profit_threshold": "0.0005",
"fee_profile": "api",
"slippage_bps": "4.0",
"execution_latency_ms": "20.0",
},
)
reports = await client.get("/dashboard/api/backtesting/reports")
assert page.status_code == 200
assert "Backtesting" in page.text
assert "/dashboard/fragment/backtesting" in page.text
assert fragment.status_code == 200
assert "Run Backtest" in fragment.text
assert "Recent Jobs" in fragment.text
assert run.status_code == 200
assert "submitted" in run.text
assert "queued" in run.text
assert reports.status_code == 200
payload = reports.json()
assert len(payload["reports"]) >= 1
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"
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from arbitrade.backtesting.replay import ReplayBookEvent
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepResult,
build_parameter_grid,
run_parameter_search,
split_events_time_windows,
)
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.models import BookLevel
def _build_cycles() -> dict[str, list]:
graph = CurrencyGraph()
graph.add_pair("USD", "BTC", "BTC/USD")
graph.add_pair("BTC", "ETH", "ETH/BTC")
graph.add_pair("ETH", "USD", "ETH/USD")
return graph.index_cycles_by_pair(graph.triangular_cycles())
def _events() -> list[ReplayBookEvent]:
base_time = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
rows: list[ReplayBookEvent] = []
for index in range(12):
tick = base_time + timedelta(seconds=index)
rows.extend(
[
ReplayBookEvent(
occurred_at=tick,
symbol="BTC/USD",
bids=(BookLevel(price=99.5, volume=10.0),),
asks=(BookLevel(price=100.0, volume=10.0),),
),
ReplayBookEvent(
occurred_at=tick,
symbol="ETH/BTC",
bids=(BookLevel(price=0.051, volume=10.0),),
asks=(BookLevel(price=0.050, volume=10.0),),
),
ReplayBookEvent(
occurred_at=tick,
symbol="ETH/USD",
bids=(BookLevel(price=110.0, volume=10.0),),
asks=(BookLevel(price=110.5, volume=10.0),),
),
]
)
return rows
def test_split_events_time_windows_returns_non_empty_train_and_test() -> None:
train, test = split_events_time_windows(_events(), train_ratio=0.7)
assert train
assert test
assert train[-1].occurred_at <= test[0].occurred_at
def test_build_parameter_grid_expands_combinations() -> None:
grid = build_parameter_grid(
theta_values=[0.0005, 0.001],
trade_capital_values=[100.0],
pair_universes=[["BTC/USD", "ETH/BTC", "ETH/USD"]],
staleness_threshold_values=[3.0, 5.0],
)
assert len(grid) == 4
def test_run_parameter_search_produces_ranked_results_with_overfit_guard() -> None:
artifacts = run_parameter_search(
events=_events(),
cycles_by_pair=_build_cycles(),
parameter_grid=build_parameter_grid(
theta_values=[0.0005, 0.001],
trade_capital_values=[75.0, 100.0],
pair_universes=[["BTC/USD", "ETH/BTC", "ETH/USD"]],
staleness_threshold_values=[5.0],
),
starting_balances={"USD": 2000.0},
train_ratio=0.7,
promotion_criteria=PromotionCriteria(
min_test_realized_pnl_usd=-1000.0,
min_test_win_rate=0.0,
min_test_fill_rate=0.0,
max_test_drawdown_usd=1_000_000.0,
max_generalization_gap_ratio=0.9,
),
)
assert artifacts.results
assert artifacts.results[0].test_score >= artifacts.results[-1].test_score
first: SweepResult = artifacts.results[0]
assert first.train_event_count > 0
assert first.test_event_count > 0
assert first.generalization_gap_ratio >= 0.0
assert isinstance(first.promotion_ready, bool)
+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
@@ -53,3 +53,20 @@ def test_valid_security_configuration_passes() -> None:
) )
assert settings.kraken_api_key_permissions == "query,trade" assert settings.kraken_api_key_permissions == "query,trade"
def test_stat_arb_entry_zscore_must_exceed_exit_zscore() -> None:
with pytest.raises(ValidationError):
Settings(
_env_file=None,
STRATEGY_STAT_ARB_ENTRY_ZSCORE="0.5",
STRATEGY_STAT_ARB_EXIT_ZSCORE="0.5",
)
def test_stat_arb_lookback_window_must_be_at_least_two() -> None:
with pytest.raises(ValidationError):
Settings(
_env_file=None,
STRATEGY_STAT_ARB_LOOKBACK_WINDOW="1",
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig
def test_stat_arb_experiment_warmup_then_entry_and_exit() -> None:
started_at = datetime(2026, 6, 2, 12, 0, tzinfo=UTC)
experiment = StatArbExperiment(
StatArbExperimentConfig(
pair_a="BTC/USD",
pair_b="ETH/USD",
lookback_window=5,
entry_zscore=1.5,
exit_zscore=0.2,
max_holding_seconds=0.5,
)
)
# Warmup with nearly stationary spread around 0.
for idx in range(5):
signal = experiment.observe(
price_a=100.0 + (0.02 * idx),
price_b=100.0,
observed_at=started_at + timedelta(seconds=idx),
)
assert signal.action in {"warmup", "hold"}
# Large positive spread should trigger short-spread entry.
entry = experiment.observe(
price_a=104.0,
price_b=100.0,
observed_at=started_at + timedelta(seconds=10),
)
assert entry.action == "enter_short_spread"
assert entry.position == "short"
assert entry.zscore is not None
# Mean reversion toward center should trigger exit.
exit_signal = experiment.observe(
price_a=100.05,
price_b=100.0,
observed_at=started_at + timedelta(seconds=11),
)
assert exit_signal.action == "exit_position"
assert exit_signal.position == "flat"
def test_stat_arb_experiment_rejects_invalid_prices() -> None:
experiment = StatArbExperiment(
StatArbExperimentConfig(
pair_a="BTC/USD",
pair_b="ETH/USD",
lookback_window=5,
)
)
at = datetime(2026, 6, 2, 12, 0, tzinfo=UTC)
try:
experiment.observe(price_a=0.0, price_b=100.0, observed_at=at)
except ValueError as exc:
assert "prices must be > 0" in str(exc)
else:
raise AssertionError("Expected ValueError for non-positive price")

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