Compare commits
76 Commits
6211575db7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4f5d8dfcc | |||
| 403daa6cf1 | |||
| dc99f1604e | |||
| f221464daa | |||
| 5e7732b85f | |||
| 77dfb08b23 | |||
| 9acabddb7e | |||
| 2fbc78f7a9 | |||
| f58634d438 | |||
| e44876c7c7 | |||
| 1e4086a0fd | |||
| cf5ff2e2d8 | |||
| db2e02c316 | |||
| c1dda187af | |||
| af0ac94a12 | |||
| ef22e217c7 | |||
| 529ff967cc | |||
| 54feb2ecd4 | |||
| df2f4f3246 | |||
| 8cfd969dae | |||
| 3f4b9a4012 | |||
| 4c59a0e4cb | |||
| 92b0b49535 | |||
| 44da9220d6 | |||
| df59f5ad7c | |||
| 170f59eb89 | |||
| 8ceca2a7e4 | |||
| c8e3daeb57 | |||
| 7d18bdf316 | |||
| 83f2064fa9 | |||
| 7728f9a8cd | |||
| a83d231d06 | |||
| 1c2558cfb3 | |||
| a0366f06ff | |||
| 86d1046862 | |||
| 6acd6bbbc9 | |||
| ff71fc5feb | |||
| 587c9afc3b | |||
| 5f2f968721 | |||
| 87dd655f08 | |||
| ccca9ef62a | |||
| 57df3a4361 | |||
| 00bd2d664d | |||
| 815284289e | |||
| 107595826a | |||
| 6b5973a0bb | |||
| 1b21f2443a | |||
| 1df4b11aef | |||
| 38e1d64437 | |||
| f612c8533a | |||
| 8ef8dc801d | |||
| df55953d31 | |||
| 5051f2de83 | |||
| 2845721797 | |||
| 9e2f08d40a | |||
| 778b41910a | |||
| 065dcbda20 | |||
| c3c524724e | |||
| 6c7dd0a715 | |||
| 0d1f6961d6 | |||
| d742577484 | |||
| 7c86e838fa | |||
| cc11082ea7 | |||
| c17f41aaf8 | |||
| b413c66ca4 | |||
| bbc806bcef | |||
| 24f2b2ed88 | |||
| cde181f343 | |||
| 0c232b7aee | |||
| 93f4f62d42 | |||
| 240a591a64 | |||
| 45e219d103 | |||
| 9d8a8a8a45 | |||
| a89886186f | |||
| 652b20274a | |||
| 7d3071463e |
@@ -5,7 +5,9 @@ __pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
build
|
||||
data
|
||||
dist
|
||||
logs
|
||||
*.pyc
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
@@ -1,9 +1,61 @@
|
||||
APP_ENV=dev
|
||||
APP_HOST=0.0.0.0
|
||||
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_JSON=true
|
||||
ALERTS_ENABLED=true
|
||||
ALERT_MIN_SEVERITY=warning
|
||||
ALERT_DEDUP_SECONDS=30
|
||||
ALERT_ON_TRADE_EVENTS=true
|
||||
ALERT_ON_ERROR_EVENTS=true
|
||||
ALERT_ON_THRESHOLD_EVENTS=true
|
||||
ALERT_ON_SYSTEM_EVENTS=true
|
||||
TELEGRAM_ALERTS_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
DISCORD_ALERTS_ENABLED=false
|
||||
DISCORD_WEBHOOK_URL=
|
||||
EMAIL_ALERTS_ENABLED=false
|
||||
EMAIL_SMTP_HOST=
|
||||
EMAIL_SMTP_PORT=587
|
||||
EMAIL_SMTP_USERNAME=
|
||||
EMAIL_SMTP_PASSWORD=
|
||||
EMAIL_ALERT_FROM=
|
||||
EMAIL_ALERT_TO=
|
||||
EMAIL_SMTP_USE_TLS=true
|
||||
DUCKDB_PATH=./data/arbitrade.duckdb
|
||||
FERNET_KEY=
|
||||
KRAKEN_API_KEY=
|
||||
KRAKEN_API_SECRET=
|
||||
KRAKEN_API_KEY_PERMISSIONS=query,trade
|
||||
KRAKEN_REST_URL=https://api.kraken.com
|
||||
KRAKEN_WS_URL=wss://ws.kraken.com/v2
|
||||
KRAKEN_PRIVATE_RATE_LIMIT_SECONDS=1.0
|
||||
KRAKEN_HTTP_TIMEOUT_SECONDS=10.0
|
||||
KRAKEN_RETRY_ATTEMPTS=3
|
||||
KRAKEN_RETRY_BASE_DELAY_SECONDS=0.25
|
||||
WS_HEARTBEAT_TIMEOUT_SECONDS=20.0
|
||||
WS_MAX_STALENESS_SECONDS=5.0
|
||||
PAPER_TRADING_MODE=true
|
||||
TRADE_CAPITAL_USD=100.0
|
||||
MAX_TRADE_CAPITAL_USD=100.0
|
||||
MAX_CONCURRENT_TRADES=
|
||||
MAX_EXPOSURE_PER_ASSET_USD=
|
||||
QUOTE_BALANCE_ASSET=USD
|
||||
MIN_ORDER_SIZE_USD=
|
||||
KILL_SWITCH_ACTIVE=false
|
||||
DAILY_LOSS_LIMIT_USD=5.0
|
||||
CUMULATIVE_LOSS_LIMIT_USD=10.0
|
||||
MAX_SOURCE_LATENCY_MS=
|
||||
MAX_APPLY_LATENCY_MS=
|
||||
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
|
||||
|
||||
+17
-3
@@ -23,19 +23,33 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev]
|
||||
pip install pip-audit
|
||||
|
||||
- name: Ruff
|
||||
run: ruff check .
|
||||
|
||||
- name: Black
|
||||
run: black --check .
|
||||
# - name: Black
|
||||
# run: black --check .
|
||||
|
||||
- name: MyPy
|
||||
run: mypy src
|
||||
|
||||
- name: Dependency audit
|
||||
run: pip-audit -r requirements/latest-runtime.in
|
||||
|
||||
- name: Secret scan (worktree + git history)
|
||||
run: python scripts/security_scan.py
|
||||
|
||||
- name: Tests
|
||||
run: pytest -q
|
||||
|
||||
- name: Latency guardrails
|
||||
run: |
|
||||
python scripts/check_latency_regression.py \
|
||||
--baseline ops/performance/latency_baseline.json \
|
||||
--thresholds ops/performance/latency_thresholds.json \
|
||||
--iterations 600
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
@@ -50,4 +64,4 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: git.allucanget.biz/${{ secrets.REGISTRY_NAMESPACE }}/arbitrade:${{ github.sha }}
|
||||
tags: git.allucanget.biz/allucanget/arbitrade:latest
|
||||
|
||||
+6
-1
@@ -31,11 +31,16 @@ Thumbs.db
|
||||
!.env.example
|
||||
secrets/
|
||||
|
||||
# Local build artifacts
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Local database / runtime data
|
||||
data/*.duckdb
|
||||
data/*.duckdb.wal
|
||||
data/*.duckdb.tmp
|
||||
data/arbitrade.duckdb
|
||||
logs/
|
||||
ops/performance/latest_profile.json
|
||||
|
||||
# Node assets if used for frontend tooling
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added stop-condition risk controls for abnormal source/apply latency and repeated execution failures.
|
||||
- Added a new stop-conditions guard and integration in market feed processing.
|
||||
- Added multi-channel alerting infrastructure with Telegram, Discord webhook, and SMTP channel clients.
|
||||
- Added alert configuration settings for severity threshold, category routing, and dedup cooldown.
|
||||
- Added dashboard alert status surfacing with configured channels and last-send delivery outcome.
|
||||
- Added append-only `audit_events` schema plus repository support for insert/query of recent audit records.
|
||||
- Added dashboard audit fragment and protected API endpoint for recent audit entries.
|
||||
- Added runtime lifecycle manager with startup recovery and graceful shutdown orchestration.
|
||||
- Added `runtime_state_snapshots` persistence for control flags, open trade count, and last known balances.
|
||||
- Added CI security gates for dependency auditing (`pip-audit --strict`) and a repository/worktree secret scan script.
|
||||
- Added strict settings validators for auth pairing, Kraken credential pairing, alert severity bounds, and key-scope policy.
|
||||
- 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 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 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
|
||||
|
||||
- Live execution path now auto-activates the kill switch when configured stop conditions are breached.
|
||||
- Added configuration env keys for stop-condition thresholds.
|
||||
- WebSocket client now emits system alerts for disconnect/reconnect and heartbeat staleness timeout events.
|
||||
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
|
||||
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
|
||||
- Added backtesting usage and replay format documentation to README.
|
||||
- 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
|
||||
|
||||
- None.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added/expanded unit coverage for risk limits and kill-switch enforcement, including stop-condition scenarios.
|
||||
- Added partial-fill recovery logic that cancels open orders when possible and hedges residual exposure on timeout or failure.
|
||||
- Added deterministic order idempotency via Kraken userref plus reconciliation helpers for Kraken order history responses.
|
||||
- Added execution journaling for trades, orders, and estimated P&L, plus a DuckDB startup fallback when the default file path is unavailable.
|
||||
- Added a mocked execution integration test that drives the triangular sequencer through the execution journal and DuckDB persistence.
|
||||
- Added a DuckDB-backed performance metrics calculator for realized P&L, win rate, trade duration, opportunities/min, fill rate, and latency percentiles.
|
||||
- Added dashboard page plus HTMX metrics fragment and SSE metrics stream.
|
||||
- Added dashboard live overview panel for status, balances, open trades, realized P&L, and opportunity feed with HTMX refresh and SSE updates.
|
||||
- Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms.
|
||||
- Added Alpine.js interactivity and a Chart.js opportunity trend panel to the dashboard.
|
||||
- Added optional HTTP Basic authentication for dashboard routes, fragments, streams, and control endpoints.
|
||||
- Added alert wiring for dashboard control actions, execution success/failure, and threshold breaches in risk guards.
|
||||
- Added unit/integration tests covering alert notifier behavior and alert emission paths.
|
||||
- Added critical system alert emission when live opportunity executor raises an unhandled exception.
|
||||
- Added WebSocket and market-feed tests for system-event alerting paths.
|
||||
- Added notifier status snapshot tracking and protected alert-status API endpoint for operational visibility.
|
||||
- Added audit event writes for dashboard controls and detector/risk/execution decision points.
|
||||
- Added tests for audit repository and dashboard audit route coverage.
|
||||
- Added startup restart safety guard that halts execution when open trades are detected after restart.
|
||||
- Added lifecycle tests for snapshot persistence, worker draining, recovery restore, and startup reconciliation hook.
|
||||
- Added unit coverage for security-related settings validation paths.
|
||||
- Added latency guardrail unit coverage and documented measured metrics aggregation speedup (`1.14x`).
|
||||
- Added deterministic replay tests that exercise the backtesting path without depending on live services.
|
||||
+5
-3
@@ -7,12 +7,14 @@ WORKDIR /app
|
||||
|
||||
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 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"]
|
||||
|
||||
@@ -6,12 +6,12 @@ Current stack:
|
||||
|
||||
- Python 3.12+
|
||||
- FastAPI + HTMX/Jinja2
|
||||
- DuckDB for dev/test/prod
|
||||
- PostgreSQL for all environments (via asyncpg)
|
||||
- Native Kraken WebSocket planned for market-data hot path
|
||||
- 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).
|
||||
Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
|
||||
## Current Status
|
||||
|
||||
@@ -21,7 +21,7 @@ Bootstrap complete for foundation layer:
|
||||
- typed settings and env loading
|
||||
- structured logging
|
||||
- encrypted secret helpers
|
||||
- DuckDB connection + base schema
|
||||
- PostgreSQL connection + full schema migration
|
||||
- FastAPI app with health endpoint
|
||||
- Gitea Actions CI scaffold
|
||||
- Docker / docker-compose scaffold
|
||||
@@ -34,6 +34,50 @@ Not implemented yet:
|
||||
- trade execution
|
||||
- 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
|
||||
|
||||
- Python 3.12+
|
||||
@@ -87,6 +131,12 @@ Install app + dev dependencies:
|
||||
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:
|
||||
|
||||
```powershell
|
||||
@@ -98,18 +148,25 @@ Minimum `.env` values:
|
||||
```env
|
||||
APP_ENV=dev
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
APP_PORT=9090
|
||||
LOG_LEVEL=INFO
|
||||
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=
|
||||
KRAKEN_API_KEY=
|
||||
KRAKEN_API_SECRET=
|
||||
KRAKEN_API_KEY_PERMISSIONS=query,trade
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Leave Kraken creds empty until Kraken integration lands.
|
||||
- If Kraken creds are set, both key and secret are required.
|
||||
- `KRAKEN_API_KEY_PERMISSIONS` must include `query,trade` and must not include withdrawal scope.
|
||||
- `FERNET_KEY` optional. If empty, keyring-backed key generation used by secret helper.
|
||||
- On Windows, app falls back to default `asyncio` loop. On non-Windows, `uvloop` installs automatically.
|
||||
|
||||
@@ -123,20 +180,24 @@ python -m arbitrade.main
|
||||
|
||||
Health endpoints:
|
||||
|
||||
- HTML: `http://localhost:8000/`
|
||||
- JSON: `http://localhost:8000/health`
|
||||
- HTML: `http://localhost:9090/`
|
||||
- JSON: `http://localhost:9090/health`
|
||||
|
||||
## Database
|
||||
|
||||
DuckDB used everywhere: local dev, tests, production.
|
||||
PostgreSQL used everywhere: local dev, tests, production.
|
||||
|
||||
Default database file:
|
||||
Default connection:
|
||||
|
||||
```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:
|
||||
|
||||
@@ -145,6 +206,30 @@ Current tables:
|
||||
- `trades`
|
||||
- `portfolio_snapshots`
|
||||
|
||||
Audit trail table:
|
||||
|
||||
- `audit_events` (append-only operational decision log)
|
||||
|
||||
Audit retention and compaction guidance:
|
||||
|
||||
- Keep at least 30 days of `audit_events` in active DB for incident triage.
|
||||
- Archive older rows to a timestamped export file before deletion.
|
||||
- Example monthly archive workflow:
|
||||
|
||||
```sql
|
||||
COPY (
|
||||
SELECT *
|
||||
FROM audit_events
|
||||
WHERE occurred_at < NOW() - INTERVAL 30 DAY
|
||||
) TO 'data/audit_events_archive_YYYYMM.parquet' (FORMAT PARQUET);
|
||||
|
||||
DELETE FROM audit_events
|
||||
WHERE occurred_at < NOW() - INTERVAL 30 DAY;
|
||||
```
|
||||
|
||||
- Back up archive files and the PostgreSQL database together.
|
||||
- For production, run archive + backup as scheduled maintenance (cron/task scheduler).
|
||||
|
||||
## Quality Checks
|
||||
|
||||
Run tests:
|
||||
@@ -171,6 +256,30 @@ Run mypy:
|
||||
mypy src
|
||||
```
|
||||
|
||||
Run dependency vulnerability audit:
|
||||
|
||||
```powershell
|
||||
pip-audit -r requirements/latest-runtime.in
|
||||
```
|
||||
|
||||
Run secret scan (worktree + git history):
|
||||
|
||||
```powershell
|
||||
python scripts/security_scan.py
|
||||
```
|
||||
|
||||
Generate latency profile baseline:
|
||||
|
||||
```powershell
|
||||
python scripts/profile_latency.py --iterations 600 --output ops/performance/latency_baseline.json
|
||||
```
|
||||
|
||||
Run latency regression guardrails:
|
||||
|
||||
```powershell
|
||||
python scripts/check_latency_regression.py --baseline ops/performance/latency_baseline.json --thresholds ops/performance/latency_thresholds.json --iterations 600
|
||||
```
|
||||
|
||||
Install pre-commit hooks:
|
||||
|
||||
```powershell
|
||||
@@ -191,6 +300,11 @@ Build locally:
|
||||
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:
|
||||
|
||||
```powershell
|
||||
@@ -201,8 +315,72 @@ Compose mounts local `data/` folder into container at `/app/data`.
|
||||
|
||||
Important:
|
||||
|
||||
- [docker-compose.yml](docker-compose.yml) still uses registry image placeholder format.
|
||||
- Replace `git.allucanget.biz/OWNER/arbitrade:latest` with actual namespace image, likely `git.allucanget.biz/allucanget/arbitrade:latest`.
|
||||
- [docker-compose.yml](docker-compose.yml) uses `git.allucanget.biz/allucanget/arbitrade:latest` as the default image reference.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -214,13 +392,6 @@ Required Gitea Actions secrets:
|
||||
|
||||
- `REGISTRY_USERNAME`
|
||||
- `REGISTRY_TOKEN`
|
||||
- `REGISTRY_NAMESPACE`
|
||||
|
||||
Expected namespace now likely:
|
||||
|
||||
```text
|
||||
allucanget
|
||||
```
|
||||
|
||||
Example registry login:
|
||||
|
||||
@@ -231,54 +402,14 @@ docker login git.allucanget.biz
|
||||
Example pushed image tag shape:
|
||||
|
||||
```text
|
||||
git.allucanget.biz/allucanget/arbitrade:<tag>
|
||||
git.allucanget.biz/allucanget/arbitrade:latest
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
## Architecture Docs
|
||||
|
||||
```text
|
||||
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/
|
||||
```
|
||||
Implementation detail moved into docs:
|
||||
|
||||
## 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:
|
||||
|
||||
- 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.
|
||||
For navigation from README, use the docs above instead of this file for deep architecture detail.
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
services:
|
||||
arbitrade:
|
||||
image: git.allucanget.biz/OWNER/arbitrade:latest
|
||||
image: git.allucanget.biz/allucanget/arbitrade:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
# Performance Hardening
|
||||
|
||||
This folder contains latency profiling baselines and guardrail thresholds used in CI.
|
||||
|
||||
## Scenarios
|
||||
|
||||
The profiler covers representative load patterns:
|
||||
|
||||
- `book_update_burst`: rapid market-data deltas with moderate detection load.
|
||||
- `execution_spike`: heavier detection/execution pressure.
|
||||
- `reconnect_storm`: frequent reconnect/reset behavior.
|
||||
|
||||
## Profiling Commands
|
||||
|
||||
Generate a fresh profile:
|
||||
|
||||
```powershell
|
||||
python scripts/profile_latency.py --iterations 600 --output ops/performance/latency_baseline.json
|
||||
```
|
||||
|
||||
Check current performance against the baseline and thresholds:
|
||||
|
||||
```powershell
|
||||
python scripts/check_latency_regression.py \
|
||||
--baseline ops/performance/latency_baseline.json \
|
||||
--thresholds ops/performance/latency_thresholds.json \
|
||||
--iterations 600
|
||||
```
|
||||
|
||||
CI executes the same guardrail check.
|
||||
|
||||
## Baseline Snapshot (2026-06-01)
|
||||
|
||||
Key end-to-end latency baselines from `latency_baseline.json`:
|
||||
|
||||
- `book_update_burst`: p95 = 0.0132 ms, p99 = 0.0198 ms
|
||||
- `execution_spike`: p95 = 0.0139 ms, p99 = 0.0177 ms
|
||||
- `reconnect_storm`: p95 = 0.0114 ms, p99 = 0.0134 ms
|
||||
|
||||
## Optimization Note
|
||||
|
||||
`MetricsCalculator.compute()` uses PostgreSQL SQL aggregations and percentiles, reducing Python-side row scans.
|
||||
|
||||
Measured benchmark (`scripts/benchmark_metrics_compute.py`):
|
||||
|
||||
- Python scan baseline: 12.623 ms
|
||||
- SQL aggregate implementation: 11.039 ms
|
||||
- Speedup: 1.14x
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"iterations": 600,
|
||||
"scenarios": {
|
||||
"book_update_burst": {
|
||||
"iterations": 600,
|
||||
"stages": {
|
||||
"ingest": {
|
||||
"p50_ms": 0.0028,
|
||||
"p95_ms": 0.0056,
|
||||
"p99_ms": 0.0083
|
||||
},
|
||||
"detect": {
|
||||
"p50_ms": 0.0034,
|
||||
"p95_ms": 0.005899999999999999,
|
||||
"p99_ms": 0.0081
|
||||
},
|
||||
"risk": {
|
||||
"p50_ms": 0.0002,
|
||||
"p95_ms": 0.0003,
|
||||
"p99_ms": 0.0006
|
||||
},
|
||||
"execution": {
|
||||
"p50_ms": 0.0006,
|
||||
"p95_ms": 0.0012,
|
||||
"p99_ms": 0.0020009999999999993
|
||||
},
|
||||
"end_to_end": {
|
||||
"p50_ms": 0.007,
|
||||
"p95_ms": 0.013204999999999996,
|
||||
"p99_ms": 0.019801
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution_spike": {
|
||||
"iterations": 600,
|
||||
"stages": {
|
||||
"ingest": {
|
||||
"p50_ms": 0.0029,
|
||||
"p95_ms": 0.003,
|
||||
"p99_ms": 0.00431099999999999
|
||||
},
|
||||
"detect": {
|
||||
"p50_ms": 0.0097,
|
||||
"p95_ms": 0.0101,
|
||||
"p99_ms": 0.012404999999999996
|
||||
},
|
||||
"risk": {
|
||||
"p50_ms": 0.0002,
|
||||
"p95_ms": 0.00019999999999999998,
|
||||
"p99_ms": 0.0003
|
||||
},
|
||||
"execution": {
|
||||
"p50_ms": 0.0006,
|
||||
"p95_ms": 0.0007,
|
||||
"p99_ms": 0.001000999999999999
|
||||
},
|
||||
"end_to_end": {
|
||||
"p50_ms": 0.0135,
|
||||
"p95_ms": 0.0139,
|
||||
"p99_ms": 0.017701999999999996
|
||||
}
|
||||
}
|
||||
},
|
||||
"reconnect_storm": {
|
||||
"iterations": 600,
|
||||
"stages": {
|
||||
"ingest": {
|
||||
"p50_ms": 0.0029,
|
||||
"p95_ms": 0.0039,
|
||||
"p99_ms": 0.0047
|
||||
},
|
||||
"detect": {
|
||||
"p50_ms": 0.0051,
|
||||
"p95_ms": 0.006,
|
||||
"p99_ms": 0.007101999999999998
|
||||
},
|
||||
"risk": {
|
||||
"p50_ms": 0.0002,
|
||||
"p95_ms": 0.00019999999999999998,
|
||||
"p99_ms": 0.0003009999999999991
|
||||
},
|
||||
"execution": {
|
||||
"p50_ms": 0.0006,
|
||||
"p95_ms": 0.0007999999999999999,
|
||||
"p99_ms": 0.0011009999999999991
|
||||
},
|
||||
"end_to_end": {
|
||||
"p50_ms": 0.0088,
|
||||
"p95_ms": 0.0114,
|
||||
"p99_ms": 0.013403999999999998
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-06-01T12:35:48.836000+00:00"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"default": {
|
||||
"p95_ms": 3.0,
|
||||
"p99_ms": 3.5
|
||||
},
|
||||
"scenarios": {
|
||||
"execution_spike": {
|
||||
"p95_ms": 3.2,
|
||||
"p99_ms": 3.8
|
||||
},
|
||||
"reconnect_storm": {
|
||||
"p95_ms": 3.4,
|
||||
"p99_ms": 4.0
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-32
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["setuptools>=69.0.0", "wheel>=0.43.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "arbitrade"
|
||||
@@ -8,39 +8,33 @@ version = "0.1.0"
|
||||
description = "Low-latency Kraken arbitrage bot"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
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",
|
||||
]
|
||||
dynamic = ["dependencies", "optional-dependencies"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black>=24.8.0",
|
||||
"mypy>=1.11.0",
|
||||
"pre-commit>=3.8.0",
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = {file = ["requirements/latest-runtime.in"]}
|
||||
|
||||
[tool.setuptools.dynamic.optional-dependencies]
|
||||
dev = {file = ["requirements/latest-dev.in"]}
|
||||
|
||||
[project.scripts]
|
||||
arbitrade = "arbitrade.main:main"
|
||||
arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/arbitrade"]
|
||||
[tool.setuptools]
|
||||
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]
|
||||
line-length = 100
|
||||
@@ -52,7 +46,7 @@ target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "N", "ASYNC"]
|
||||
ignore = ["E203"]
|
||||
ignore = ["E203", "E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
@@ -63,7 +57,7 @@ pretty = true
|
||||
mypy_path = "src"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["duckdb", "keyring", "uvloop"]
|
||||
module = ["asyncpg", "keyring", "sortedcontainers"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Unpinned dev dependencies (latest available)
|
||||
asyncpg-stubs
|
||||
black
|
||||
mypy
|
||||
pre-commit
|
||||
pytest
|
||||
pytest-asyncio
|
||||
respx
|
||||
ruff
|
||||
vcrpy
|
||||
@@ -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
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import duckdb
|
||||
|
||||
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||
|
||||
|
||||
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
|
||||
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
|
||||
if fee_rate is not None:
|
||||
return fee_rate
|
||||
if db_path is not None:
|
||||
try:
|
||||
conn = duckdb.connect(db_path)
|
||||
row = conn.execute("""
|
||||
SELECT maker_fee FROM kraken_account_snapshots
|
||||
ORDER BY snapshot_at DESC LIMIT 1
|
||||
""").fetchone()
|
||||
conn.close()
|
||||
if row is not None and row[0] is not None:
|
||||
return float(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0026 # ultimate fallback
|
||||
|
||||
|
||||
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
||||
graph = CurrencyGraph()
|
||||
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||
graph.add_pair("BTC", "ETH", "ETH/BTC")
|
||||
graph.add_pair("ETH", "USD", "ETH/USD")
|
||||
cycles = graph.triangular_cycles()
|
||||
return graph.index_cycles_by_pair(cycles), ["BTC/USD", "ETH/BTC", "ETH/USD"]
|
||||
|
||||
|
||||
def _parse_balances(raw: str) -> Mapping[str, float]:
|
||||
balances: dict[str, float] = {}
|
||||
for entry in raw.split(","):
|
||||
if not entry.strip():
|
||||
continue
|
||||
asset, value = entry.split("=", 1)
|
||||
balances[asset.strip().upper()] = float(value)
|
||||
return balances
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run backtest.")
|
||||
parser.add_argument("--events", type=Path, required=True)
|
||||
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
||||
parser.add_argument("--trade-capital", type=float, default=100.0)
|
||||
parser.add_argument("--fee-rate", type=float, default=None)
|
||||
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
||||
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
cycles_by_pair, available_pairs = _build_graph()
|
||||
events = load_replay_events(args.events)
|
||||
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
|
||||
config = BacktestConfig(
|
||||
fee_rate=fee_rate,
|
||||
trade_capital=args.trade_capital,
|
||||
slippage_bps=args.slippage_bps,
|
||||
execution_latency_ms=args.execution_latency_ms,
|
||||
)
|
||||
|
||||
engine = BacktestReplayEngine(
|
||||
cycles_by_pair=cycles_by_pair,
|
||||
available_pairs=available_pairs,
|
||||
config=config,
|
||||
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
||||
)
|
||||
starting_balances = _parse_balances(args.starting_balances)
|
||||
r = asyncio.run(engine.run(events, starting_balances=starting_balances))
|
||||
|
||||
print("Backtest report:")
|
||||
print(f"- processed_events: {r.processed_events}")
|
||||
print(f"- opportunities_seen: {r.opportunities_seen}")
|
||||
print(f"- trades_executed: {r.trades_executed}")
|
||||
print(f"- win_rate: {r.win_rate if r.win_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: {r.realized_pnl_usd:.4f}")
|
||||
print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
|
||||
print(f"- miss_reasons: {dict(r.miss_reasons)}")
|
||||
print(
|
||||
"- execution_latency_ms: "
|
||||
f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
|
||||
f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
|
||||
f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from statistics import fmean
|
||||
from tempfile import gettempdir
|
||||
from time import perf_counter
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.metrics import MetricsCalculator
|
||||
from arbitrade.storage.pg_store import PgStore
|
||||
|
||||
|
||||
async def _python_scan_compute(store: PgStore) -> tuple[float, float | None, float | None]:
|
||||
sql_s = """
|
||||
SELECT started_at, finished_at, realized_pnl
|
||||
FROM trades
|
||||
WHERE finished_at IS NOT NULL
|
||||
"""
|
||||
sql_d = "SELECT detected_at FROM opportunities"
|
||||
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)
|
||||
durations = [
|
||||
(row[1] - row[0]).total_seconds()
|
||||
for row in trade_rows
|
||||
if isinstance(row[0], datetime) and isinstance(row[1], datetime)
|
||||
]
|
||||
avg_duration = fmean(durations) if durations else None
|
||||
|
||||
times = [row[0] for row in orows if isinstance(row[0], datetime)]
|
||||
if len(times) >= 2:
|
||||
ss = (max(times) - min(times)).total_seconds()
|
||||
opm = len(times) / (ss / 60.0) if ss > 0.0 else float(len(times))
|
||||
elif len(times) == 1:
|
||||
opm = 60.0
|
||||
else:
|
||||
opm = None
|
||||
|
||||
return realized, avg_duration, opm
|
||||
|
||||
|
||||
async def _seed_dataset(store: PgStore) -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
trade_rows: list[tuple[object, ...]] = []
|
||||
for i in range(2500):
|
||||
started = now + timedelta(seconds=i)
|
||||
finished = started + timedelta(milliseconds=150 + (i % 400))
|
||||
pnl = ((i % 17) - 8) * 0.25
|
||||
trade_rows.append(
|
||||
(
|
||||
f"t{i}",
|
||||
started,
|
||||
finished,
|
||||
"filled",
|
||||
pnl,
|
||||
pnl * 0.9,
|
||||
100.0,
|
||||
"USD->BTC->ETH->USD",
|
||||
3,
|
||||
)
|
||||
)
|
||||
|
||||
opportunity_rows: list[tuple[object, ...]] = []
|
||||
for i in range(5000):
|
||||
detected_at = now + timedelta(milliseconds=200 * i)
|
||||
opportunity_rows.append((detected_at, "USD->BTC->ETH->USD", 2.5, 1.2, 0.03, bool(i % 2)))
|
||||
|
||||
order_rows: list[tuple[object, ...]] = []
|
||||
for i in range(3500):
|
||||
order_rows.append(
|
||||
(
|
||||
f"t{i % 2500}",
|
||||
f"o{i}",
|
||||
0,
|
||||
"BTC/USD",
|
||||
"buy",
|
||||
1.0,
|
||||
i,
|
||||
"closed",
|
||||
0.9,
|
||||
100.0,
|
||||
"{}",
|
||||
now,
|
||||
)
|
||||
)
|
||||
|
||||
async with store.pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM trades")
|
||||
await conn.execute("DELETE FROM opportunities")
|
||||
await conn.execute("DELETE FROM orders")
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO trades (
|
||||
trade_ref,
|
||||
started_at,
|
||||
finished_at,
|
||||
status,
|
||||
realized_pnl,
|
||||
estimated_pnl,
|
||||
capital_used,
|
||||
cycle,
|
||||
leg_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
trade_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO opportunities (
|
||||
detected_at,
|
||||
cycle,
|
||||
gross_pct,
|
||||
net_pct,
|
||||
est_profit,
|
||||
executed
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
opportunity_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO orders (
|
||||
trade_ref,
|
||||
order_ref,
|
||||
leg_index,
|
||||
pair,
|
||||
side,
|
||||
volume,
|
||||
user_ref,
|
||||
status,
|
||||
filled_volume,
|
||||
avg_price,
|
||||
raw_response,
|
||||
recorded_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
order_rows,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
|
||||
settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
|
||||
store = PgStore(settings)
|
||||
store.migrate()
|
||||
await _seed_dataset(store)
|
||||
|
||||
calculator = MetricsCalculator(store)
|
||||
|
||||
for _ in range(3):
|
||||
await _python_scan_compute(store)
|
||||
await calculator.compute()
|
||||
|
||||
runs = 20
|
||||
start = perf_counter()
|
||||
for _ in range(runs):
|
||||
await _python_scan_compute(store)
|
||||
python_ms = (perf_counter() - start) * 1000.0 / runs
|
||||
|
||||
start = perf_counter()
|
||||
for _ in range(runs):
|
||||
await calculator.compute()
|
||||
sql_ms = (perf_counter() - start) * 1000.0 / runs
|
||||
|
||||
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
|
||||
|
||||
print(f"python_scan_avg_ms={python_ms:.3f}")
|
||||
print(f"sql_aggregate_avg_ms={sql_ms:.3f}")
|
||||
print(f"speedup_x={speedup:.2f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from arbitrade.perf.guardrails import evaluate_guardrails
|
||||
from arbitrade.perf.latency import run_latency_profile
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"Expected object JSON at {path}")
|
||||
return {str(k): parsed[k] for k in parsed}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check latency profile against baseline thresholds."
|
||||
)
|
||||
parser.add_argument("--baseline", type=Path, required=True)
|
||||
parser.add_argument("--thresholds", type=Path, required=True)
|
||||
parser.add_argument("--iterations", type=int, default=600)
|
||||
parser.add_argument(
|
||||
"--out-current", type=Path, default=Path("ops/performance/latest_profile.json")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
baseline = _read_json(args.baseline)
|
||||
thresholds = _read_json(args.thresholds)
|
||||
current = run_latency_profile(iterations=args.iterations)
|
||||
|
||||
args.out_current.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out_current.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
|
||||
failures = evaluate_guardrails(baseline=baseline, current=current, thresholds=thresholds)
|
||||
if failures:
|
||||
print("Latency guardrail failures:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("Latency guardrails passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from arbitrade.perf.latency import run_latency_profile
|
||||
|
||||
|
||||
def _format_summary(profile: dict[str, object]) -> str:
|
||||
scenarios = profile.get("scenarios")
|
||||
if not isinstance(scenarios, dict):
|
||||
return "No scenarios found."
|
||||
|
||||
lines = ["Latency profiling summary:"]
|
||||
for scenario_name, payload in scenarios.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
lines.append(f"- {scenario_name}")
|
||||
stages = payload.get("stages")
|
||||
if not isinstance(stages, dict):
|
||||
continue
|
||||
for stage_name, stage_payload in stages.items():
|
||||
if not isinstance(stage_payload, dict):
|
||||
continue
|
||||
p95 = float(stage_payload.get("p95_ms", 0.0))
|
||||
p99 = float(stage_payload.get("p99_ms", 0.0))
|
||||
lines.append(f" - {stage_name}: p95={p95:.4f}ms p99={p99:.4f}ms")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Profile synthetic latency scenarios.")
|
||||
parser.add_argument("--iterations", type=int, default=600)
|
||||
parser.add_argument("--output", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
profile = run_latency_profile(iterations=args.iterations)
|
||||
profile["generated_at"] = datetime.now(UTC).isoformat()
|
||||
|
||||
print(_format_summary(profile))
|
||||
|
||||
if args.output is not None:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
||||
print(f"Wrote profile JSON to {args.output}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parents[1]
|
||||
|
||||
EXCLUDED_DIRS = {
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
"data",
|
||||
"node_modules",
|
||||
}
|
||||
|
||||
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
||||
("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
||||
(
|
||||
"generic_token",
|
||||
re.compile(
|
||||
r"(?i)(token|secret|password)\s*[:=]\s*['\"]?"
|
||||
r"(?=[A-Za-z0-9_\-+/=.]{20,})(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_\-+/=.]{20,}"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _is_probably_text(path: Path) -> bool:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
sample = handle.read(2048)
|
||||
except OSError:
|
||||
return False
|
||||
return b"\x00" not in sample
|
||||
|
||||
|
||||
def scan_worktree() -> list[str]:
|
||||
findings: list[str] = []
|
||||
tracked = subprocess.run(
|
||||
["git", "-C", str(WORKSPACE), "ls-files"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if tracked.returncode != 0:
|
||||
return ["worktree_scan_failed"]
|
||||
|
||||
for rel_path in tracked.stdout.splitlines():
|
||||
path = WORKSPACE / rel_path
|
||||
if not path.is_file() or any(part in EXCLUDED_DIRS for part in path.parts):
|
||||
continue
|
||||
if not _is_probably_text(path):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for rule_name, pattern in PATTERNS:
|
||||
if pattern.search(content):
|
||||
findings.append(f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
|
||||
return findings
|
||||
|
||||
|
||||
def scan_git_history() -> list[str]:
|
||||
cmd = ["git", "-C", str(WORKSPACE), "log", "--all", "-p", "--pretty=format:%H"]
|
||||
completed = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
||||
if completed.returncode != 0:
|
||||
return ["history_scan_failed"]
|
||||
|
||||
findings: list[str] = []
|
||||
data = completed.stdout
|
||||
for rule_name, pattern in PATTERNS:
|
||||
if pattern.search(data):
|
||||
findings.append(f"history:{rule_name}")
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
findings = [*scan_worktree(), *scan_git_history()]
|
||||
if findings:
|
||||
print("Security scan found potential secrets:")
|
||||
for finding in findings:
|
||||
print(f"- {finding}")
|
||||
print("Rotate any exposed credentials immediately.")
|
||||
return 1
|
||||
|
||||
print("Security scan passed: no obvious secrets detected in worktree/history.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Alerting primitives and channel clients."""
|
||||
|
||||
from arbitrade.alerting.notifier import (
|
||||
AlertEvent,
|
||||
AlertNotifier,
|
||||
AlertSeverity,
|
||||
DiscordWebhookChannel,
|
||||
EmailSmtpChannel,
|
||||
SupportsAlertStatus,
|
||||
TelegramChannel,
|
||||
build_channels_from_settings,
|
||||
dispatch_alert_nowait,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlertEvent",
|
||||
"AlertNotifier",
|
||||
"AlertSeverity",
|
||||
"DiscordWebhookChannel",
|
||||
"EmailSmtpChannel",
|
||||
"SupportsAlertStatus",
|
||||
"TelegramChannel",
|
||||
"build_channels_from_settings",
|
||||
"dispatch_alert_nowait",
|
||||
]
|
||||
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
AlertSeverity = Literal["info", "warning", "error", "critical"]
|
||||
|
||||
_SEVERITY_RANK: dict[AlertSeverity, int] = {
|
||||
"info": 10,
|
||||
"warning": 20,
|
||||
"error": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AlertEvent:
|
||||
category: str
|
||||
severity: AlertSeverity
|
||||
title: str
|
||||
message: str
|
||||
occurred_at: datetime
|
||||
details: dict[str, str]
|
||||
|
||||
|
||||
class AlertChannel(Protocol):
|
||||
async def send(self, event: AlertEvent) -> None: ...
|
||||
|
||||
|
||||
class SupportsAlerts(Protocol):
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsAlertStatus(Protocol):
|
||||
def status_snapshot(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class AlertNotifier:
|
||||
def __init__(
|
||||
self,
|
||||
channels: list[AlertChannel],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
min_severity: AlertSeverity = "info",
|
||||
dedup_seconds: float = 0.0,
|
||||
category_flags: dict[str, bool] | None = None,
|
||||
) -> None:
|
||||
if dedup_seconds < 0.0:
|
||||
raise ValueError("dedup_seconds must be >= 0.0")
|
||||
self._channels = channels
|
||||
self._enabled = enabled
|
||||
self._min_severity: AlertSeverity = min_severity
|
||||
self._dedup_seconds = dedup_seconds
|
||||
self._category_flags = {key.lower(): value for key, value in (category_flags or {}).items()}
|
||||
self._last_sent_at: dict[str, datetime] = {}
|
||||
self._last_result: str = "never"
|
||||
self._last_attempted_at: datetime | None = None
|
||||
self._last_success_at: datetime | None = None
|
||||
self._last_error: str | None = None
|
||||
self._last_event_title: str | None = None
|
||||
self._last_event_category: str | None = None
|
||||
self._last_event_severity: AlertSeverity | None = None
|
||||
self._last_channel_results: list[str] = []
|
||||
|
||||
@property
|
||||
def has_channels(self) -> bool:
|
||||
return bool(self._channels)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
if not self._enabled or not self._channels:
|
||||
self._last_result = "skipped_disabled" if not self._enabled else "skipped_no_channels"
|
||||
return False
|
||||
|
||||
normalized_category = category.strip().lower()
|
||||
if self._category_flags and not self._category_flags.get(normalized_category, True):
|
||||
self._last_result = "skipped_category"
|
||||
return False
|
||||
|
||||
if _SEVERITY_RANK[severity] < _SEVERITY_RANK[self._min_severity]:
|
||||
self._last_result = "skipped_severity"
|
||||
return False
|
||||
|
||||
dedup_key = f"{normalized_category}|{severity}|{title}|{message}"
|
||||
now = datetime.now(UTC)
|
||||
if self._dedup_seconds > 0.0:
|
||||
previous = self._last_sent_at.get(dedup_key)
|
||||
if previous is not None:
|
||||
elapsed = (now - previous).total_seconds()
|
||||
if elapsed < self._dedup_seconds:
|
||||
self._last_result = "skipped_dedup"
|
||||
return False
|
||||
|
||||
event = AlertEvent(
|
||||
category=normalized_category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
occurred_at=now,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(channel.send(event) for channel in self._channels),
|
||||
return_exceptions=True,
|
||||
)
|
||||
self._last_attempted_at = now
|
||||
self._last_event_title = title
|
||||
self._last_event_category = normalized_category
|
||||
self._last_event_severity = severity
|
||||
self._last_channel_results = []
|
||||
for channel, result in zip(self._channels, results, strict=False):
|
||||
channel_name = type(channel).__name__
|
||||
if isinstance(result, Exception):
|
||||
self._last_channel_results.append(f"{channel_name}: error")
|
||||
else:
|
||||
self._last_channel_results.append(f"{channel_name}: ok")
|
||||
|
||||
if all(isinstance(result, Exception) for result in results):
|
||||
self._last_result = "failed"
|
||||
self._last_error = "all channels failed"
|
||||
return False
|
||||
|
||||
self._last_result = (
|
||||
"partial_success"
|
||||
if any(isinstance(result, Exception) for result in results)
|
||||
else "success"
|
||||
)
|
||||
self._last_error = None
|
||||
self._last_success_at = now
|
||||
|
||||
self._last_sent_at[dedup_key] = now
|
||||
return True
|
||||
|
||||
def status_snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"has_channels": self.has_channels,
|
||||
"configured_channels": [type(channel).__name__ for channel in self._channels],
|
||||
"min_severity": self._min_severity,
|
||||
"dedup_seconds": self._dedup_seconds,
|
||||
"last_result": self._last_result,
|
||||
"last_attempted_at": (
|
||||
self._last_attempted_at.isoformat() if self._last_attempted_at is not None else None
|
||||
),
|
||||
"last_success_at": (
|
||||
self._last_success_at.isoformat() if self._last_success_at is not None else None
|
||||
),
|
||||
"last_error": self._last_error,
|
||||
"last_event": (
|
||||
None
|
||||
if self._last_event_title is None
|
||||
else {
|
||||
"title": self._last_event_title,
|
||||
"category": self._last_event_category,
|
||||
"severity": self._last_event_severity,
|
||||
}
|
||||
),
|
||||
"last_channel_results": self._last_channel_results,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_alert_nowait(
|
||||
notifier: SupportsAlerts | None,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if notifier is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
loop.create_task(
|
||||
notifier.notify(
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _format_event_text(event: AlertEvent) -> str:
|
||||
lines = [
|
||||
f"[{event.severity.upper()}] {event.title}",
|
||||
f"Category: {event.category}",
|
||||
f"Time: {event.occurred_at.isoformat()}",
|
||||
event.message,
|
||||
]
|
||||
if event.details:
|
||||
lines.append("Details:")
|
||||
for key, value in sorted(event.details.items()):
|
||||
lines.append(f"- {key}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TelegramChannel:
|
||||
def __init__(self, *, bot_token: str, chat_id: str, timeout_seconds: float = 10.0) -> None:
|
||||
self._bot_token = bot_token
|
||||
self._chat_id = chat_id
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self._chat_id,
|
||||
"text": _format_event_text(event),
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
timeout = httpx.Timeout(self._timeout_seconds)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class DiscordWebhookChannel:
|
||||
def __init__(self, *, webhook_url: str, timeout_seconds: float = 10.0) -> None:
|
||||
self._webhook_url = webhook_url
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
payload = {"content": _format_event_text(event)}
|
||||
timeout = httpx.Timeout(self._timeout_seconds)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(self._webhook_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class EmailSmtpChannel:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
sender: str,
|
||||
recipients: list[str],
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
use_tls: bool = True,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> None:
|
||||
if not recipients:
|
||||
raise ValueError("recipients must not be empty")
|
||||
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._sender = sender
|
||||
self._recipients = recipients
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._use_tls = use_tls
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._sender
|
||||
message["To"] = ", ".join(self._recipients)
|
||||
message["Subject"] = f"[{event.severity.upper()}] {event.title}"
|
||||
message.set_content(_format_event_text(event))
|
||||
|
||||
await asyncio.to_thread(self._send_sync, message)
|
||||
|
||||
def _send_sync(self, message: EmailMessage) -> None:
|
||||
with smtplib.SMTP(self._host, self._port, timeout=self._timeout_seconds) as client:
|
||||
if self._use_tls:
|
||||
client.starttls()
|
||||
if self._username and self._password:
|
||||
client.login(self._username, self._password)
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
class _AlertSettings(Protocol):
|
||||
alerts_enabled: bool
|
||||
alert_min_severity: str
|
||||
alert_dedup_seconds: float
|
||||
alert_on_trade_events: bool
|
||||
alert_on_error_events: bool
|
||||
alert_on_threshold_events: bool
|
||||
alert_on_system_events: bool
|
||||
|
||||
telegram_alerts_enabled: bool
|
||||
telegram_bot_token: str | None
|
||||
telegram_chat_id: str | None
|
||||
|
||||
discord_alerts_enabled: bool
|
||||
discord_webhook_url: str | None
|
||||
|
||||
email_alerts_enabled: bool
|
||||
email_smtp_host: str | None
|
||||
email_smtp_port: int
|
||||
email_smtp_username: str | None
|
||||
email_smtp_password: str | None
|
||||
email_alert_from: str | None
|
||||
email_alert_to: str | None
|
||||
email_smtp_use_tls: bool
|
||||
|
||||
|
||||
def _as_alert_severity(value: str) -> AlertSeverity:
|
||||
normalized = value.strip().lower()
|
||||
if normalized == "info":
|
||||
return "info"
|
||||
if normalized == "warning":
|
||||
return "warning"
|
||||
if normalized == "error":
|
||||
return "error"
|
||||
if normalized == "critical":
|
||||
return "critical"
|
||||
else:
|
||||
raise ValueError("alert_min_severity must be one of: info, warning, error, critical")
|
||||
|
||||
|
||||
def build_channels_from_settings(settings: _AlertSettings) -> list[AlertChannel]:
|
||||
channels: list[AlertChannel] = []
|
||||
|
||||
if settings.telegram_alerts_enabled:
|
||||
if not settings.telegram_bot_token or not settings.telegram_chat_id:
|
||||
raise ValueError("telegram alerts require bot token and chat id")
|
||||
channels.append(
|
||||
TelegramChannel(
|
||||
bot_token=settings.telegram_bot_token,
|
||||
chat_id=settings.telegram_chat_id,
|
||||
)
|
||||
)
|
||||
|
||||
if settings.discord_alerts_enabled:
|
||||
if not settings.discord_webhook_url:
|
||||
raise ValueError("discord alerts require webhook url")
|
||||
channels.append(DiscordWebhookChannel(webhook_url=settings.discord_webhook_url))
|
||||
|
||||
if settings.email_alerts_enabled:
|
||||
if not settings.email_smtp_host:
|
||||
raise ValueError("email alerts require SMTP host")
|
||||
if not settings.email_alert_from:
|
||||
raise ValueError("email alerts require sender address")
|
||||
if not settings.email_alert_to:
|
||||
raise ValueError("email alerts require recipient list")
|
||||
|
||||
recipients = [
|
||||
address.strip() for address in settings.email_alert_to.split(",") if address.strip()
|
||||
]
|
||||
channels.append(
|
||||
EmailSmtpChannel(
|
||||
host=settings.email_smtp_host,
|
||||
port=settings.email_smtp_port,
|
||||
sender=settings.email_alert_from,
|
||||
recipients=recipients,
|
||||
username=settings.email_smtp_username,
|
||||
password=settings.email_smtp_password,
|
||||
use_tls=settings.email_smtp_use_tls,
|
||||
)
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
def build_notifier_from_settings(settings: _AlertSettings) -> AlertNotifier:
|
||||
severity = _as_alert_severity(settings.alert_min_severity)
|
||||
channels = build_channels_from_settings(settings)
|
||||
category_flags = {
|
||||
"trade": settings.alert_on_trade_events,
|
||||
"error": settings.alert_on_error_events,
|
||||
"threshold": settings.alert_on_threshold_events,
|
||||
"system": settings.alert_on_system_events,
|
||||
}
|
||||
return AlertNotifier(
|
||||
channels,
|
||||
enabled=settings.alerts_enabled,
|
||||
min_severity=severity,
|
||||
dedup_seconds=settings.alert_dedup_seconds,
|
||||
category_flags=category_flags,
|
||||
)
|
||||
+189
-5
@@ -1,19 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
|
||||
from arbitrade.api.routes import router
|
||||
from arbitrade.alerting.notifier import build_notifier_from_settings
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
from arbitrade.api.routes import public_router, router
|
||||
from arbitrade.backtesting.runner import backtest_worker
|
||||
from arbitrade.config.pairing_sync import run_pairing_sync_loop
|
||||
from arbitrade.config.service import ConfigurationService
|
||||
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.storage.db import DuckDBStore
|
||||
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.runtime.lifecycle import graceful_shutdown, restore_runtime_state
|
||||
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter
|
||||
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:
|
||||
configure_logging(settings.log_level, settings.log_json)
|
||||
|
||||
db = DuckDBStore(settings)
|
||||
db.migrate()
|
||||
db = PgStore(settings)
|
||||
|
||||
app = FastAPI(title="arbitrade", version="0.1.0")
|
||||
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
|
||||
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)
|
||||
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
|
||||
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 app.state.store.stop()
|
||||
await get_db_sink().stop_consumer()
|
||||
|
||||
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
|
||||
app.state.settings = settings
|
||||
app.state.store = db
|
||||
app.state.kraken_client = kraken_client
|
||||
app.state.fee_sync_stop_event = fee_sync_stop_event
|
||||
app.state.pairing_sync_stop_event = pairing_sync_stop_event
|
||||
app.state.backtest_queue = backtest_queue
|
||||
app.state.metrics = MetricsCalculator(db)
|
||||
app.state.audit_repository = AuditRepository(db)
|
||||
app.state.runtime_state_repository = RuntimeStateRepository(db)
|
||||
app.state.alert_notifier = build_notifier_from_settings(settings)
|
||||
svc = ConfigurationService(settings, db, app.state.audit_repository)
|
||||
app.state.configuration_service = svc
|
||||
app.state.backtest_recent_reports = []
|
||||
app.state.dashboard_controls = DashboardControlState(
|
||||
is_running=not settings.kill_switch_active,
|
||||
)
|
||||
app.include_router(public_router)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from secrets import compare_digest
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
|
||||
dashboard_basic_auth = HTTPBasic(auto_error=False)
|
||||
|
||||
|
||||
def require_dashboard_auth(
|
||||
request: Request,
|
||||
credentials: Annotated[HTTPBasicCredentials | None, Depends(dashboard_basic_auth)],
|
||||
) -> None:
|
||||
settings = request.app.state.settings
|
||||
username = settings.dashboard_auth_username
|
||||
password = settings.dashboard_auth_password
|
||||
|
||||
if username is None and password is None:
|
||||
return
|
||||
|
||||
if username is None or password is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Dashboard auth misconfigured",
|
||||
)
|
||||
|
||||
if (
|
||||
credentials is None
|
||||
or not compare_digest(credentials.username, username)
|
||||
or not compare_digest(credentials.password, password)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": 'Basic realm="Arbitrade Dashboard"'},
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from arbitrade.risk.kill_switch import KillSwitch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DashboardControlState:
|
||||
is_running: bool = True
|
||||
kill_switch: KillSwitch = field(default_factory=KillSwitch)
|
||||
tradable_pairs: list[str] = field(default_factory=list)
|
||||
strategy_mode: str = "incremental"
|
||||
strategy_profit_threshold: float = 0.0005
|
||||
strategy_max_depth_levels: int = 10
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def mark_updated(self) -> None:
|
||||
self.updated_at = datetime.now(UTC)
|
||||
+465
-9
@@ -1,28 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from asyncio import Lock
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
from arbitrade.api.auth import require_dashboard_auth
|
||||
from arbitrade.config.pairing_sync import sync_pairings_from_kraken
|
||||
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)])
|
||||
public_router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_templates_directory() -> str:
|
||||
# Support source layout, Docker runtime (/app), and installed package data.
|
||||
source_layout_path = Path(__file__).resolve().parents[3] / "web" / "templates"
|
||||
if source_layout_path.is_dir():
|
||||
return str(source_layout_path)
|
||||
|
||||
docker_runtime_path = Path.cwd() / "web" / "templates"
|
||||
if docker_runtime_path.is_dir():
|
||||
return str(docker_runtime_path)
|
||||
|
||||
try:
|
||||
package_path = resources.files("arbitrade").joinpath("web", "templates")
|
||||
if package_path.is_dir():
|
||||
return str(package_path)
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
pass
|
||||
|
||||
return str(source_layout_path)
|
||||
|
||||
|
||||
templates = Jinja2Templates(directory=_resolve_templates_directory())
|
||||
_BACKTEST_ROOT = Path(__file__).resolve().parents[3]
|
||||
_BACKTEST_RUN_LOCK = Lock()
|
||||
|
||||
|
||||
async def _health_response(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="health.html",
|
||||
context={"title": "Arbitrade Health Check"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_response(request)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_response(request)
|
||||
|
||||
|
||||
@router.get("/dashboard/health", response_class=HTMLResponse)
|
||||
async def dashboard_health_page(request: Request) -> HTMLResponse:
|
||||
return await _health_response(request)
|
||||
|
||||
|
||||
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
|
||||
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="health.html",
|
||||
name="backtesting.html",
|
||||
context={
|
||||
"status": "ok",
|
||||
"time": datetime.now(UTC).isoformat(),
|
||||
"title": "Arbitrade Health",
|
||||
"title": "Arbitrade Backtesting",
|
||||
"request": request,
|
||||
"panel_endpoint": "/dashboard/fragment/backtesting",
|
||||
"dashboard_endpoint": "/dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health", response_class=JSONResponse)
|
||||
@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)
|
||||
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/metrics.html",
|
||||
context={"request": request, **(await _dashboard_metrics(request))},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/fragment/overview", response_class=HTMLResponse)
|
||||
async def dashboard_overview(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/overview.html",
|
||||
context={"request": request, **(await _dashboard_overview(request))},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/fragment/controls", response_class=HTMLResponse)
|
||||
async def dashboard_controls(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/controls.html",
|
||||
context={"request": request, **_dashboard_controls(request)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/fragment/charts", response_class=HTMLResponse)
|
||||
async def dashboard_charts(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/charts.html",
|
||||
context={"request": request, **(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))},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/fragment/audit", response_class=HTMLResponse)
|
||||
async def dashboard_audit(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/audit.html",
|
||||
context={"request": request, **(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},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/api/alerts/status", response_class=JSONResponse)
|
||||
async def dashboard_alert_status(request: Request) -> JSONResponse:
|
||||
return JSONResponse(_alert_status_snapshot(request))
|
||||
|
||||
|
||||
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
|
||||
async def dashboard_audit_recent(request: Request) -> JSONResponse:
|
||||
return JSONResponse(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)
|
||||
async def dashboard_control_start(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_ctl_start(request)
|
||||
|
||||
|
||||
@router.post("/dashboard/control/stop", response_class=HTMLResponse)
|
||||
async def dashboard_control_stop(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_ctl_stop(request)
|
||||
|
||||
|
||||
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
|
||||
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_ctl_kill(request)
|
||||
|
||||
|
||||
@router.post("/dashboard/control/config", response_class=HTMLResponse)
|
||||
async def dashboard_control_config(request: Request) -> HTMLResponse:
|
||||
return await _dashboard_ctl_cfg(request)
|
||||
|
||||
|
||||
@router.get("/dashboard/stream/metrics")
|
||||
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
||||
metrics = await _dashboard_metrics(request)
|
||||
fragment = (
|
||||
templates.get_template("partials/metrics.html")
|
||||
.render(
|
||||
request=request,
|
||||
**metrics,
|
||||
)
|
||||
.strip()
|
||||
.replace("\n", "")
|
||||
)
|
||||
|
||||
async def _event_stream() -> AsyncIterator[bytes]:
|
||||
payload = json.dumps(fragment)
|
||||
yield f"event: metrics\ndata: {payload}\n\n".encode()
|
||||
|
||||
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/dashboard/stream/overview")
|
||||
async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
||||
overview = await _dashboard_overview(request)
|
||||
fragment = (
|
||||
templates.get_template("partials/overview.html")
|
||||
.render(request=request, **overview)
|
||||
.strip()
|
||||
.replace("\n", "")
|
||||
)
|
||||
|
||||
async def _event_stream() -> AsyncIterator[bytes]:
|
||||
payload = json.dumps(fragment)
|
||||
yield f"event: overview\ndata: {payload}\n\n".encode()
|
||||
|
||||
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@public_router.get("/health", response_class=JSONResponse)
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||
|
||||
|
||||
# ── 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})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from arbitrade.backtesting.replay import (
|
||||
BacktestConfig,
|
||||
BacktestReplayEngine,
|
||||
BacktestReport,
|
||||
ReplayBookEvent,
|
||||
ReplayClock,
|
||||
load_replay_events,
|
||||
)
|
||||
from arbitrade.backtesting.sweep import (
|
||||
PromotionCriteria,
|
||||
SweepArtifacts,
|
||||
SweepParameters,
|
||||
SweepResult,
|
||||
build_parameter_grid,
|
||||
persist_sweep_results,
|
||||
run_parameter_search,
|
||||
split_events_time_windows,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReplayClock",
|
||||
"ReplayBookEvent",
|
||||
"BacktestConfig",
|
||||
"BacktestReport",
|
||||
"BacktestReplayEngine",
|
||||
"load_replay_events",
|
||||
"SweepParameters",
|
||||
"SweepResult",
|
||||
"SweepArtifacts",
|
||||
"PromotionCriteria",
|
||||
"split_events_time_windows",
|
||||
"build_parameter_grid",
|
||||
"run_parameter_search",
|
||||
"persist_sweep_results",
|
||||
]
|
||||
@@ -0,0 +1,410 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
|
||||
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
|
||||
from arbitrade.detection.graph import TriangularCycle
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||
from arbitrade.market_data.order_book import OrderBook
|
||||
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||
from arbitrade.storage.pg_store import PgStore
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReplayClock:
|
||||
_current: datetime
|
||||
|
||||
@classmethod
|
||||
def at(cls, started_at: datetime) -> ReplayClock:
|
||||
return cls(_current=started_at.astimezone(UTC))
|
||||
|
||||
@property
|
||||
def now(self) -> datetime:
|
||||
return self._current
|
||||
|
||||
def advance_to(self, next_time: datetime) -> None:
|
||||
normalized = next_time.astimezone(UTC)
|
||||
if normalized < self._current:
|
||||
raise ValueError("Replay events must be monotonic by timestamp")
|
||||
self._current = normalized
|
||||
|
||||
def advance_ms(self, milliseconds: float) -> None:
|
||||
if milliseconds < 0.0:
|
||||
raise ValueError("milliseconds must be >= 0")
|
||||
self._current = self._current.fromtimestamp(
|
||||
self._current.timestamp() + (milliseconds / 1000.0),
|
||||
tz=UTC,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayBookEvent:
|
||||
occurred_at: datetime
|
||||
symbol: str
|
||||
bids: tuple[BookLevel, ...]
|
||||
asks: tuple[BookLevel, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BacktestConfig:
|
||||
fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
|
||||
min_profit_threshold: float = 0.0005
|
||||
trade_capital: float = 100.0
|
||||
quote_asset: str = "USD"
|
||||
slippage_bps: float = 4.0
|
||||
execution_latency_ms: float = 20.0
|
||||
max_depth_levels: int = 10
|
||||
max_concurrent_trades: int = 1
|
||||
min_order_size_by_pair: Mapping[str, float] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BacktestReport:
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
processed_events: int
|
||||
opportunities_seen: int
|
||||
trades_executed: int
|
||||
win_rate: float | None
|
||||
fill_rate: float | None
|
||||
realized_pnl_usd: float
|
||||
max_drawdown_usd: float
|
||||
miss_reasons: Mapping[str, int]
|
||||
execution_latency_p50_ms: float | None
|
||||
execution_latency_p95_ms: float | None
|
||||
execution_latency_p99_ms: float | None
|
||||
|
||||
|
||||
class _SimulatedRestClient:
|
||||
def __init__(
|
||||
self, clock: ReplayClock, *, slippage_bps: float, execution_latency_ms: float
|
||||
) -> None:
|
||||
self._clock = clock
|
||||
self._slippage_bps = slippage_bps
|
||||
self._execution_latency_ms = execution_latency_ms
|
||||
self._sequence = 0
|
||||
self._last_fill_ratio = 1.0
|
||||
self._last_trade_latency_ms = execution_latency_ms
|
||||
|
||||
@property
|
||||
def last_fill_ratio(self) -> float:
|
||||
return self._last_fill_ratio
|
||||
|
||||
@property
|
||||
def last_trade_latency_ms(self) -> float:
|
||||
return self._last_trade_latency_ms
|
||||
|
||||
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, Any]:
|
||||
self._sequence += 1
|
||||
self._clock.advance_ms(self._execution_latency_ms)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
normalized_fill = max(0.85, 1.0 - (self._slippage_bps / 10000.0) * 8.0)
|
||||
self._last_fill_ratio = normalized_fill
|
||||
self._last_trade_latency_ms = self._execution_latency_ms
|
||||
|
||||
return {
|
||||
"txid": [f"sim-{self._sequence}"],
|
||||
"status": "closed",
|
||||
"pair": pair,
|
||||
"side": side,
|
||||
"requested_volume": volume,
|
||||
"filled_volume": volume * normalized_fill,
|
||||
"simulated_at": self._clock.now.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _percentile(values: Sequence[float], percentile: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
|
||||
ordered = sorted(values)
|
||||
if percentile <= 0.0:
|
||||
return ordered[0]
|
||||
if percentile >= 100.0:
|
||||
return ordered[-1]
|
||||
|
||||
rank = (len(ordered) - 1) * (percentile / 100.0)
|
||||
lower = int(rank)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
weight = rank - lower
|
||||
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
||||
|
||||
|
||||
def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
|
||||
if not isinstance(raw_levels, list):
|
||||
raise ValueError("Book levels must be a list")
|
||||
|
||||
levels: list[BookLevel] = []
|
||||
for raw_level in raw_levels:
|
||||
if (
|
||||
not isinstance(raw_level, list)
|
||||
or len(raw_level) != 2
|
||||
or not isinstance(raw_level[0], int | float)
|
||||
or not isinstance(raw_level[1], int | float)
|
||||
):
|
||||
raise ValueError("Each level must be [price, volume]")
|
||||
levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
|
||||
|
||||
return tuple(levels)
|
||||
|
||||
|
||||
def load_replay_events(path: Path) -> list[ReplayBookEvent]:
|
||||
events: list[ReplayBookEvent] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parsed = orjson.loads(line)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Each JSONL row must be an object")
|
||||
|
||||
timestamp_raw = parsed.get("timestamp")
|
||||
symbol_raw = parsed.get("symbol")
|
||||
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
|
||||
raise ValueError("Each event must include timestamp and symbol")
|
||||
|
||||
occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
|
||||
events.append(
|
||||
ReplayBookEvent(
|
||||
occurred_at=occurred_at,
|
||||
symbol=symbol_raw,
|
||||
bids=_parse_book_levels(parsed.get("bids")),
|
||||
asks=_parse_book_levels(parsed.get("asks")),
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(events, key=lambda event: event.occurred_at)
|
||||
|
||||
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cycles_by_pair: Mapping[str, list[TriangularCycle]],
|
||||
available_pairs: Sequence[str],
|
||||
config: BacktestConfig,
|
||||
started_at: datetime,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._clock = ReplayClock.at(started_at)
|
||||
self._books: dict[str, OrderBook] = {}
|
||||
|
||||
self._detector = IncrementalCycleDetector(
|
||||
cycles_by_pair,
|
||||
fee_rate=config.fee_rate,
|
||||
max_depth_levels=config.max_depth_levels,
|
||||
min_profit_threshold=config.min_profit_threshold,
|
||||
min_order_size_by_pair=config.min_order_size_by_pair,
|
||||
)
|
||||
self._pre_trade = PreTradeValidator()
|
||||
self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
|
||||
self._simulated_rest = _SimulatedRestClient(
|
||||
self._clock,
|
||||
slippage_bps=config.slippage_bps,
|
||||
execution_latency_ms=config.execution_latency_ms,
|
||||
)
|
||||
self._sequencer = TriangularExecutionSequencer(
|
||||
self._simulated_rest,
|
||||
available_pairs=available_pairs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
|
||||
currencies = [part for part in event.cycle.split("->") if part]
|
||||
if len(currencies) < 2:
|
||||
return {}
|
||||
|
||||
origin = currencies[0]
|
||||
return {
|
||||
currency: event.allocated_capital for currency in currencies[1:] if currency != origin
|
||||
}
|
||||
|
||||
async def run(
|
||||
self,
|
||||
events: Sequence[ReplayBookEvent],
|
||||
*,
|
||||
starting_balances: Mapping[str, float],
|
||||
) -> BacktestReport:
|
||||
miss_reasons: Counter[str] = Counter()
|
||||
|
||||
processed_events = 0
|
||||
opportunities_seen = 0
|
||||
trades_executed = 0
|
||||
|
||||
realized_pnl = 0.0
|
||||
equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
|
||||
peak_equity = equity
|
||||
max_drawdown = 0.0
|
||||
|
||||
fill_samples: list[float] = []
|
||||
realized_samples: list[float] = []
|
||||
execution_latencies: list[float] = []
|
||||
|
||||
for event in events:
|
||||
self._clock.advance_to(event.occurred_at)
|
||||
processed_events += 1
|
||||
|
||||
book = self._books.setdefault(event.symbol.upper(), OrderBook())
|
||||
book.apply_bids(event.bids)
|
||||
book.apply_asks(event.asks)
|
||||
|
||||
opportunities = self._detector.opportunities_for_updated_pair(
|
||||
event.symbol,
|
||||
self._books,
|
||||
base_capital=self._config.trade_capital,
|
||||
)
|
||||
opportunities_seen += len(opportunities)
|
||||
|
||||
for opportunity in opportunities:
|
||||
required_by_asset = {
|
||||
self._config.quote_asset.upper(): opportunity.allocated_capital
|
||||
}
|
||||
if not self._pre_trade.validate(
|
||||
balances_by_asset=starting_balances,
|
||||
required_by_asset=required_by_asset,
|
||||
):
|
||||
miss_reasons["insufficient_balance"] += 1
|
||||
continue
|
||||
|
||||
exposure = self._exposure_for_event(opportunity)
|
||||
if not self._trade_limits.is_trade_allowed(exposure):
|
||||
miss_reasons["trade_limit"] += 1
|
||||
continue
|
||||
|
||||
self._trade_limits.open_trade(exposure)
|
||||
result = await self._sequencer.execute(opportunity)
|
||||
self._trade_limits.close_trade(exposure)
|
||||
|
||||
execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
|
||||
fill_samples.append(self._simulated_rest.last_fill_ratio)
|
||||
|
||||
if not result.success:
|
||||
miss_reasons["execution_failed"] += 1
|
||||
continue
|
||||
|
||||
slippage_cost = (
|
||||
opportunity.allocated_capital
|
||||
* (self._config.slippage_bps / 10000.0)
|
||||
* max(result.completed_legs, 1)
|
||||
)
|
||||
realized_trade_pnl = opportunity.est_profit - slippage_cost
|
||||
realized_samples.append(realized_trade_pnl)
|
||||
|
||||
realized_pnl += realized_trade_pnl
|
||||
equity += realized_trade_pnl
|
||||
peak_equity = max(peak_equity, equity)
|
||||
max_drawdown = max(max_drawdown, peak_equity - equity)
|
||||
trades_executed += 1
|
||||
|
||||
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
|
||||
win_rate = (wins / len(realized_samples)) if realized_samples else None
|
||||
fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
|
||||
|
||||
return BacktestReport(
|
||||
started_at=events[0].occurred_at if events else self._clock.now,
|
||||
finished_at=events[-1].occurred_at if events else self._clock.now,
|
||||
processed_events=processed_events,
|
||||
opportunities_seen=opportunities_seen,
|
||||
trades_executed=trades_executed,
|
||||
win_rate=win_rate,
|
||||
fill_rate=fill_rate,
|
||||
realized_pnl_usd=realized_pnl,
|
||||
max_drawdown_usd=max_drawdown,
|
||||
miss_reasons=dict(miss_reasons),
|
||||
execution_latency_p50_ms=_percentile(execution_latencies, 50.0),
|
||||
execution_latency_p95_ms=_percentile(execution_latencies, 95.0),
|
||||
execution_latency_p99_ms=_percentile(execution_latencies, 99.0),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -1,26 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
env_ignore_empty=True,
|
||||
)
|
||||
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
||||
app_port: int = Field(default=8000, alias="APP_PORT")
|
||||
app_port: int = Field(default=9090, alias="APP_PORT")
|
||||
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
log_json: bool = Field(default=True, alias="LOG_JSON")
|
||||
|
||||
duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
|
||||
dashboard_auth_username: str | None = Field(
|
||||
default=None,
|
||||
alias="DASHBOARD_AUTH_USERNAME",
|
||||
)
|
||||
dashboard_auth_password: str | None = Field(
|
||||
default=None,
|
||||
alias="DASHBOARD_AUTH_PASSWORD",
|
||||
)
|
||||
|
||||
alerts_enabled: bool = Field(default=True, alias="ALERTS_ENABLED")
|
||||
alert_min_severity: str = Field(default="warning", alias="ALERT_MIN_SEVERITY")
|
||||
alert_dedup_seconds: float = Field(default=30.0, alias="ALERT_DEDUP_SECONDS")
|
||||
alert_on_trade_events: bool = Field(default=True, alias="ALERT_ON_TRADE_EVENTS")
|
||||
alert_on_error_events: bool = Field(default=True, alias="ALERT_ON_ERROR_EVENTS")
|
||||
alert_on_threshold_events: bool = Field(default=True, alias="ALERT_ON_THRESHOLD_EVENTS")
|
||||
alert_on_system_events: bool = Field(default=True, alias="ALERT_ON_SYSTEM_EVENTS")
|
||||
|
||||
telegram_alerts_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTS_ENABLED")
|
||||
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
|
||||
telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
|
||||
|
||||
discord_alerts_enabled: bool = Field(default=False, alias="DISCORD_ALERTS_ENABLED")
|
||||
discord_webhook_url: str | None = Field(default=None, alias="DISCORD_WEBHOOK_URL")
|
||||
|
||||
email_alerts_enabled: bool = Field(default=False, alias="EMAIL_ALERTS_ENABLED")
|
||||
email_smtp_host: str | None = Field(default=None, alias="EMAIL_SMTP_HOST")
|
||||
email_smtp_port: int = Field(default=587, alias="EMAIL_SMTP_PORT")
|
||||
email_smtp_username: str | None = Field(default=None, alias="EMAIL_SMTP_USERNAME")
|
||||
email_smtp_password: str | None = Field(default=None, alias="EMAIL_SMTP_PASSWORD")
|
||||
email_alert_from: str | None = Field(default=None, alias="EMAIL_ALERT_FROM")
|
||||
email_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
|
||||
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
|
||||
|
||||
# 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_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
|
||||
kraken_private_rate_limit_seconds: float = Field(
|
||||
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
|
||||
)
|
||||
kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
|
||||
kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS")
|
||||
kraken_retry_base_delay_seconds: float = Field(
|
||||
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
|
||||
)
|
||||
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
|
||||
kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET")
|
||||
kraken_api_key_permissions: str = Field(
|
||||
default="query,trade",
|
||||
alias="KRAKEN_API_KEY_PERMISSIONS",
|
||||
)
|
||||
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
|
||||
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
|
||||
strategy_enable_stat_arb_experiment: bool = Field(
|
||||
default=False,
|
||||
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
|
||||
)
|
||||
strategy_stat_arb_lookback_window: int = Field(
|
||||
default=120,
|
||||
alias="STRATEGY_STAT_ARB_LOOKBACK_WINDOW",
|
||||
)
|
||||
strategy_stat_arb_entry_zscore: float = Field(
|
||||
default=2.0,
|
||||
alias="STRATEGY_STAT_ARB_ENTRY_ZSCORE",
|
||||
)
|
||||
strategy_stat_arb_exit_zscore: float = Field(
|
||||
default=0.5,
|
||||
alias="STRATEGY_STAT_ARB_EXIT_ZSCORE",
|
||||
)
|
||||
strategy_stat_arb_max_holding_seconds: float = Field(
|
||||
default=900.0,
|
||||
alias="STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS",
|
||||
)
|
||||
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
|
||||
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
|
||||
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
|
||||
max_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES")
|
||||
max_exposure_per_asset_usd: float | None = Field(
|
||||
default=None,
|
||||
alias="MAX_EXPOSURE_PER_ASSET_USD",
|
||||
)
|
||||
quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET")
|
||||
min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD")
|
||||
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
|
||||
daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD")
|
||||
cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
|
||||
max_source_latency_ms: float | None = Field(default=None, alias="MAX_SOURCE_LATENCY_MS")
|
||||
max_apply_latency_ms: float | None = Field(default=None, alias="MAX_APPLY_LATENCY_MS")
|
||||
max_consecutive_failures: int | None = Field(default=None, alias="MAX_CONSECUTIVE_FAILURES")
|
||||
|
||||
fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
|
||||
|
||||
@field_validator("app_env")
|
||||
@classmethod
|
||||
def _validate_app_env(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"dev", "test", "prod"}:
|
||||
raise ValueError("APP_ENV must be one of: dev, test, prod")
|
||||
return normalized
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def _validate_log_level(cls, value: str) -> str:
|
||||
normalized = value.strip().upper()
|
||||
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
raise ValueError("LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
|
||||
return normalized
|
||||
|
||||
@field_validator("alert_min_severity")
|
||||
@classmethod
|
||||
def _validate_alert_severity(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"info", "warning", "error", "critical"}:
|
||||
raise ValueError("ALERT_MIN_SEVERITY must be one of: info, warning, error, critical")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_security_constraints(self) -> Settings:
|
||||
if bool(self.dashboard_auth_username) ^ bool(self.dashboard_auth_password):
|
||||
raise ValueError("dashboard auth requires both username and password")
|
||||
|
||||
if bool(self.kraken_api_key) ^ bool(self.kraken_api_secret):
|
||||
raise ValueError("Kraken API auth requires both API key and secret")
|
||||
|
||||
permissions = {
|
||||
token.strip().lower()
|
||||
for token in self.kraken_api_key_permissions.split(",")
|
||||
if token.strip()
|
||||
}
|
||||
if permissions and ("query" not in permissions or "trade" not in permissions):
|
||||
raise ValueError("KRAKEN_API_KEY_PERMISSIONS must include query and trade")
|
||||
if "withdraw" in permissions or "withdrawals" in permissions:
|
||||
raise ValueError("KRAKEN_API_KEY_PERMISSIONS must not include withdrawal scope")
|
||||
|
||||
if self.alert_dedup_seconds < 0.0:
|
||||
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
|
||||
|
||||
if self.strategy_stat_arb_lookback_window < 2:
|
||||
raise ValueError("STRATEGY_STAT_ARB_LOOKBACK_WINDOW must be >= 2")
|
||||
if self.strategy_stat_arb_entry_zscore <= 0.0:
|
||||
raise ValueError("STRATEGY_STAT_ARB_ENTRY_ZSCORE must be > 0")
|
||||
if self.strategy_stat_arb_exit_zscore < 0.0:
|
||||
raise ValueError("STRATEGY_STAT_ARB_EXIT_ZSCORE must be >= 0")
|
||||
if self.strategy_stat_arb_entry_zscore <= self.strategy_stat_arb_exit_zscore:
|
||||
raise ValueError(
|
||||
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
|
||||
)
|
||||
if self.strategy_stat_arb_max_holding_seconds <= 0.0:
|
||||
raise ValueError("STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Dashboard module for monitoring and controlling the arbitrage bot."""
|
||||
@@ -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
@@ -0,0 +1,12 @@
|
||||
"""Arbitrage detection package."""
|
||||
|
||||
from arbitrade.detection.engine import CycleScore, IncrementalCycleDetector, OpportunityEvent
|
||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||
|
||||
__all__ = [
|
||||
"CurrencyGraph",
|
||||
"TriangularCycle",
|
||||
"CycleScore",
|
||||
"OpportunityEvent",
|
||||
"IncrementalCycleDetector",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import statistics
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
import orjson
|
||||
|
||||
from arbitrade.detection.engine import IncrementalCycleDetector
|
||||
from arbitrade.detection.graph import CurrencyGraph
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
from arbitrade.market_data.order_book import OrderBook
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectionBenchmarkResult:
|
||||
iterations: int
|
||||
total_ms: float
|
||||
avg_ms: float
|
||||
p50_ms: float
|
||||
p95_ms: float
|
||||
max_ms: float
|
||||
target_ms: float
|
||||
|
||||
@property
|
||||
def meets_target(self) -> bool:
|
||||
return self.p95_ms <= self.target_ms
|
||||
|
||||
|
||||
def _make_book(*, bid: float, ask: float) -> OrderBook:
|
||||
book = OrderBook()
|
||||
book.apply_bids([BookLevel(price=bid, volume=10.0)])
|
||||
book.apply_asks([BookLevel(price=ask, volume=10.0)])
|
||||
return book
|
||||
|
||||
|
||||
def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, OrderBook]]:
|
||||
asset_pairs = {
|
||||
"XXBTZUSD": {"wsname": "BTC/USD"},
|
||||
"XETHXXBT": {"wsname": "ETH/BTC"},
|
||||
"XETHZUSD": {"wsname": "ETH/USD"},
|
||||
}
|
||||
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
|
||||
cycles = graph.triangular_cycles()
|
||||
index = graph.index_cycles_by_pair(cycles)
|
||||
|
||||
detector = IncrementalCycleDetector(
|
||||
index,
|
||||
fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
|
||||
min_profit_threshold=0.001,
|
||||
max_depth_levels=5,
|
||||
max_book_age_seconds=10.0,
|
||||
)
|
||||
|
||||
books = {
|
||||
"BTC/USD": _make_book(bid=99.9, ask=100.0),
|
||||
"ETH/BTC": _make_book(bid=0.049, ask=0.05),
|
||||
"ETH/USD": _make_book(bid=5.2, ask=5.21),
|
||||
}
|
||||
return detector, books
|
||||
|
||||
|
||||
def run_incremental_detection_benchmark(
|
||||
*,
|
||||
iterations: int = 50_000,
|
||||
target_ms: float = 1.0,
|
||||
) -> DetectionBenchmarkResult:
|
||||
if iterations <= 0:
|
||||
raise ValueError("iterations must be > 0")
|
||||
|
||||
detector, books = _build_detector_and_books()
|
||||
|
||||
samples_ms: list[float] = []
|
||||
started_ns = time.perf_counter_ns()
|
||||
for _ in range(iterations):
|
||||
t0_ns = time.perf_counter_ns()
|
||||
detector.score_updated_pair("ETH/BTC", books)
|
||||
elapsed_ms = (time.perf_counter_ns() - t0_ns) / 1_000_000
|
||||
samples_ms.append(elapsed_ms)
|
||||
|
||||
total_ms = (time.perf_counter_ns() - started_ns) / 1_000_000
|
||||
return DetectionBenchmarkResult(
|
||||
iterations=iterations,
|
||||
total_ms=total_ms,
|
||||
avg_ms=statistics.fmean(samples_ms),
|
||||
p50_ms=statistics.quantiles(samples_ms, n=100)[49],
|
||||
p95_ms=statistics.quantiles(samples_ms, n=100)[94],
|
||||
max_ms=max(samples_ms),
|
||||
target_ms=target_ms,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Benchmark incremental detection latency")
|
||||
parser.add_argument("--iterations", type=int, default=50_000)
|
||||
parser.add_argument("--target-ms", type=float, default=1.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_incremental_detection_benchmark(
|
||||
iterations=args.iterations,
|
||||
target_ms=args.target_ms,
|
||||
)
|
||||
|
||||
payload = {
|
||||
**asdict(result),
|
||||
"meets_target": result.meets_target,
|
||||
}
|
||||
print(orjson.dumps(payload).decode("utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from arbitrade.detection.graph import TriangularCycle
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
from arbitrade.market_data.order_book import OrderBook
|
||||
|
||||
|
||||
def _normalize_pair_symbol(symbol: str) -> str:
|
||||
if "/" not in symbol:
|
||||
return symbol.upper()
|
||||
|
||||
base, quote = symbol.split("/", 1)
|
||||
return f"{base.upper()}/{quote.upper()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CycleScore:
|
||||
cycle: TriangularCycle
|
||||
gross_rate: float
|
||||
net_rate: float
|
||||
min_profit_threshold: float
|
||||
updated_pair: str
|
||||
scored_at: datetime
|
||||
|
||||
@property
|
||||
def is_profitable(self) -> bool:
|
||||
return (self.net_rate - 1.0) >= self.min_profit_threshold
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpportunityEvent:
|
||||
detected_at: datetime
|
||||
cycle: str
|
||||
updated_pair: str
|
||||
gross_rate: float
|
||||
net_rate: float
|
||||
gross_pct: float
|
||||
net_pct: float
|
||||
est_profit: float
|
||||
allocated_capital: float = 1.0
|
||||
|
||||
@classmethod
|
||||
def from_cycle_score(cls, score: CycleScore, base_capital: float = 1.0) -> OpportunityEvent:
|
||||
gross_pct = (score.gross_rate - 1.0) * 100.0
|
||||
net_pct = (score.net_rate - 1.0) * 100.0
|
||||
est_profit = (score.net_rate - 1.0) * base_capital
|
||||
a, b, c = score.cycle.currencies
|
||||
cycle = f"{a}->{b}->{c}->{a}"
|
||||
return cls(
|
||||
detected_at=score.scored_at,
|
||||
cycle=cycle,
|
||||
updated_pair=score.updated_pair,
|
||||
gross_rate=score.gross_rate,
|
||||
net_rate=score.net_rate,
|
||||
gross_pct=gross_pct,
|
||||
net_pct=net_pct,
|
||||
est_profit=est_profit,
|
||||
allocated_capital=base_capital,
|
||||
)
|
||||
|
||||
|
||||
class IncrementalCycleDetector:
|
||||
def __init__(
|
||||
self,
|
||||
cycles_by_pair: Mapping[str, list[TriangularCycle]],
|
||||
*,
|
||||
fee_rate: float = 0.0,
|
||||
max_depth_levels: int = 10,
|
||||
min_profit_threshold: float = 0.0,
|
||||
min_order_size_by_pair: Mapping[str, float] | None = None,
|
||||
max_book_age_seconds: float | None = None,
|
||||
) -> None:
|
||||
self._cycles_by_pair = {
|
||||
_normalize_pair_symbol(pair): list(cycles) for pair, cycles in cycles_by_pair.items()
|
||||
}
|
||||
self._fee_multiplier = 1.0 - fee_rate
|
||||
self._max_depth_levels = max_depth_levels
|
||||
self._min_profit_threshold = min_profit_threshold
|
||||
self._max_book_age_seconds = max_book_age_seconds
|
||||
self._min_order_size_by_pair = {
|
||||
_normalize_pair_symbol(pair): float(min_size)
|
||||
for pair, min_size in (min_order_size_by_pair or {}).items()
|
||||
}
|
||||
|
||||
if self._fee_multiplier < 0.0:
|
||||
raise ValueError("fee_rate must be <= 1.0")
|
||||
if self._max_depth_levels <= 0:
|
||||
raise ValueError("max_depth_levels must be > 0")
|
||||
if self._min_profit_threshold < 0.0:
|
||||
raise ValueError("min_profit_threshold must be >= 0.0")
|
||||
if self._max_book_age_seconds is not None and self._max_book_age_seconds <= 0.0:
|
||||
raise ValueError("max_book_age_seconds must be > 0.0")
|
||||
for min_size in self._min_order_size_by_pair.values():
|
||||
if min_size <= 0.0:
|
||||
raise ValueError("minimum order size must be > 0.0")
|
||||
|
||||
def score_updated_pair(
|
||||
self,
|
||||
updated_pair: str,
|
||||
books: Mapping[str, OrderBook],
|
||||
) -> list[CycleScore]:
|
||||
normalized_pair = _normalize_pair_symbol(updated_pair)
|
||||
impacted_cycles = self._cycles_by_pair.get(normalized_pair, [])
|
||||
|
||||
normalized_books = {_normalize_pair_symbol(symbol): book for symbol, book in books.items()}
|
||||
|
||||
scores: list[CycleScore] = []
|
||||
scored_at = datetime.now(UTC)
|
||||
for cycle in impacted_cycles:
|
||||
rates = self._score_cycle(cycle, normalized_books, scored_at)
|
||||
if rates is None:
|
||||
continue
|
||||
gross_rate, net_rate = rates
|
||||
if (net_rate - 1.0) < self._min_profit_threshold:
|
||||
continue
|
||||
scores.append(
|
||||
CycleScore(
|
||||
cycle=cycle,
|
||||
gross_rate=gross_rate,
|
||||
net_rate=net_rate,
|
||||
min_profit_threshold=self._min_profit_threshold,
|
||||
updated_pair=normalized_pair,
|
||||
scored_at=scored_at,
|
||||
)
|
||||
)
|
||||
|
||||
return scores
|
||||
|
||||
def opportunities_for_updated_pair(
|
||||
self,
|
||||
updated_pair: str,
|
||||
books: Mapping[str, OrderBook],
|
||||
*,
|
||||
base_capital: float = 1.0,
|
||||
) -> list[OpportunityEvent]:
|
||||
if base_capital <= 0.0:
|
||||
raise ValueError("base_capital must be > 0.0")
|
||||
|
||||
scores = self.score_updated_pair(updated_pair, books)
|
||||
return [OpportunityEvent.from_cycle_score(score, base_capital) for score in scores]
|
||||
|
||||
def _score_cycle(
|
||||
self,
|
||||
cycle: TriangularCycle,
|
||||
books: Mapping[str, OrderBook],
|
||||
scored_at: datetime,
|
||||
) -> tuple[float, float] | None:
|
||||
if not self._is_cycle_fresh(cycle, books, scored_at):
|
||||
return None
|
||||
|
||||
a, b, c = cycle.currencies
|
||||
gross_amount = 1.0
|
||||
|
||||
gross_ab = self._convert(gross_amount, a, b, cycle, books)
|
||||
if gross_ab is None:
|
||||
return None
|
||||
net_ab = gross_ab * self._fee_multiplier
|
||||
|
||||
gross_bc = self._convert(gross_ab, b, c, cycle, books)
|
||||
if gross_bc is None:
|
||||
return None
|
||||
net_bc = self._convert(net_ab, b, c, cycle, books)
|
||||
if net_bc is None:
|
||||
return None
|
||||
net_bc *= self._fee_multiplier
|
||||
|
||||
gross_ca = self._convert(gross_bc, c, a, cycle, books)
|
||||
if gross_ca is None:
|
||||
return None
|
||||
net_ca = self._convert(net_bc, c, a, cycle, books)
|
||||
if net_ca is None:
|
||||
return None
|
||||
net_ca *= self._fee_multiplier
|
||||
|
||||
return gross_ca, net_ca
|
||||
|
||||
def _is_cycle_fresh(
|
||||
self,
|
||||
cycle: TriangularCycle,
|
||||
books: Mapping[str, OrderBook],
|
||||
scored_at: datetime,
|
||||
) -> bool:
|
||||
if self._max_book_age_seconds is None:
|
||||
return True
|
||||
|
||||
for pair in cycle.pairs:
|
||||
normalized_pair = _normalize_pair_symbol(pair)
|
||||
book = books.get(normalized_pair)
|
||||
if book is None:
|
||||
return False
|
||||
|
||||
age_seconds = (scored_at - book.updated_at).total_seconds()
|
||||
if age_seconds > self._max_book_age_seconds:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _pair_for_edge(cycle: TriangularCycle, from_currency: str, to_currency: str) -> str | None:
|
||||
for pair in cycle.pairs:
|
||||
if "/" not in pair:
|
||||
continue
|
||||
base, quote = pair.split("/", 1)
|
||||
base = base.upper()
|
||||
quote = quote.upper()
|
||||
if {base, quote} == {from_currency, to_currency}:
|
||||
return f"{base}/{quote}"
|
||||
return None
|
||||
|
||||
def _convert(
|
||||
self,
|
||||
amount: float,
|
||||
from_currency: str,
|
||||
to_currency: str,
|
||||
cycle: TriangularCycle,
|
||||
books: Mapping[str, OrderBook],
|
||||
) -> float | None:
|
||||
pair = self._pair_for_edge(cycle, from_currency, to_currency)
|
||||
if pair is None:
|
||||
return None
|
||||
|
||||
book = books.get(pair)
|
||||
if book is None:
|
||||
return None
|
||||
|
||||
bids, asks = book.top_levels(depth=self._max_depth_levels)
|
||||
|
||||
base, quote = pair.split("/", 1)
|
||||
base = base.upper()
|
||||
quote = quote.upper()
|
||||
|
||||
if from_currency == base and to_currency == quote:
|
||||
quote_out = self._sell_base_for_quote(amount, bids)
|
||||
if quote_out is None:
|
||||
return None
|
||||
if not self._is_min_order_size_satisfied(pair, amount):
|
||||
return None
|
||||
return quote_out
|
||||
|
||||
if from_currency == quote and to_currency == base:
|
||||
base_out = self._buy_base_with_quote(amount, asks)
|
||||
if base_out is None:
|
||||
return None
|
||||
if not self._is_min_order_size_satisfied(pair, base_out):
|
||||
return None
|
||||
return base_out
|
||||
|
||||
return None
|
||||
|
||||
def _is_min_order_size_satisfied(self, pair: str, base_amount: float) -> bool:
|
||||
min_size = self._min_order_size_by_pair.get(pair)
|
||||
if min_size is None:
|
||||
return True
|
||||
return base_amount >= min_size
|
||||
|
||||
@staticmethod
|
||||
def _sell_base_for_quote(amount_base: float, bids: list[BookLevel]) -> float | None:
|
||||
remaining = amount_base
|
||||
quote_out = 0.0
|
||||
for level in bids:
|
||||
if remaining <= 0.0:
|
||||
break
|
||||
if level.price <= 0.0 or level.volume <= 0.0:
|
||||
continue
|
||||
|
||||
executed = min(remaining, level.volume)
|
||||
quote_out += executed * level.price
|
||||
remaining -= executed
|
||||
|
||||
if remaining > 0.0:
|
||||
return None
|
||||
return quote_out
|
||||
|
||||
@staticmethod
|
||||
def _buy_base_with_quote(amount_quote: float, asks: list[BookLevel]) -> float | None:
|
||||
remaining_quote = amount_quote
|
||||
base_out = 0.0
|
||||
for level in asks:
|
||||
if remaining_quote <= 0.0:
|
||||
break
|
||||
if level.price <= 0.0 or level.volume <= 0.0:
|
||||
continue
|
||||
|
||||
level_quote_capacity = level.volume * level.price
|
||||
spend = min(remaining_quote, level_quote_capacity)
|
||||
base_out += spend / level.price
|
||||
remaining_quote -= spend
|
||||
|
||||
if remaining_quote > 0.0:
|
||||
return None
|
||||
return base_out
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TriangularCycle:
|
||||
currencies: tuple[str, str, str]
|
||||
pairs: tuple[str, str, str]
|
||||
|
||||
|
||||
def _canonical_pair(base: str, quote: str) -> str:
|
||||
return f"{base}/{quote}"
|
||||
|
||||
|
||||
class CurrencyGraph:
|
||||
def __init__(self) -> None:
|
||||
self._adjacency: dict[str, set[str]] = {}
|
||||
self._pair_by_direction: dict[tuple[str, str], str] = {}
|
||||
|
||||
@property
|
||||
def adjacency(self) -> dict[str, set[str]]:
|
||||
return self._adjacency
|
||||
|
||||
@property
|
||||
def pair_by_direction(self) -> dict[tuple[str, str], str]:
|
||||
return self._pair_by_direction
|
||||
|
||||
def add_pair(self, base: str, quote: str, pair_symbol: str | None = None) -> None:
|
||||
normalized_base = base.upper()
|
||||
normalized_quote = quote.upper()
|
||||
symbol = pair_symbol or _canonical_pair(normalized_base, normalized_quote)
|
||||
|
||||
self._adjacency.setdefault(normalized_base, set()).add(normalized_quote)
|
||||
self._adjacency.setdefault(normalized_quote, set()).add(normalized_base)
|
||||
|
||||
self._pair_by_direction[(normalized_base, normalized_quote)] = symbol
|
||||
self._pair_by_direction[(normalized_quote, normalized_base)] = symbol
|
||||
|
||||
@classmethod
|
||||
def from_kraken_asset_pairs(cls, asset_pairs: dict[str, Any]) -> CurrencyGraph:
|
||||
graph = cls()
|
||||
for value in asset_pairs.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
wsname = value.get("wsname")
|
||||
if isinstance(wsname, str) and "/" in wsname:
|
||||
base, quote = wsname.split("/", 1)
|
||||
graph.add_pair(base, quote, wsname)
|
||||
continue
|
||||
|
||||
raw_base = value.get("base")
|
||||
raw_quote = value.get("quote")
|
||||
if isinstance(raw_base, str) and isinstance(raw_quote, str):
|
||||
graph.add_pair(raw_base, raw_quote)
|
||||
|
||||
return graph
|
||||
|
||||
def triangular_cycles(self) -> list[TriangularCycle]:
|
||||
found: dict[tuple[str, str, str], TriangularCycle] = {}
|
||||
|
||||
for a, neighbors_a in self._adjacency.items():
|
||||
for b in neighbors_a:
|
||||
if a >= b:
|
||||
continue
|
||||
neighbors_b = self._adjacency.get(b, set())
|
||||
for c in neighbors_b:
|
||||
if b >= c:
|
||||
continue
|
||||
if a not in self._adjacency.get(c, set()):
|
||||
continue
|
||||
|
||||
p_ab = self._pair_by_direction[(a, b)]
|
||||
p_bc = self._pair_by_direction[(b, c)]
|
||||
p_ca = self._pair_by_direction[(c, a)]
|
||||
|
||||
key = (a, b, c)
|
||||
found[key] = TriangularCycle(currencies=key, pairs=(p_ab, p_bc, p_ca))
|
||||
|
||||
return list(found.values())
|
||||
|
||||
@staticmethod
|
||||
def index_cycles_by_pair(cycles: list[TriangularCycle]) -> dict[str, list[TriangularCycle]]:
|
||||
index: dict[str, list[TriangularCycle]] = {}
|
||||
for cycle in cycles:
|
||||
for pair in cycle.pairs:
|
||||
index.setdefault(pair, []).append(cycle)
|
||||
return index
|
||||
@@ -0,0 +1 @@
|
||||
"""Kraken exchange integration package."""
|
||||
@@ -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")
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.models import KrakenApiResult, LatencySample
|
||||
from arbitrade.exchange.signing import sign_kraken_private_path
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _result_dict(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = payload.get("result", {})
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
class KrakenRestClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.kraken_rest_url,
|
||||
timeout=settings.kraken_http_timeout_seconds,
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=50),
|
||||
headers={"User-Agent": "arbitrade/0.1.0"},
|
||||
)
|
||||
self._private_lock = asyncio.Lock()
|
||||
|
||||
issues = self.validate_compliance()
|
||||
if issues:
|
||||
_LOG.warning("kraken_compliance_issues", issues=issues)
|
||||
else:
|
||||
_LOG.info("kraken_compliance_ok")
|
||||
|
||||
def validate_compliance(self) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
if not self._settings.kraken_rest_url.startswith("https://"):
|
||||
issues.append("KRAKEN_REST_URL should use https://")
|
||||
|
||||
if self._settings.kraken_private_rate_limit_seconds < 1.0:
|
||||
issues.append("KRAKEN_PRIVATE_RATE_LIMIT_SECONDS below 1.0 may violate Kraken limits")
|
||||
|
||||
if self._settings.kraken_retry_attempts < 1:
|
||||
issues.append("KRAKEN_RETRY_ATTEMPTS must be >= 1")
|
||||
|
||||
if self._settings.kraken_retry_base_delay_seconds < 0:
|
||||
issues.append("KRAKEN_RETRY_BASE_DELAY_SECONDS must be >= 0")
|
||||
|
||||
return issues
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def warm_connection_pool(self) -> None:
|
||||
await self.server_time()
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> KrakenApiResult:
|
||||
attempts = self._settings.kraken_retry_attempts
|
||||
delay = self._settings.kraken_retry_base_delay_seconds
|
||||
params = params or {}
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
response = await self._client.get(endpoint, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(f"Kraken error: {payload['error']}")
|
||||
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.info(
|
||||
"kraken_rest_request_ok",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
sample=LatencySample.now("rest_request", latency_ms=latency).latency_ms,
|
||||
)
|
||||
return KrakenApiResult(endpoint=endpoint, payload=payload)
|
||||
except Exception as exc:
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.warning(
|
||||
"kraken_rest_request_failed",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
error=str(exc),
|
||||
)
|
||||
if attempt >= attempts:
|
||||
raise
|
||||
await asyncio.sleep(delay * (2 ** (attempt - 1)))
|
||||
|
||||
raise RuntimeError("unreachable retry loop")
|
||||
|
||||
async def _private_post_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> KrakenApiResult:
|
||||
api_key = self._settings.kraken_api_key
|
||||
api_secret = self._settings.kraken_api_secret
|
||||
if not api_key or not api_secret:
|
||||
raise RuntimeError("Missing Kraken API credentials for private endpoint")
|
||||
|
||||
attempts = self._settings.kraken_retry_attempts
|
||||
delay = self._settings.kraken_retry_base_delay_seconds
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
nonce = str(int(time.time() * 1000))
|
||||
payload = {"nonce": nonce}
|
||||
if data is not None:
|
||||
payload.update(data)
|
||||
|
||||
encoded = urlencode(payload)
|
||||
signature = sign_kraken_private_path(endpoint, nonce, encoded, api_secret)
|
||||
|
||||
response = await self._client.post(
|
||||
endpoint,
|
||||
data=payload,
|
||||
headers={
|
||||
"API-Key": api_key,
|
||||
"API-Sign": signature,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if body.get("error"):
|
||||
raise RuntimeError(f"Kraken error: {body['error']}")
|
||||
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.info(
|
||||
"kraken_private_rest_request_ok",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
sample=LatencySample.now("private_rest_request", latency_ms=latency).latency_ms,
|
||||
)
|
||||
return KrakenApiResult(endpoint=endpoint, payload=body)
|
||||
except Exception as exc:
|
||||
latency = (time.perf_counter() - t0) * 1000
|
||||
_LOG.warning(
|
||||
"kraken_private_rest_request_failed",
|
||||
endpoint=endpoint,
|
||||
attempt=attempt,
|
||||
latency_ms=latency,
|
||||
error=str(exc),
|
||||
)
|
||||
if attempt >= attempts:
|
||||
raise
|
||||
await asyncio.sleep(delay * (2 ** (attempt - 1)))
|
||||
|
||||
raise RuntimeError("unreachable retry loop")
|
||||
|
||||
async def server_time(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/Time")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def assets(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/Assets")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def asset_pairs(self) -> dict[str, Any]:
|
||||
result = await self._request_with_retry("/0/public/AssetPairs")
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def _throttled_private_call(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async with self._private_lock:
|
||||
result = await self._private_post_with_retry(endpoint, data=data)
|
||||
await asyncio.sleep(self._settings.kraken_private_rate_limit_seconds)
|
||||
return _result_dict(result.payload)
|
||||
|
||||
async def balances(self) -> dict[str, Any]:
|
||||
return await self._throttled_private_call("/0/private/Balance")
|
||||
|
||||
async def place_market_order(
|
||||
self,
|
||||
*,
|
||||
pair: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
user_ref: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_side = side.lower()
|
||||
if normalized_side not in {"buy", "sell"}:
|
||||
raise ValueError("side must be 'buy' or 'sell'")
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume must be > 0.0")
|
||||
if user_ref is not None and user_ref < 0:
|
||||
raise ValueError("user_ref must be >= 0")
|
||||
|
||||
data = {
|
||||
"pair": pair,
|
||||
"type": normalized_side,
|
||||
"ordertype": "market",
|
||||
"volume": str(volume),
|
||||
}
|
||||
if user_ref is not None:
|
||||
data["userref"] = str(user_ref)
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/AddOrder",
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
pair: str,
|
||||
side: str,
|
||||
volume: float,
|
||||
price: float,
|
||||
user_ref: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_side = side.lower()
|
||||
if normalized_side not in {"buy", "sell"}:
|
||||
raise ValueError("side must be 'buy' or 'sell'")
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume must be > 0.0")
|
||||
if price <= 0.0:
|
||||
raise ValueError("price must be > 0.0")
|
||||
if user_ref is not None and user_ref < 0:
|
||||
raise ValueError("user_ref must be >= 0")
|
||||
|
||||
data = {
|
||||
"pair": pair,
|
||||
"type": normalized_side,
|
||||
"ordertype": "limit",
|
||||
"price": str(price),
|
||||
"volume": str(volume),
|
||||
}
|
||||
if user_ref is not None:
|
||||
data["userref"] = str(user_ref)
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/AddOrder",
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def query_order(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
include_trades: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/QueryOrders",
|
||||
data={
|
||||
"txid": order_id,
|
||||
"trades": "true" if include_trades else "false",
|
||||
},
|
||||
)
|
||||
|
||||
async def cancel_order(self, *, order_id: str) -> dict[str, Any]:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
return await self._throttled_private_call(
|
||||
"/0/private/CancelOrder",
|
||||
data={"txid": order_id},
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import structlog
|
||||
import websockets
|
||||
|
||||
from arbitrade.alerting.notifier import AlertSeverity, SupportsAlerts
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.models import BookDelta, BookLevel
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WsMessage:
|
||||
received_at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class KrakenWsClient:
|
||||
def __init__(self, settings: Settings, *, alert_notifier: SupportsAlerts | None = None) -> None:
|
||||
self._settings = settings
|
||||
self._last_message_at: datetime | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self._alert_notifier = alert_notifier
|
||||
self._has_connected_once = False
|
||||
self._was_disconnected = False
|
||||
self._subscribed_symbols: list[str] = []
|
||||
|
||||
@property
|
||||
def is_stale(self) -> bool:
|
||||
if self._last_message_at is None:
|
||||
return True
|
||||
return (
|
||||
datetime.now(UTC) - self._last_message_at
|
||||
).total_seconds() > self._settings.ws_max_staleness_seconds
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
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]:
|
||||
delay = 1.0
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
url = self._settings.kraken_ws_url
|
||||
async with websockets.connect(url, max_size=2_000_000) as ws:
|
||||
_LOG.info("kraken_ws_connected", url=url)
|
||||
if self._has_connected_once and self._was_disconnected:
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="info",
|
||||
title="WebSocket reconnected",
|
||||
message="Kraken WebSocket connection restored.",
|
||||
details={"url": url},
|
||||
)
|
||||
self._has_connected_once = True
|
||||
self._was_disconnected = False
|
||||
delay = 1.0
|
||||
await self._subscribe(ws)
|
||||
async for raw in self._recv_loop(ws):
|
||||
yield raw
|
||||
except Exception as exc:
|
||||
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
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="warning",
|
||||
title="WebSocket disconnected",
|
||||
message="Kraken WebSocket disconnected, reconnect scheduled.",
|
||||
details={
|
||||
"error": str(exc),
|
||||
"reconnect_in_seconds": f"{delay}",
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30.0)
|
||||
|
||||
async def _recv_loop(self, ws: Any) -> AsyncIterator[WsMessage]:
|
||||
while not self._stop.is_set():
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
ws.recv(), timeout=self._settings.ws_heartbeat_timeout_seconds
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._notify(
|
||||
category="system",
|
||||
severity="critical",
|
||||
title="WebSocket staleness abort",
|
||||
message="No WebSocket heartbeat within configured timeout; reconnecting.",
|
||||
details={
|
||||
"heartbeat_timeout_seconds": (
|
||||
f"{self._settings.ws_heartbeat_timeout_seconds}"
|
||||
),
|
||||
},
|
||||
)
|
||||
raise
|
||||
parse_start = time.perf_counter()
|
||||
payload = orjson.loads(raw)
|
||||
self._last_message_at = datetime.now(UTC)
|
||||
|
||||
_LOG.debug(
|
||||
"kraken_ws_message",
|
||||
recv_latency_ms=(parse_start - t0) * 1000,
|
||||
parse_latency_ms=(time.perf_counter() - parse_start) * 1000,
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
yield WsMessage(received_at=self._last_message_at, payload=payload)
|
||||
|
||||
async def _notify(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
severity: AlertSeverity,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if self._alert_notifier is None:
|
||||
return
|
||||
await self._alert_notifier.notify(
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_book_delta(message: dict[str, Any]) -> BookDelta | None:
|
||||
# Kraken v2 book update shape can vary by channel; keep parser defensive.
|
||||
channel = str(message.get("channel", ""))
|
||||
if "book" not in channel:
|
||||
return None
|
||||
|
||||
symbol = str(message.get("symbol", ""))
|
||||
data = message.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
|
||||
first = data[0]
|
||||
if not isinstance(first, dict):
|
||||
return None
|
||||
|
||||
bids = [
|
||||
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
|
||||
for level in first.get("bids", [])
|
||||
if isinstance(level, dict) and "price" in level and "qty" in level
|
||||
]
|
||||
asks = [
|
||||
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
|
||||
for level in first.get("asks", [])
|
||||
if isinstance(level, dict) and "price" in level and "qty" in level
|
||||
]
|
||||
|
||||
checksum: int | None = None
|
||||
raw_checksum = first.get("checksum")
|
||||
if isinstance(raw_checksum, int):
|
||||
checksum = raw_checksum
|
||||
|
||||
source_timestamp_ms: int | None = None
|
||||
if isinstance(first.get("timestamp"), int):
|
||||
source_timestamp_ms = first["timestamp"]
|
||||
|
||||
return BookDelta(
|
||||
symbol=symbol,
|
||||
bids=bids,
|
||||
asks=asks,
|
||||
checksum=checksum,
|
||||
source_timestamp_ms=source_timestamp_ms,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KrakenApiResult:
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LatencySample:
|
||||
stage: str
|
||||
at: datetime
|
||||
latency_ms: float
|
||||
|
||||
@classmethod
|
||||
def now(cls, stage: str, latency_ms: float) -> LatencySample:
|
||||
return cls(stage=stage, at=datetime.now(UTC), latency_ms=latency_ms)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookLevel:
|
||||
price: float
|
||||
volume: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookDelta:
|
||||
symbol: str
|
||||
bids: list[BookLevel]
|
||||
asks: list[BookLevel]
|
||||
checksum: int | None = None
|
||||
source_timestamp_ms: int | None = None
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def sign_kraken_private_path(path: str, nonce: str, post_data: str, api_secret: str) -> str:
|
||||
message = nonce.encode("utf-8") + post_data.encode("utf-8")
|
||||
sha256 = hashlib.sha256(message).digest()
|
||||
mac = hmac.new(base64.b64decode(api_secret), path.encode("utf-8") + sha256, hashlib.sha512)
|
||||
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Trade execution helpers."""
|
||||
|
||||
from arbitrade.execution.fill_monitor import (
|
||||
FillMonitor,
|
||||
FillMonitorResult,
|
||||
OrderFillState,
|
||||
)
|
||||
from arbitrade.execution.idempotency import (
|
||||
IdempotencyKeyFactory,
|
||||
OrderReconciler,
|
||||
ReconciliationReport,
|
||||
)
|
||||
from arbitrade.execution.recovery import PartialFillRecovery, RecoveryAction
|
||||
from arbitrade.execution.sequencer import (
|
||||
ExecutionLeg,
|
||||
TriangularExecutionResult,
|
||||
TriangularExecutionSequencer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ExecutionLeg",
|
||||
"OrderFillState",
|
||||
"FillMonitorResult",
|
||||
"FillMonitor",
|
||||
"IdempotencyKeyFactory",
|
||||
"ReconciliationReport",
|
||||
"OrderReconciler",
|
||||
"RecoveryAction",
|
||||
"PartialFillRecovery",
|
||||
"TriangularExecutionResult",
|
||||
"TriangularExecutionSequencer",
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SupportsOrderStatusPolling(Protocol):
|
||||
async def query_order(
|
||||
self, *, order_id: str, include_trades: bool = True
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrderFillState:
|
||||
order_id: str
|
||||
status: str
|
||||
filled_volume: float | None
|
||||
avg_price: float | None
|
||||
updated_at: datetime
|
||||
source: str
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.status in {"closed", "canceled", "expired"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FillMonitorResult:
|
||||
order_id: str
|
||||
timed_out: bool
|
||||
terminal_state: OrderFillState | None
|
||||
last_state: OrderFillState | None
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
class FillMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
poll_client: SupportsOrderStatusPolling,
|
||||
*,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
max_wait_seconds: float = 10.0,
|
||||
ws_status_provider: Callable[[str], OrderFillState | None] | None = None,
|
||||
) -> None:
|
||||
if poll_interval_seconds <= 0.0:
|
||||
raise ValueError("poll_interval_seconds must be > 0.0")
|
||||
if max_wait_seconds <= 0.0:
|
||||
raise ValueError("max_wait_seconds must be > 0.0")
|
||||
|
||||
self._poll_client = poll_client
|
||||
self._poll_interval_seconds = poll_interval_seconds
|
||||
self._max_wait_seconds = max_wait_seconds
|
||||
self._ws_status_provider = ws_status_provider
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _state_from_payload(
|
||||
cls, order_id: str, payload: dict[str, Any], *, source: str
|
||||
) -> OrderFillState:
|
||||
status = str(payload.get("status", "unknown")).lower()
|
||||
return OrderFillState(
|
||||
order_id=order_id,
|
||||
status=status,
|
||||
filled_volume=cls._to_float(payload.get("vol_exec")),
|
||||
avg_price=cls._to_float(payload.get("price") or payload.get("avg_price")),
|
||||
updated_at=datetime.now(UTC),
|
||||
source=source,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _extract_order_payload(cls, order_id: str, response: dict[str, Any]) -> dict[str, Any]:
|
||||
if order_id in response and isinstance(response[order_id], dict):
|
||||
payload = response[order_id]
|
||||
return {str(key): value for key, value in payload.items()}
|
||||
return response
|
||||
|
||||
async def wait_for_terminal_fill(self, order_id: str) -> FillMonitorResult:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
started = time.monotonic()
|
||||
last_state: OrderFillState | None = None
|
||||
|
||||
while True:
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed >= self._max_wait_seconds:
|
||||
return FillMonitorResult(
|
||||
order_id=order_id,
|
||||
timed_out=True,
|
||||
terminal_state=None,
|
||||
last_state=last_state,
|
||||
elapsed_seconds=elapsed,
|
||||
)
|
||||
|
||||
if self._ws_status_provider is not None:
|
||||
ws_state = self._ws_status_provider(order_id)
|
||||
if ws_state is not None:
|
||||
last_state = ws_state
|
||||
if ws_state.is_terminal:
|
||||
return FillMonitorResult(
|
||||
order_id=order_id,
|
||||
timed_out=False,
|
||||
terminal_state=ws_state,
|
||||
last_state=ws_state,
|
||||
elapsed_seconds=elapsed,
|
||||
)
|
||||
|
||||
response = await self._poll_client.query_order(order_id=order_id, include_trades=True)
|
||||
payload = self._extract_order_payload(order_id, response)
|
||||
polled_state = self._state_from_payload(order_id, payload, source="rest_poll")
|
||||
last_state = polled_state
|
||||
if polled_state.is_terminal:
|
||||
return FillMonitorResult(
|
||||
order_id=order_id,
|
||||
timed_out=False,
|
||||
terminal_state=polled_state,
|
||||
last_state=polled_state,
|
||||
elapsed_seconds=time.monotonic() - started,
|
||||
)
|
||||
|
||||
await asyncio.sleep(self._poll_interval_seconds)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.execution.sequencer import ExecutionLeg
|
||||
|
||||
|
||||
class SupportsOrderHistoryLookup(Protocol):
|
||||
async def query_order(
|
||||
self, *, order_id: str, include_trades: bool = True
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationReport:
|
||||
order_id: str
|
||||
user_ref: int
|
||||
status: str
|
||||
filled_volume: float | None
|
||||
avg_price: float | None
|
||||
is_terminal: bool
|
||||
matches_request: bool
|
||||
raw_payload: dict[str, Any]
|
||||
|
||||
|
||||
class IdempotencyKeyFactory:
|
||||
def user_ref_for_leg(self, event: OpportunityEvent, leg: ExecutionLeg, leg_index: int) -> int:
|
||||
material = "|".join(
|
||||
[
|
||||
event.cycle,
|
||||
event.updated_pair,
|
||||
leg.from_currency,
|
||||
leg.to_currency,
|
||||
leg.pair,
|
||||
leg.side,
|
||||
f"{leg.volume:.12f}",
|
||||
str(leg_index),
|
||||
]
|
||||
).encode("utf-8")
|
||||
digest = hashlib.sha256(material).digest()
|
||||
value = int.from_bytes(digest[:8], "big") % 2_147_483_647
|
||||
return value or 1
|
||||
|
||||
|
||||
class OrderReconciler:
|
||||
def __init__(self, history_client: SupportsOrderHistoryLookup) -> None:
|
||||
self._history_client = history_client
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_payload(order_id: str, response: dict[str, Any]) -> dict[str, Any]:
|
||||
if order_id in response and isinstance(response[order_id], dict):
|
||||
payload = response[order_id]
|
||||
return {str(key): value for key, value in payload.items()}
|
||||
return response
|
||||
|
||||
async def reconcile_order(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
user_ref: int,
|
||||
expected_pair: str,
|
||||
expected_side: str,
|
||||
expected_volume: float,
|
||||
) -> ReconciliationReport:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
response = await self._history_client.query_order(order_id=order_id, include_trades=True)
|
||||
payload = self._extract_payload(order_id, response)
|
||||
status = str(payload.get("status", "unknown")).lower()
|
||||
filled_volume = self._to_float(payload.get("vol_exec"))
|
||||
avg_price = self._to_float(payload.get("price") or payload.get("avg_price"))
|
||||
reported_pair = str(payload.get("pair", expected_pair))
|
||||
reported_side = str(payload.get("type", expected_side)).lower()
|
||||
matches_request = (
|
||||
reported_pair == expected_pair
|
||||
and reported_side == expected_side.lower()
|
||||
and (
|
||||
expected_volume <= 0.0 or filled_volume is None or filled_volume <= expected_volume
|
||||
)
|
||||
and payload.get("userref") in {None, str(user_ref), user_ref}
|
||||
)
|
||||
|
||||
return ReconciliationReport(
|
||||
order_id=order_id,
|
||||
user_ref=user_ref,
|
||||
status=status,
|
||||
filled_volume=filled_volume,
|
||||
avg_price=avg_price,
|
||||
is_terminal=status in {"closed", "canceled", "expired"},
|
||||
matches_request=matches_request,
|
||||
raw_payload=payload,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from arbitrade.execution.fill_monitor import FillMonitorResult, OrderFillState
|
||||
|
||||
|
||||
class SupportsOrderLifecycle(Protocol):
|
||||
async def cancel_order(self, *, order_id: str) -> dict[str, Any]: ...
|
||||
|
||||
async def place_market_order(
|
||||
self, *, pair: str, side: str, volume: float
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecoveryAction:
|
||||
order_id: str
|
||||
canceled: bool
|
||||
hedged: bool
|
||||
hedge_pair: str | None = None
|
||||
hedge_side: str | None = None
|
||||
hedge_volume: float | None = None
|
||||
cancel_response: dict[str, Any] | None = None
|
||||
hedge_response: dict[str, Any] | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class PartialFillRecovery:
|
||||
def __init__(self, rest_client: SupportsOrderLifecycle) -> None:
|
||||
self._rest_client = rest_client
|
||||
|
||||
@staticmethod
|
||||
def _counter_side(side: str) -> str:
|
||||
normalized = side.lower()
|
||||
if normalized == "buy":
|
||||
return "sell"
|
||||
if normalized == "sell":
|
||||
return "buy"
|
||||
raise ValueError("side must be 'buy' or 'sell'")
|
||||
|
||||
@staticmethod
|
||||
def _residual_volume(terminal_state: OrderFillState | None, requested_volume: float) -> float:
|
||||
if requested_volume <= 0.0:
|
||||
raise ValueError("requested_volume must be > 0.0")
|
||||
if terminal_state is None or terminal_state.filled_volume is None:
|
||||
return requested_volume
|
||||
residual = requested_volume - terminal_state.filled_volume
|
||||
return residual if residual > 0.0 else 0.0
|
||||
|
||||
async def recover_partial_fill(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
pair: str,
|
||||
side: str,
|
||||
requested_volume: float,
|
||||
fill_result: FillMonitorResult,
|
||||
) -> RecoveryAction:
|
||||
if not order_id.strip():
|
||||
raise ValueError("order_id must be non-empty")
|
||||
|
||||
cancel_response: dict[str, Any] | None = None
|
||||
hedge_response: dict[str, Any] | None = None
|
||||
hedged = False
|
||||
canceled = False
|
||||
reason = None
|
||||
|
||||
state = fill_result.terminal_state or fill_result.last_state
|
||||
residual_volume = self._residual_volume(state, requested_volume)
|
||||
|
||||
if state is not None and state.status in {"open", "partial"}:
|
||||
cancel_response = await self._rest_client.cancel_order(order_id=order_id)
|
||||
canceled = True
|
||||
reason = f"canceled_{state.status}_order"
|
||||
|
||||
if residual_volume > 0.0 and fill_result.timed_out:
|
||||
hedge_response = await self._rest_client.place_market_order(
|
||||
pair=pair,
|
||||
side=self._counter_side(side),
|
||||
volume=residual_volume,
|
||||
)
|
||||
hedged = True
|
||||
if reason is None:
|
||||
reason = "hedged_timed_out_order"
|
||||
|
||||
return RecoveryAction(
|
||||
order_id=order_id,
|
||||
canceled=canceled,
|
||||
hedged=hedged,
|
||||
hedge_pair=pair if hedged else None,
|
||||
hedge_side=self._counter_side(side) if hedged else None,
|
||||
hedge_volume=residual_volume if hedged else None,
|
||||
cancel_response=cancel_response,
|
||||
hedge_response=hedge_response,
|
||||
reason=reason,
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from arbitrade.alerting.notifier import SupportsAlerts
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.storage.executions import AsyncExecutionWriter
|
||||
from arbitrade.storage.repositories import (
|
||||
AuditRecord,
|
||||
AuditRepository,
|
||||
OrderRecord,
|
||||
PnLRecord,
|
||||
TradeRecord,
|
||||
)
|
||||
|
||||
|
||||
class SupportsOrderPlacement(Protocol):
|
||||
async def place_market_order(
|
||||
self, *, pair: str, side: str, volume: float
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExecutionLeg:
|
||||
from_currency: str
|
||||
to_currency: str
|
||||
pair: str
|
||||
side: str
|
||||
volume: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TriangularExecutionResult:
|
||||
success: bool
|
||||
requested_legs: tuple[ExecutionLeg, ...]
|
||||
completed_legs: int
|
||||
responses: tuple[dict[str, Any], ...]
|
||||
failure_reason: str | None = None
|
||||
|
||||
|
||||
class TriangularExecutionSequencer:
|
||||
def __init__(
|
||||
self,
|
||||
rest_client: SupportsOrderPlacement,
|
||||
*,
|
||||
available_pairs: Sequence[str],
|
||||
volume_for_leg: Callable[[OpportunityEvent, ExecutionLeg, int], float] | None = None,
|
||||
execution_writer: AsyncExecutionWriter | None = None,
|
||||
alert_notifier: SupportsAlerts | None = None,
|
||||
audit_repository: AuditRepository | None = None,
|
||||
) -> None:
|
||||
self._rest_client = rest_client
|
||||
self._available_pairs = {self._normalize_pair(pair) for pair in available_pairs}
|
||||
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
|
||||
self._execution_writer = execution_writer
|
||||
self._alert_notifier = alert_notifier
|
||||
self._audit_repository = audit_repository
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pair(pair: str) -> str:
|
||||
normalized = pair.strip().upper().replace("-", "/")
|
||||
if "/" not in normalized:
|
||||
return normalized
|
||||
base, quote = normalized.split("/", 1)
|
||||
return f"{base}/{quote}"
|
||||
|
||||
@staticmethod
|
||||
def _default_volume_for_leg(event: OpportunityEvent, _leg: ExecutionLeg, _idx: int) -> float:
|
||||
if event.allocated_capital <= 0.0:
|
||||
raise ValueError("allocated_capital must be > 0.0")
|
||||
return event.allocated_capital
|
||||
|
||||
def _resolve_leg(self, from_currency: str, to_currency: str, volume: float) -> ExecutionLeg:
|
||||
from_cur = from_currency.upper()
|
||||
to_cur = to_currency.upper()
|
||||
|
||||
buy_pair = f"{to_cur}/{from_cur}"
|
||||
if buy_pair in self._available_pairs:
|
||||
return ExecutionLeg(
|
||||
from_currency=from_cur,
|
||||
to_currency=to_cur,
|
||||
pair=buy_pair,
|
||||
side="buy",
|
||||
volume=volume,
|
||||
)
|
||||
|
||||
sell_pair = f"{from_cur}/{to_cur}"
|
||||
if sell_pair in self._available_pairs:
|
||||
return ExecutionLeg(
|
||||
from_currency=from_cur,
|
||||
to_currency=to_cur,
|
||||
pair=sell_pair,
|
||||
side="sell",
|
||||
volume=volume,
|
||||
)
|
||||
|
||||
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
|
||||
|
||||
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
|
||||
currencies = [part.strip().upper() for part in event.cycle.split("->") if part.strip()]
|
||||
if len(currencies) < 4 or currencies[0] != currencies[-1]:
|
||||
raise ValueError("cycle must be a closed triangular path like A->B->C->A")
|
||||
|
||||
if len(currencies) != 4:
|
||||
raise ValueError("cycle must contain exactly three unique currencies")
|
||||
|
||||
legs: list[ExecutionLeg] = []
|
||||
for idx in range(3):
|
||||
from_currency = currencies[idx]
|
||||
to_currency = currencies[idx + 1]
|
||||
placeholder_leg = ExecutionLeg(
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
pair="",
|
||||
side="buy",
|
||||
volume=0.0,
|
||||
)
|
||||
volume = self._volume_for_leg(event, placeholder_leg, idx)
|
||||
if volume <= 0.0:
|
||||
raise ValueError("volume_for_leg must return a positive volume")
|
||||
legs.append(self._resolve_leg(from_currency, to_currency, volume))
|
||||
|
||||
return tuple(legs)
|
||||
|
||||
@staticmethod
|
||||
def _trade_ref_for_event(event: OpportunityEvent) -> str:
|
||||
material = (
|
||||
f"{event.cycle}|{event.updated_pair}|"
|
||||
f"{event.detected_at.timestamp():.6f}|"
|
||||
f"{event.allocated_capital:.12f}"
|
||||
)
|
||||
return material.encode("utf-8").hex()[:32]
|
||||
|
||||
@staticmethod
|
||||
def _order_ref_from_response(response: dict[str, Any], default: str) -> str:
|
||||
txid = response.get("txid")
|
||||
if isinstance(txid, list) and txid:
|
||||
return str(txid[0])
|
||||
if isinstance(txid, str) and txid.strip():
|
||||
return txid
|
||||
return default
|
||||
|
||||
async def execute(self, event: OpportunityEvent) -> TriangularExecutionResult:
|
||||
legs = self._build_legs(event)
|
||||
responses: list[dict[str, Any]] = []
|
||||
trade_ref = self._trade_ref_for_event(event)
|
||||
started_at = datetime.now(UTC)
|
||||
|
||||
for idx, leg in enumerate(legs):
|
||||
try:
|
||||
response = await self._rest_client.place_market_order(
|
||||
pair=leg.pair,
|
||||
side=leg.side,
|
||||
volume=leg.volume,
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.trade.failed",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"failed_leg_index": idx,
|
||||
"error": str(exc),
|
||||
},
|
||||
correlation_id=trade_ref,
|
||||
)
|
||||
)
|
||||
if self._alert_notifier is not None:
|
||||
await self._alert_notifier.notify(
|
||||
category="error",
|
||||
severity="error",
|
||||
title="Trade execution failed",
|
||||
message="Triangular execution failed before completing all legs.",
|
||||
details={
|
||||
"cycle": event.cycle,
|
||||
"failed_leg_index": str(idx),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
if self._execution_writer is not None:
|
||||
await self._execution_writer.enqueue(
|
||||
TradeRecord(
|
||||
trade_ref=trade_ref,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(UTC),
|
||||
status="failed",
|
||||
realized_pnl=None,
|
||||
estimated_pnl=event.est_profit,
|
||||
capital_used=event.allocated_capital,
|
||||
cycle=event.cycle,
|
||||
leg_count=len(legs),
|
||||
)
|
||||
)
|
||||
return TriangularExecutionResult(
|
||||
success=False,
|
||||
requested_legs=legs,
|
||||
completed_legs=idx,
|
||||
responses=tuple(responses),
|
||||
failure_reason=str(exc),
|
||||
)
|
||||
|
||||
responses.append(response)
|
||||
|
||||
if self._execution_writer is not None:
|
||||
order_ref = self._order_ref_from_response(response, f"leg-{idx}")
|
||||
await self._execution_writer.enqueue(
|
||||
OrderRecord(
|
||||
trade_ref=trade_ref,
|
||||
order_ref=order_ref,
|
||||
leg_index=idx,
|
||||
pair=leg.pair,
|
||||
side=leg.side,
|
||||
volume=leg.volume,
|
||||
user_ref=None,
|
||||
status=str(response.get("status", "submitted")),
|
||||
filled_volume=None,
|
||||
avg_price=None,
|
||||
raw_response=response,
|
||||
recorded_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
|
||||
if self._execution_writer is not None:
|
||||
await self._execution_writer.enqueue(
|
||||
TradeRecord(
|
||||
trade_ref=trade_ref,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(UTC),
|
||||
status="filled",
|
||||
realized_pnl=None,
|
||||
estimated_pnl=event.est_profit,
|
||||
capital_used=event.allocated_capital,
|
||||
cycle=event.cycle,
|
||||
leg_count=len(legs),
|
||||
)
|
||||
)
|
||||
await self._execution_writer.enqueue(
|
||||
PnLRecord(
|
||||
trade_ref=trade_ref,
|
||||
recorded_at=datetime.now(UTC),
|
||||
kind="estimated",
|
||||
pnl_usd=event.est_profit,
|
||||
source="triangular_sequencer",
|
||||
)
|
||||
)
|
||||
|
||||
if self._alert_notifier is not None:
|
||||
await self._alert_notifier.notify(
|
||||
category="trade",
|
||||
severity="warning" if event.est_profit < 0.0 else "info",
|
||||
title="Trade execution completed",
|
||||
message="Triangular execution completed all requested legs.",
|
||||
details={
|
||||
"cycle": event.cycle,
|
||||
"completed_legs": str(len(legs)),
|
||||
"estimated_pnl_usd": f"{event.est_profit}",
|
||||
},
|
||||
)
|
||||
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.trade.completed",
|
||||
decision="approved",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"completed_legs": len(legs),
|
||||
"estimated_pnl_usd": event.est_profit,
|
||||
},
|
||||
correlation_id=trade_ref,
|
||||
)
|
||||
)
|
||||
|
||||
return TriangularExecutionResult(
|
||||
success=True,
|
||||
requested_legs=legs,
|
||||
completed_legs=len(legs),
|
||||
responses=tuple(responses),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Logging package — DB sink, maintenance tasks."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -6,6 +6,8 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.logging.db_sink import db_sink_processor
|
||||
|
||||
|
||||
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
|
||||
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_logger_name,
|
||||
timestamper,
|
||||
db_sink_processor,
|
||||
]
|
||||
|
||||
if json_logs:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Market data ingestion and book cache package."""
|
||||
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
|
||||
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
|
||||
from arbitrade.exchange.kraken_ws import KrakenWsClient
|
||||
from arbitrade.market_data.order_book import OrderBook
|
||||
from arbitrade.risk.kill_switch import KillSwitch
|
||||
from arbitrade.risk.loss_limits import LossLimitGuard
|
||||
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||
from arbitrade.risk.stop_conditions import StopConditionsGuard
|
||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter, MarketSnapshot
|
||||
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
||||
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExecutionOutcome:
|
||||
realized_pnl: float | None = None
|
||||
close_trade: bool = True
|
||||
|
||||
|
||||
class MarketDataFeed:
|
||||
def __init__(
|
||||
self,
|
||||
ws_client: KrakenWsClient,
|
||||
snapshot_writer: AsyncMarketSnapshotWriter,
|
||||
detector: IncrementalCycleDetector | None = None,
|
||||
opportunity_writer: AsyncOpportunityWriter | None = None,
|
||||
paper_trading_mode: bool = True,
|
||||
opportunity_executor: (
|
||||
Callable[[OpportunityEvent], Awaitable[ExecutionOutcome | float | None]] | None
|
||||
) = None,
|
||||
trade_capital: float = 1.0,
|
||||
max_trade_capital: float | None = None,
|
||||
loss_limit_guard: LossLimitGuard | None = None,
|
||||
trade_limits_guard: TradeLimitsGuard | None = None,
|
||||
pre_trade_validator: PreTradeValidator | None = None,
|
||||
balance_provider: Callable[[], Mapping[str, float]] | None = None,
|
||||
quote_balance_asset: str = "USD",
|
||||
kill_switch: KillSwitch | None = None,
|
||||
stop_conditions_guard: StopConditionsGuard | None = None,
|
||||
alert_notifier: SupportsAlerts | None = None,
|
||||
audit_repository: AuditRepository | None = None,
|
||||
) -> None:
|
||||
self._ws_client = ws_client
|
||||
self._snapshot_writer = snapshot_writer
|
||||
self._books: dict[str, OrderBook] = {}
|
||||
self._detector = detector
|
||||
self._opportunity_writer = opportunity_writer
|
||||
self._paper_trading_mode = paper_trading_mode
|
||||
self._opportunity_executor = opportunity_executor
|
||||
self._trade_capital = trade_capital
|
||||
self._max_trade_capital = max_trade_capital
|
||||
self._loss_limit_guard = loss_limit_guard
|
||||
self._trade_limits_guard = trade_limits_guard
|
||||
self._pre_trade_validator = pre_trade_validator
|
||||
self._balance_provider = balance_provider
|
||||
self._quote_balance_asset = quote_balance_asset.upper()
|
||||
self._kill_switch = kill_switch
|
||||
self._stop_conditions_guard = stop_conditions_guard
|
||||
self._alert_notifier = alert_notifier
|
||||
self._audit_repository = audit_repository
|
||||
|
||||
if self._trade_capital <= 0.0:
|
||||
raise ValueError("trade_capital must be > 0.0")
|
||||
if self._max_trade_capital is not None and self._max_trade_capital <= 0.0:
|
||||
raise ValueError("max_trade_capital must be > 0.0")
|
||||
|
||||
@property
|
||||
def books(self) -> dict[str, OrderBook]:
|
||||
return self._books
|
||||
|
||||
def _effective_trade_capital(self) -> float:
|
||||
if self._max_trade_capital is None:
|
||||
return self._trade_capital
|
||||
return min(self._trade_capital, self._max_trade_capital)
|
||||
|
||||
@staticmethod
|
||||
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
|
||||
currencies = [part for part in event.cycle.split("->") if part]
|
||||
if len(currencies) < 2:
|
||||
return {}
|
||||
|
||||
start = currencies[0]
|
||||
exposure_assets = {currency for currency in currencies[1:] if currency != start}
|
||||
return {asset: event.allocated_capital for asset in exposure_assets}
|
||||
|
||||
async def run(self) -> None:
|
||||
async for message in self._ws_client.connect_stream():
|
||||
parse_start = time.perf_counter()
|
||||
delta = self._ws_client.parse_book_delta(message.payload)
|
||||
if delta is None:
|
||||
continue
|
||||
|
||||
book = self._books.setdefault(delta.symbol, OrderBook())
|
||||
book.apply_bids(delta.bids)
|
||||
book.apply_asks(delta.asks)
|
||||
|
||||
checksum_ok = True
|
||||
if delta.checksum is not None:
|
||||
checksum_ok = book.compute_checksum() == delta.checksum
|
||||
|
||||
apply_latency_ms = (time.perf_counter() - parse_start) * 1000
|
||||
source_latency_ms: float | None = None
|
||||
if delta.source_timestamp_ms is not None:
|
||||
source_latency_ms = datetime.now(UTC).timestamp() * 1000 - float(
|
||||
delta.source_timestamp_ms
|
||||
)
|
||||
|
||||
_LOG.info(
|
||||
"book_delta_applied",
|
||||
symbol=delta.symbol,
|
||||
bids=len(delta.bids),
|
||||
asks=len(delta.asks),
|
||||
checksum_ok=checksum_ok,
|
||||
apply_latency_ms=apply_latency_ms,
|
||||
source_latency_ms=source_latency_ms,
|
||||
)
|
||||
|
||||
if self._stop_conditions_guard is not None:
|
||||
self._stop_conditions_guard.observe_latency(
|
||||
source_latency_ms=source_latency_ms,
|
||||
apply_latency_ms=apply_latency_ms,
|
||||
)
|
||||
if self._stop_conditions_guard.is_halted:
|
||||
if self._kill_switch is not None and not self._kill_switch.is_active:
|
||||
self._kill_switch.activate(
|
||||
reason=self._stop_conditions_guard.halted_reason
|
||||
or "stop_conditions_halted",
|
||||
)
|
||||
_LOG.warning(
|
||||
"stop_condition_halt_triggered",
|
||||
reason=self._stop_conditions_guard.halted_reason,
|
||||
symbol=delta.symbol,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.stop_condition_halt",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"reason": self._stop_conditions_guard.halted_reason
|
||||
or "unknown",
|
||||
"symbol": delta.symbol,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
if self._detector is not None:
|
||||
opportunities = self._detector.opportunities_for_updated_pair(
|
||||
delta.symbol,
|
||||
self._books,
|
||||
base_capital=self._effective_trade_capital(),
|
||||
)
|
||||
_LOG.debug(
|
||||
"incremental_opportunity_scores",
|
||||
symbol=delta.symbol,
|
||||
opportunities=len(opportunities),
|
||||
)
|
||||
|
||||
for event in opportunities:
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="detector",
|
||||
event_type="detector.opportunity",
|
||||
decision="scored",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"updated_pair": event.updated_pair,
|
||||
"net_pct": event.net_pct,
|
||||
"est_profit": event.est_profit,
|
||||
},
|
||||
)
|
||||
)
|
||||
_LOG.info(
|
||||
"opportunity_detected",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
gross_pct=event.gross_pct,
|
||||
net_pct=event.net_pct,
|
||||
est_profit=event.est_profit,
|
||||
mode="paper" if self._paper_trading_mode else "live",
|
||||
)
|
||||
|
||||
if self._opportunity_writer is not None:
|
||||
await self._opportunity_writer.enqueue(event)
|
||||
|
||||
if self._paper_trading_mode:
|
||||
_LOG.info(
|
||||
"paper_trade_simulated",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
net_pct=event.net_pct,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.paper_trade",
|
||||
decision="skipped",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"updated_pair": event.updated_pair,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._opportunity_executor is None:
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_no_executor",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.live_trade",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"reason": "missing_executor",
|
||||
"cycle": event.cycle,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._kill_switch is not None and self._kill_switch.is_active:
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_kill_switch",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
reason=self._kill_switch.reason,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.kill_switch",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"reason": self._kill_switch.reason or "manual",
|
||||
"cycle": event.cycle,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
self._stop_conditions_guard is not None
|
||||
and self._stop_conditions_guard.is_halted
|
||||
):
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_stop_condition_halt",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
reason=self._stop_conditions_guard.halted_reason,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.stop_condition",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"reason": self._stop_conditions_guard.halted_reason
|
||||
or "halted",
|
||||
"cycle": event.cycle,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._loss_limit_guard is not None and self._loss_limit_guard.is_halted:
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_loss_limit_halted",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
reason=self._loss_limit_guard.halted_reason,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.loss_limit",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"reason": self._loss_limit_guard.halted_reason or "halted",
|
||||
"cycle": event.cycle,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._pre_trade_validator is not None and self._balance_provider is not None:
|
||||
required_balances = {self._quote_balance_asset: event.allocated_capital}
|
||||
balances = {
|
||||
asset.upper(): amount
|
||||
for asset, amount in self._balance_provider().items()
|
||||
}
|
||||
if not self._pre_trade_validator.validate(
|
||||
balances_by_asset=balances,
|
||||
required_by_asset=required_balances,
|
||||
):
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_pre_trade_validation",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
required_by_asset=required_balances,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.pre_trade_validation",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"required_by_asset": {
|
||||
key: required_balances[key]
|
||||
for key in required_balances
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
exposure_by_asset = self._exposure_for_event(event)
|
||||
if (
|
||||
self._trade_limits_guard is not None
|
||||
and not self._trade_limits_guard.is_trade_allowed(exposure_by_asset)
|
||||
):
|
||||
_LOG.warning(
|
||||
"live_trade_skipped_trade_limits",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
exposure_by_asset=exposure_by_asset,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="risk_manager",
|
||||
event_type="risk.trade_limits",
|
||||
decision="rejected",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"exposure_by_asset": {
|
||||
key: exposure_by_asset[key] for key in exposure_by_asset
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._trade_limits_guard is not None:
|
||||
self._trade_limits_guard.open_trade(exposure_by_asset)
|
||||
|
||||
try:
|
||||
outcome = await self._opportunity_executor(event)
|
||||
except Exception as exc:
|
||||
if self._trade_limits_guard is not None:
|
||||
self._trade_limits_guard.close_trade(exposure_by_asset)
|
||||
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="system",
|
||||
severity="critical",
|
||||
title="Critical execution exception",
|
||||
message="Unhandled exception raised by opportunity executor.",
|
||||
details={
|
||||
"cycle": event.cycle,
|
||||
"updated_pair": event.updated_pair,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
if self._stop_conditions_guard is not None:
|
||||
self._stop_conditions_guard.register_failure()
|
||||
if self._stop_conditions_guard.is_halted:
|
||||
if (
|
||||
self._kill_switch is not None
|
||||
and not self._kill_switch.is_active
|
||||
):
|
||||
self._kill_switch.activate(
|
||||
reason=self._stop_conditions_guard.halted_reason
|
||||
or "stop_conditions_halted",
|
||||
)
|
||||
_LOG.warning(
|
||||
"stop_condition_halt_triggered",
|
||||
reason=self._stop_conditions_guard.halted_reason,
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
)
|
||||
|
||||
_LOG.exception(
|
||||
"live_trade_execution_failed",
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
)
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.live_trade",
|
||||
decision="error",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"updated_pair": event.updated_pair,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if self._stop_conditions_guard is not None:
|
||||
self._stop_conditions_guard.register_success()
|
||||
|
||||
realized_pnl: float | None
|
||||
close_trade = True
|
||||
if isinstance(outcome, ExecutionOutcome):
|
||||
realized_pnl = outcome.realized_pnl
|
||||
close_trade = outcome.close_trade
|
||||
else:
|
||||
realized_pnl = outcome
|
||||
|
||||
if realized_pnl is not None and self._loss_limit_guard is not None:
|
||||
self._loss_limit_guard.register_realized_pnl(realized_pnl)
|
||||
if self._loss_limit_guard.is_halted:
|
||||
_LOG.warning(
|
||||
"loss_limit_halt_triggered",
|
||||
reason=self._loss_limit_guard.halted_reason,
|
||||
cumulative_pnl=self._loss_limit_guard.cumulative_pnl,
|
||||
)
|
||||
|
||||
if self._trade_limits_guard is not None and close_trade:
|
||||
self._trade_limits_guard.close_trade(exposure_by_asset)
|
||||
|
||||
if self._audit_repository is not None:
|
||||
await self._audit_repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="execution_engine",
|
||||
event_type="execution.live_trade",
|
||||
decision="approved",
|
||||
payload={
|
||||
"cycle": event.cycle,
|
||||
"updated_pair": event.updated_pair,
|
||||
"realized_pnl": realized_pnl,
|
||||
"close_trade": close_trade,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await self._snapshot_writer.enqueue(
|
||||
MarketSnapshot(
|
||||
snapshot_at=datetime.now(UTC),
|
||||
symbol=delta.symbol,
|
||||
source="kraken_ws",
|
||||
payload=message.payload,
|
||||
latency_ms=source_latency_ms,
|
||||
)
|
||||
)
|
||||
@@ -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]
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sortedcontainers import SortedDict
|
||||
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
|
||||
ZERO_CLEAN_RE = re.compile(r"^0+", re.ASCII)
|
||||
|
||||
|
||||
def _normalize_price_for_checksum(value: float) -> str:
|
||||
text = f"{value:.10f}".replace(".", "")
|
||||
text = text.rstrip("0")
|
||||
stripped = ZERO_CLEAN_RE.sub("", text)
|
||||
return stripped or "0"
|
||||
|
||||
|
||||
def _normalize_volume_for_checksum(value: float) -> str:
|
||||
text = f"{value:.10f}".replace(".", "")
|
||||
text = text.rstrip("0")
|
||||
stripped = ZERO_CLEAN_RE.sub("", text)
|
||||
return stripped or "0"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BookView:
|
||||
best_bid: BookLevel | None
|
||||
best_ask: BookLevel | None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrderBook:
|
||||
def __init__(self) -> None:
|
||||
self._bids: SortedDict[float, float] = SortedDict()
|
||||
self._asks: SortedDict[float, float] = SortedDict()
|
||||
self._updated_at: datetime = datetime.now(UTC)
|
||||
|
||||
@property
|
||||
def updated_at(self) -> datetime:
|
||||
return self._updated_at
|
||||
|
||||
def apply_bids(self, updates: Iterable[BookLevel]) -> None:
|
||||
for level in updates:
|
||||
if level.volume <= 0:
|
||||
self._bids.pop(level.price, None)
|
||||
else:
|
||||
self._bids[level.price] = level.volume
|
||||
self._updated_at = datetime.now(UTC)
|
||||
|
||||
def apply_asks(self, updates: Iterable[BookLevel]) -> None:
|
||||
for level in updates:
|
||||
if level.volume <= 0:
|
||||
self._asks.pop(level.price, None)
|
||||
else:
|
||||
self._asks[level.price] = level.volume
|
||||
self._updated_at = datetime.now(UTC)
|
||||
|
||||
def best_bid(self) -> BookLevel | None:
|
||||
if not self._bids:
|
||||
return None
|
||||
price = self._bids.peekitem(-1)[0]
|
||||
return BookLevel(price=price, volume=self._bids[price])
|
||||
|
||||
def best_ask(self) -> BookLevel | None:
|
||||
if not self._asks:
|
||||
return None
|
||||
price = self._asks.peekitem(0)[0]
|
||||
return BookLevel(price=price, volume=self._asks[price])
|
||||
|
||||
def snapshot(self) -> BookView:
|
||||
return BookView(
|
||||
best_bid=self.best_bid(),
|
||||
best_ask=self.best_ask(),
|
||||
updated_at=self._updated_at,
|
||||
)
|
||||
|
||||
def top_levels(self, depth: int = 10) -> tuple[list[BookLevel], list[BookLevel]]:
|
||||
bid_keys = list(self._bids.keys())
|
||||
ask_keys = list(self._asks.keys())
|
||||
|
||||
bids = [
|
||||
BookLevel(price=price, volume=self._bids[price])
|
||||
for price in reversed(bid_keys[-depth:])
|
||||
]
|
||||
asks = [BookLevel(price=price, volume=self._asks[price]) for price in ask_keys[:depth]]
|
||||
return bids, asks
|
||||
|
||||
def compute_checksum(self, depth: int = 10) -> int:
|
||||
bids, asks = self.top_levels(depth)
|
||||
combined: list[str] = []
|
||||
for level in bids:
|
||||
combined.append(_normalize_price_for_checksum(level.price))
|
||||
combined.append(_normalize_volume_for_checksum(level.volume))
|
||||
for level in asks:
|
||||
combined.append(_normalize_price_for_checksum(level.price))
|
||||
combined.append(_normalize_volume_for_checksum(level.volume))
|
||||
|
||||
import zlib
|
||||
|
||||
return zlib.crc32("".join(combined).encode("utf-8"))
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from arbitrade.storage.pg_store import PgStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PerformanceMetrics:
|
||||
realized_pnl_usd: float
|
||||
win_rate: float | None
|
||||
avg_trade_duration_seconds: float | None
|
||||
opportunities_per_minute: float | None
|
||||
fill_rate: float | None
|
||||
latency_p50_seconds: float | None
|
||||
latency_p95_seconds: float | None
|
||||
latency_p99_seconds: float | None
|
||||
|
||||
|
||||
class MetricsCalculator:
|
||||
def __init__(self, store: PgStore) -> None:
|
||||
self._store = store
|
||||
|
||||
async def compute(self) -> PerformanceMetrics:
|
||||
async with self._store.pool.acquire() as conn:
|
||||
tm = await conn.fetchrow("""
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
|
||||
COUNT(*) AS total_trades,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
|
||||
AVG(EXTRACT(EPOCH FROM finished_at - started_at)) AS avg_trade_duration_seconds,
|
||||
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p50_seconds,
|
||||
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p95_seconds,
|
||||
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p99_seconds
|
||||
FROM trades
|
||||
WHERE finished_at IS NOT NULL
|
||||
""")
|
||||
|
||||
om = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) AS opportunity_count,
|
||||
MIN(detected_at) AS first_detected_at,
|
||||
MAX(detected_at) AS last_detected_at
|
||||
FROM opportunities
|
||||
""")
|
||||
|
||||
fm = await conn.fetchrow("""
|
||||
SELECT AVG(filled_volume / volume) AS fill_rate
|
||||
FROM orders
|
||||
WHERE volume > 0 AND filled_volume IS NOT NULL
|
||||
""")
|
||||
|
||||
r_pnl_usd = (
|
||||
float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
|
||||
)
|
||||
tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
|
||||
wt = int(tm["winning_trades"]) if tm and tm["winning_trades"] is not None else 0
|
||||
wr = wt / tt if tt > 0 else None
|
||||
|
||||
atd = (
|
||||
float(tm["avg_trade_duration_seconds"])
|
||||
if tm and tm["avg_trade_duration_seconds"] is not None
|
||||
else None
|
||||
)
|
||||
|
||||
oc = (
|
||||
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
|
||||
)
|
||||
|
||||
opportunities_per_minute: float | None
|
||||
if oc >= 2 and fo is not None and lo is not None:
|
||||
span_seconds = (lo - fo).total_seconds()
|
||||
opportunities_per_minute = (
|
||||
oc / (span_seconds / 60.0) if span_seconds > 0.0 else float(oc)
|
||||
)
|
||||
elif oc == 1:
|
||||
opportunities_per_minute = 60.0
|
||||
else:
|
||||
opportunities_per_minute = None
|
||||
|
||||
fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
|
||||
|
||||
lp50 = (
|
||||
float(tm["latency_p50_seconds"])
|
||||
if tm and tm["latency_p50_seconds"] is not None
|
||||
else None
|
||||
)
|
||||
lp95 = (
|
||||
float(tm["latency_p95_seconds"])
|
||||
if tm and tm["latency_p95_seconds"] is not None
|
||||
else None
|
||||
)
|
||||
lp99 = (
|
||||
float(tm["latency_p99_seconds"])
|
||||
if tm and tm["latency_p99_seconds"] is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return PerformanceMetrics(
|
||||
realized_pnl_usd=r_pnl_usd,
|
||||
win_rate=wr,
|
||||
avg_trade_duration_seconds=atd,
|
||||
opportunities_per_minute=opportunities_per_minute,
|
||||
fill_rate=fill_rate,
|
||||
latency_p50_seconds=lp50,
|
||||
latency_p95_seconds=lp95,
|
||||
latency_p99_seconds=lp99,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
from arbitrade.perf.guardrails import evaluate_guardrails
|
||||
from arbitrade.perf.latency import run_latency_profile
|
||||
|
||||
__all__ = ["run_latency_profile", "evaluate_guardrails"]
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def evaluate_guardrails(
|
||||
*,
|
||||
baseline: dict[str, object],
|
||||
current: dict[str, object],
|
||||
thresholds: dict[str, object],
|
||||
) -> list[str]:
|
||||
failures: list[str] = []
|
||||
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
current_scenarios = current.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, dict) or not isinstance(current_scenarios, dict):
|
||||
return ["invalid profile payload: missing scenarios map"]
|
||||
|
||||
default_thresholds = thresholds.get("default")
|
||||
if not isinstance(default_thresholds, dict):
|
||||
default_thresholds = {"p95_ms": 2.5, "p99_ms": 3.0}
|
||||
|
||||
scenario_thresholds = thresholds.get("scenarios")
|
||||
if not isinstance(scenario_thresholds, dict):
|
||||
scenario_thresholds = {}
|
||||
|
||||
for scenario, baseline_payload in baseline_scenarios.items():
|
||||
current_payload = current_scenarios.get(scenario)
|
||||
if not isinstance(baseline_payload, dict) or not isinstance(current_payload, dict):
|
||||
failures.append(f"missing scenario in current profile: {scenario}")
|
||||
continue
|
||||
|
||||
baseline_stages = baseline_payload.get("stages")
|
||||
current_stages = current_payload.get("stages")
|
||||
if not isinstance(baseline_stages, dict) or not isinstance(current_stages, dict):
|
||||
failures.append(f"missing stages map for scenario: {scenario}")
|
||||
continue
|
||||
|
||||
scenario_config = scenario_thresholds.get(scenario)
|
||||
if not isinstance(scenario_config, dict):
|
||||
scenario_config = {}
|
||||
|
||||
for stage, baseline_stage in baseline_stages.items():
|
||||
current_stage = current_stages.get(stage)
|
||||
if not isinstance(baseline_stage, dict) or not isinstance(current_stage, dict):
|
||||
failures.append(f"missing stage in current profile: {scenario}.{stage}")
|
||||
continue
|
||||
|
||||
for percentile_key in ("p95_ms", "p99_ms"):
|
||||
threshold_ratio_raw = scenario_config.get(
|
||||
percentile_key,
|
||||
default_thresholds.get(percentile_key, 3.0),
|
||||
)
|
||||
threshold_ratio = (
|
||||
float(threshold_ratio_raw)
|
||||
if isinstance(threshold_ratio_raw, int | float)
|
||||
else 3.0
|
||||
)
|
||||
|
||||
base_value_raw = baseline_stage.get(percentile_key)
|
||||
current_value_raw = current_stage.get(percentile_key)
|
||||
if not isinstance(base_value_raw, int | float) or not isinstance(
|
||||
current_value_raw, int | float
|
||||
):
|
||||
failures.append(
|
||||
f"invalid percentile value: {scenario}.{stage}.{percentile_key}"
|
||||
)
|
||||
continue
|
||||
|
||||
base_value = float(base_value_raw)
|
||||
current_value = float(current_value_raw)
|
||||
# Avoid divide-by-zero while still preserving strict checks.
|
||||
max_allowed = max(base_value * threshold_ratio, 0.001)
|
||||
if current_value > max_allowed:
|
||||
failures.append(
|
||||
f"latency regression: {scenario}.{stage}.{percentile_key} "
|
||||
f"current={current_value:.4f}ms "
|
||||
f"baseline={base_value:.4f}ms "
|
||||
f"allowed={max_allowed:.4f}ms"
|
||||
)
|
||||
|
||||
return failures
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter_ns
|
||||
|
||||
import orjson
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PercentileSummary:
|
||||
p50_ms: float
|
||||
p95_ms: float
|
||||
p99_ms: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScenarioProfile:
|
||||
scenario: str
|
||||
iterations: int
|
||||
stages: dict[str, PercentileSummary]
|
||||
|
||||
|
||||
def _percentile(samples: list[float], percentile: float) -> float:
|
||||
if not samples:
|
||||
return 0.0
|
||||
ordered = sorted(samples)
|
||||
if percentile <= 0.0:
|
||||
return ordered[0]
|
||||
if percentile >= 100.0:
|
||||
return ordered[-1]
|
||||
rank = (len(ordered) - 1) * (percentile / 100.0)
|
||||
lower = int(rank)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
weight = rank - lower
|
||||
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
||||
|
||||
|
||||
def _summarize(samples: list[float]) -> PercentileSummary:
|
||||
return PercentileSummary(
|
||||
p50_ms=_percentile(samples, 50.0),
|
||||
p95_ms=_percentile(samples, 95.0),
|
||||
p99_ms=_percentile(samples, 99.0),
|
||||
)
|
||||
|
||||
|
||||
def _ingest_stage(raw_payload: bytes, state: dict[str, float]) -> None:
|
||||
parsed = orjson.loads(raw_payload)
|
||||
bids = parsed.get("bids", [])
|
||||
asks = parsed.get("asks", [])
|
||||
for price, volume in bids[:4]:
|
||||
state[str(price)] = float(volume)
|
||||
for price, volume in asks[:4]:
|
||||
state[str(price)] = float(volume)
|
||||
|
||||
|
||||
def _detect_stage(values: list[float], cycles: int) -> float:
|
||||
best = 0.0
|
||||
size = len(values)
|
||||
for idx in range(cycles):
|
||||
a = values[idx % size]
|
||||
b = values[(idx + 3) % size]
|
||||
c = values[(idx + 7) % size]
|
||||
gross = (a / b) * c
|
||||
net = gross * 0.9975
|
||||
if net > best:
|
||||
best = net
|
||||
return best
|
||||
|
||||
|
||||
def _risk_stage(net_edge: float, capital: float) -> float:
|
||||
if net_edge < 1.0002:
|
||||
return 0.0
|
||||
if capital > 500.0:
|
||||
capital = 500.0
|
||||
return capital * min(net_edge - 1.0, 0.02)
|
||||
|
||||
|
||||
def _execution_stage(planned_pnl: float, order_id: int) -> None:
|
||||
payload = {
|
||||
"order_id": order_id,
|
||||
"planned_pnl": planned_pnl,
|
||||
"legs": [
|
||||
{"pair": "BTC/USD", "side": "buy", "qty": 0.01},
|
||||
{"pair": "ETH/BTC", "side": "buy", "qty": 0.1},
|
||||
{"pair": "ETH/USD", "side": "sell", "qty": 0.1},
|
||||
],
|
||||
}
|
||||
_ = orjson.dumps(payload)
|
||||
|
||||
|
||||
def _run_scenario(
|
||||
name: str,
|
||||
iterations: int,
|
||||
detect_cycles: int,
|
||||
reconnect_every: int,
|
||||
) -> ScenarioProfile:
|
||||
payloads = [
|
||||
orjson.dumps(
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
"bids": [[100000.0 + i, 0.2 + (i % 5) * 0.01] for i in range(12)],
|
||||
"asks": [[100001.0 + i, 0.2 + (i % 7) * 0.01] for i in range(12)],
|
||||
}
|
||||
)
|
||||
for _ in range(5)
|
||||
]
|
||||
value_series = [1.0 + (idx % 31) * 0.0007 for idx in range(128)]
|
||||
order_state: dict[str, float] = {}
|
||||
|
||||
ingest_ms: list[float] = []
|
||||
detect_ms: list[float] = []
|
||||
risk_ms: list[float] = []
|
||||
execution_ms: list[float] = []
|
||||
end_to_end_ms: list[float] = []
|
||||
|
||||
for idx in range(iterations):
|
||||
start_ns = perf_counter_ns()
|
||||
payload = payloads[idx % len(payloads)]
|
||||
|
||||
t0 = perf_counter_ns()
|
||||
_ingest_stage(payload, order_state)
|
||||
if reconnect_every > 0 and idx > 0 and idx % reconnect_every == 0:
|
||||
order_state.clear()
|
||||
t1 = perf_counter_ns()
|
||||
|
||||
net_edge = _detect_stage(value_series, detect_cycles)
|
||||
t2 = perf_counter_ns()
|
||||
|
||||
planned = _risk_stage(net_edge, capital=100.0 + (idx % 50))
|
||||
t3 = perf_counter_ns()
|
||||
|
||||
_execution_stage(planned, order_id=idx)
|
||||
t4 = perf_counter_ns()
|
||||
|
||||
ingest_ms.append((t1 - t0) / 1_000_000.0)
|
||||
detect_ms.append((t2 - t1) / 1_000_000.0)
|
||||
risk_ms.append((t3 - t2) / 1_000_000.0)
|
||||
execution_ms.append((t4 - t3) / 1_000_000.0)
|
||||
end_to_end_ms.append((t4 - start_ns) / 1_000_000.0)
|
||||
|
||||
return ScenarioProfile(
|
||||
scenario=name,
|
||||
iterations=iterations,
|
||||
stages={
|
||||
"ingest": _summarize(ingest_ms),
|
||||
"detect": _summarize(detect_ms),
|
||||
"risk": _summarize(risk_ms),
|
||||
"execution": _summarize(execution_ms),
|
||||
"end_to_end": _summarize(end_to_end_ms),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_latency_profile(iterations: int = 600) -> dict[str, object]:
|
||||
scenarios: list[Callable[[], ScenarioProfile]] = [
|
||||
lambda: _run_scenario(
|
||||
name="book_update_burst",
|
||||
iterations=iterations,
|
||||
detect_cycles=32,
|
||||
reconnect_every=0,
|
||||
),
|
||||
lambda: _run_scenario(
|
||||
name="execution_spike",
|
||||
iterations=iterations,
|
||||
detect_cycles=96,
|
||||
reconnect_every=0,
|
||||
),
|
||||
lambda: _run_scenario(
|
||||
name="reconnect_storm",
|
||||
iterations=iterations,
|
||||
detect_cycles=48,
|
||||
reconnect_every=20,
|
||||
),
|
||||
]
|
||||
|
||||
result: dict[str, object] = {"iterations": iterations, "scenarios": {}}
|
||||
scenario_map = result["scenarios"]
|
||||
assert isinstance(scenario_map, dict)
|
||||
|
||||
for scenario in scenarios:
|
||||
profile = scenario()
|
||||
scenario_map[profile.scenario] = {
|
||||
"iterations": profile.iterations,
|
||||
"stages": {
|
||||
stage: {
|
||||
"p50_ms": summary.p50_ms,
|
||||
"p95_ms": summary.p95_ms,
|
||||
"p99_ms": summary.p99_ms,
|
||||
}
|
||||
for stage, summary in profile.stages.items()
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Risk management helpers."""
|
||||
|
||||
from arbitrade.risk.kill_switch import KillSwitch
|
||||
from arbitrade.risk.loss_limits import LossLimitGuard
|
||||
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||
from arbitrade.risk.stop_conditions import StopConditionsGuard
|
||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||
|
||||
__all__ = [
|
||||
"LossLimitGuard",
|
||||
"TradeLimitsGuard",
|
||||
"PreTradeValidator",
|
||||
"KillSwitch",
|
||||
"StopConditionsGuard",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class KillSwitch:
|
||||
def __init__(self, *, active: bool = False, reason: str | None = None) -> None:
|
||||
self._active = active
|
||||
self._reason = reason or ("manual" if active else None)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
@property
|
||||
def reason(self) -> str | None:
|
||||
return self._reason
|
||||
|
||||
def activate(self, *, reason: str = "manual") -> None:
|
||||
self._active = True
|
||||
self._reason = reason
|
||||
|
||||
def deactivate(self) -> None:
|
||||
self._active = False
|
||||
self._reason = None
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
|
||||
|
||||
|
||||
class LossLimitGuard:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
daily_loss_limit: float | None = None,
|
||||
cumulative_loss_limit: float | None = None,
|
||||
alert_notifier: SupportsAlerts | None = None,
|
||||
) -> None:
|
||||
self._daily_loss_limit = daily_loss_limit
|
||||
self._cumulative_loss_limit = cumulative_loss_limit
|
||||
|
||||
if self._daily_loss_limit is not None and self._daily_loss_limit <= 0.0:
|
||||
raise ValueError("daily_loss_limit must be > 0.0")
|
||||
if self._cumulative_loss_limit is not None and self._cumulative_loss_limit <= 0.0:
|
||||
raise ValueError("cumulative_loss_limit must be > 0.0")
|
||||
|
||||
self._cumulative_pnl = 0.0
|
||||
self._daily_pnl: dict[date, float] = {}
|
||||
self._halted_reason: str | None = None
|
||||
self._alert_notifier = alert_notifier
|
||||
|
||||
@property
|
||||
def cumulative_pnl(self) -> float:
|
||||
return self._cumulative_pnl
|
||||
|
||||
@property
|
||||
def halted_reason(self) -> str | None:
|
||||
return self._halted_reason
|
||||
|
||||
@property
|
||||
def is_halted(self) -> bool:
|
||||
return self._halted_reason is not None
|
||||
|
||||
def daily_pnl(self, day: date) -> float:
|
||||
return self._daily_pnl.get(day, 0.0)
|
||||
|
||||
def register_realized_pnl(self, pnl: float, *, at: datetime | None = None) -> None:
|
||||
if self.is_halted:
|
||||
return
|
||||
|
||||
timestamp = at or datetime.now(UTC)
|
||||
day_key = timestamp.date()
|
||||
|
||||
self._cumulative_pnl += pnl
|
||||
self._daily_pnl[day_key] = self._daily_pnl.get(day_key, 0.0) + pnl
|
||||
|
||||
if (
|
||||
self._daily_loss_limit is not None
|
||||
and self._daily_pnl[day_key] <= -self._daily_loss_limit
|
||||
):
|
||||
self._halted_reason = "daily_loss_limit_breached"
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="critical",
|
||||
title="Daily loss limit breached",
|
||||
message="Trading halted because daily realized PnL crossed configured loss limit.",
|
||||
details={
|
||||
"daily_pnl": f"{self._daily_pnl[day_key]}",
|
||||
"daily_loss_limit": f"{self._daily_loss_limit}",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
self._cumulative_loss_limit is not None
|
||||
and self._cumulative_pnl <= -self._cumulative_loss_limit
|
||||
):
|
||||
self._halted_reason = "cumulative_loss_limit_breached"
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="critical",
|
||||
title="Cumulative loss limit breached",
|
||||
message=(
|
||||
"Trading halted because cumulative realized PnL crossed "
|
||||
"configured loss limit."
|
||||
),
|
||||
details={
|
||||
"cumulative_pnl": f"{self._cumulative_pnl}",
|
||||
"cumulative_loss_limit": f"{self._cumulative_loss_limit}",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
class PreTradeValidator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
min_order_size_by_asset: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
self._min_order_size_by_asset = {
|
||||
asset.upper(): float(value) for asset, value in (min_order_size_by_asset or {}).items()
|
||||
}
|
||||
|
||||
for value in self._min_order_size_by_asset.values():
|
||||
if value <= 0.0:
|
||||
raise ValueError("minimum order size must be > 0.0")
|
||||
|
||||
def validate(
|
||||
self,
|
||||
*,
|
||||
balances_by_asset: Mapping[str, float],
|
||||
required_by_asset: Mapping[str, float],
|
||||
) -> bool:
|
||||
# Minimum order size checks first to fail fast on structural invalid sizes.
|
||||
for asset, required in required_by_asset.items():
|
||||
if required <= 0.0:
|
||||
continue
|
||||
|
||||
min_size = self._min_order_size_by_asset.get(asset.upper())
|
||||
if min_size is not None and required < min_size:
|
||||
return False
|
||||
|
||||
# Balance checks ensure required quantity is currently available.
|
||||
for asset, required in required_by_asset.items():
|
||||
if required <= 0.0:
|
||||
continue
|
||||
available = balances_by_asset.get(asset.upper(), 0.0)
|
||||
if available < required:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
|
||||
|
||||
|
||||
class StopConditionsGuard:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_source_latency_ms: float | None = None,
|
||||
max_apply_latency_ms: float | None = None,
|
||||
max_consecutive_failures: int | None = None,
|
||||
alert_notifier: SupportsAlerts | None = None,
|
||||
) -> None:
|
||||
if max_source_latency_ms is not None and max_source_latency_ms <= 0.0:
|
||||
raise ValueError("max_source_latency_ms must be > 0.0")
|
||||
if max_apply_latency_ms is not None and max_apply_latency_ms <= 0.0:
|
||||
raise ValueError("max_apply_latency_ms must be > 0.0")
|
||||
if max_consecutive_failures is not None and max_consecutive_failures <= 0:
|
||||
raise ValueError("max_consecutive_failures must be > 0")
|
||||
|
||||
self._max_source_latency_ms = max_source_latency_ms
|
||||
self._max_apply_latency_ms = max_apply_latency_ms
|
||||
self._max_consecutive_failures = max_consecutive_failures
|
||||
|
||||
self._consecutive_failures = 0
|
||||
self._halted_reason: str | None = None
|
||||
self._alert_notifier = alert_notifier
|
||||
|
||||
@property
|
||||
def halted_reason(self) -> str | None:
|
||||
return self._halted_reason
|
||||
|
||||
@property
|
||||
def is_halted(self) -> bool:
|
||||
return self._halted_reason is not None
|
||||
|
||||
@property
|
||||
def consecutive_failures(self) -> int:
|
||||
return self._consecutive_failures
|
||||
|
||||
def observe_latency(
|
||||
self,
|
||||
*,
|
||||
source_latency_ms: float | None,
|
||||
apply_latency_ms: float,
|
||||
) -> None:
|
||||
if self.is_halted:
|
||||
return
|
||||
|
||||
if (
|
||||
self._max_source_latency_ms is not None
|
||||
and source_latency_ms is not None
|
||||
and source_latency_ms > self._max_source_latency_ms
|
||||
):
|
||||
self._halted_reason = "source_latency_limit_breached"
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="critical",
|
||||
title="Source latency limit breached",
|
||||
message="Trading halted because source latency exceeded configured limit.",
|
||||
details={
|
||||
"source_latency_ms": f"{source_latency_ms}",
|
||||
"max_source_latency_ms": f"{self._max_source_latency_ms}",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if self._max_apply_latency_ms is not None and apply_latency_ms > self._max_apply_latency_ms:
|
||||
self._halted_reason = "apply_latency_limit_breached"
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="critical",
|
||||
title="Apply latency limit breached",
|
||||
message="Trading halted because apply latency exceeded configured limit.",
|
||||
details={
|
||||
"apply_latency_ms": f"{apply_latency_ms}",
|
||||
"max_apply_latency_ms": f"{self._max_apply_latency_ms}",
|
||||
},
|
||||
)
|
||||
|
||||
def register_failure(self) -> None:
|
||||
if self.is_halted:
|
||||
return
|
||||
|
||||
self._consecutive_failures += 1
|
||||
if (
|
||||
self._max_consecutive_failures is not None
|
||||
and self._consecutive_failures >= self._max_consecutive_failures
|
||||
):
|
||||
self._halted_reason = "consecutive_failures_limit_breached"
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="critical",
|
||||
title="Consecutive failures limit breached",
|
||||
message="Trading halted because consecutive failures exceeded configured limit.",
|
||||
details={
|
||||
"consecutive_failures": f"{self._consecutive_failures}",
|
||||
"max_consecutive_failures": f"{self._max_consecutive_failures}",
|
||||
},
|
||||
)
|
||||
|
||||
def register_success(self) -> None:
|
||||
if self.is_halted:
|
||||
return
|
||||
self._consecutive_failures = 0
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
|
||||
|
||||
|
||||
class TradeLimitsGuard:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_concurrent_trades: int | None = None,
|
||||
max_exposure_per_asset: float | None = None,
|
||||
alert_notifier: SupportsAlerts | None = None,
|
||||
) -> None:
|
||||
if max_concurrent_trades is not None and max_concurrent_trades <= 0:
|
||||
raise ValueError("max_concurrent_trades must be > 0")
|
||||
if max_exposure_per_asset is not None and max_exposure_per_asset <= 0.0:
|
||||
raise ValueError("max_exposure_per_asset must be > 0.0")
|
||||
|
||||
self._max_concurrent_trades = max_concurrent_trades
|
||||
self._max_exposure_per_asset = max_exposure_per_asset
|
||||
self._active_trades = 0
|
||||
self._asset_exposure: dict[str, float] = {}
|
||||
self._alert_notifier = alert_notifier
|
||||
|
||||
@property
|
||||
def active_trades(self) -> int:
|
||||
return self._active_trades
|
||||
|
||||
def exposure_for_asset(self, asset: str) -> float:
|
||||
return self._asset_exposure.get(asset.upper(), 0.0)
|
||||
|
||||
def is_trade_allowed(self, exposure_by_asset: Mapping[str, float]) -> bool:
|
||||
if (
|
||||
self._max_concurrent_trades is not None
|
||||
and self._active_trades >= self._max_concurrent_trades
|
||||
):
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="warning",
|
||||
title="Concurrent trade limit reached",
|
||||
message="Trade rejected by concurrent trade cap.",
|
||||
details={
|
||||
"active_trades": f"{self._active_trades}",
|
||||
"max_concurrent_trades": f"{self._max_concurrent_trades}",
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
if self._max_exposure_per_asset is None:
|
||||
return True
|
||||
|
||||
for asset, exposure in exposure_by_asset.items():
|
||||
if exposure <= 0.0:
|
||||
continue
|
||||
key = asset.upper()
|
||||
next_exposure = self._asset_exposure.get(key, 0.0) + exposure
|
||||
if next_exposure > self._max_exposure_per_asset:
|
||||
dispatch_alert_nowait(
|
||||
self._alert_notifier,
|
||||
category="threshold",
|
||||
severity="warning",
|
||||
title="Asset exposure limit reached",
|
||||
message="Trade rejected by per-asset exposure cap.",
|
||||
details={
|
||||
"asset": key,
|
||||
"next_exposure": f"{next_exposure}",
|
||||
"max_exposure_per_asset": f"{self._max_exposure_per_asset}",
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def open_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
|
||||
self._active_trades += 1
|
||||
for asset, exposure in exposure_by_asset.items():
|
||||
if exposure <= 0.0:
|
||||
continue
|
||||
key = asset.upper()
|
||||
self._asset_exposure[key] = self._asset_exposure.get(key, 0.0) + exposure
|
||||
|
||||
def close_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
|
||||
if self._active_trades > 0:
|
||||
self._active_trades -= 1
|
||||
|
||||
for asset, exposure in exposure_by_asset.items():
|
||||
if exposure <= 0.0:
|
||||
continue
|
||||
key = asset.upper()
|
||||
current = self._asset_exposure.get(key, 0.0)
|
||||
next_exposure = max(current - exposure, 0.0)
|
||||
if next_exposure == 0.0:
|
||||
self._asset_exposure.pop(key, None)
|
||||
else:
|
||||
self._asset_exposure[key] = next_exposure
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Runtime lifecycle and recovery helpers."""
|
||||
|
||||
from arbitrade.runtime.lifecycle import (
|
||||
RuntimeRecoveryReport,
|
||||
graceful_shutdown,
|
||||
persist_runtime_snapshot,
|
||||
restore_runtime_state,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RuntimeRecoveryReport",
|
||||
"graceful_shutdown",
|
||||
"persist_runtime_snapshot",
|
||||
"restore_runtime_state",
|
||||
]
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
from arbitrade.storage.pg_store import PgStore
|
||||
from arbitrade.storage.repositories import (
|
||||
AuditRecord,
|
||||
AuditRepository,
|
||||
RuntimeStateRecord,
|
||||
RuntimeStateRepository,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeRecoveryReport:
|
||||
restored_from_snapshot: bool
|
||||
snapshot_at: str | None
|
||||
open_trades_detected: int
|
||||
restart_guard_active: bool
|
||||
|
||||
|
||||
def _controls(app: FastAPI) -> DashboardControlState:
|
||||
return cast(DashboardControlState, app.state.dashboard_controls)
|
||||
|
||||
|
||||
def _store(app: FastAPI) -> PgStore:
|
||||
return cast(PgStore, app.state.store)
|
||||
|
||||
|
||||
def _audit_repository(app: FastAPI) -> AuditRepository | None:
|
||||
repository = getattr(app.state, "audit_repository", None)
|
||||
return repository if isinstance(repository, AuditRepository) else None
|
||||
|
||||
|
||||
def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
|
||||
repository = getattr(app.state, "runtime_state_repository", None)
|
||||
return repository if isinstance(repository, RuntimeStateRepository) else None
|
||||
|
||||
|
||||
async def _open_trade_count(store: PgStore) -> int:
|
||||
async with store.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT COUNT(*)
|
||||
FROM trades
|
||||
WHERE finished_at IS NULL
|
||||
""")
|
||||
return int(row[0]) if row is not None else 0
|
||||
|
||||
|
||||
async def _latest_balances(store: PgStore) -> dict[str, Any] | None:
|
||||
async with store.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT balances
|
||||
FROM portfolio_snapshots
|
||||
ORDER BY snapshot_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
if row is None or row["balances"] is None:
|
||||
return None
|
||||
raw_balances = row["balances"]
|
||||
if isinstance(raw_balances, str):
|
||||
return {"raw": raw_balances}
|
||||
return {"raw": str(raw_balances)}
|
||||
|
||||
|
||||
async def _record_audit(
|
||||
app: FastAPI,
|
||||
*,
|
||||
event_type: str,
|
||||
decision: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
repository = _audit_repository(app)
|
||||
if repository is None:
|
||||
return
|
||||
await repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="runtime",
|
||||
event_type=event_type,
|
||||
decision=decision,
|
||||
payload=payload,
|
||||
correlation_id=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _run_startup_reconciler(app: FastAPI) -> None:
|
||||
reconciler = getattr(app.state, "startup_reconciler", None)
|
||||
if reconciler is None:
|
||||
return
|
||||
|
||||
reconcile_member = getattr(reconciler, "reconcile_open_trades", None)
|
||||
if reconcile_member is None or not callable(reconcile_member):
|
||||
return
|
||||
|
||||
result = reconcile_member()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
async def persist_runtime_snapshot(
|
||||
app: FastAPI, *, note: str | None = None
|
||||
) -> RuntimeStateRecord | None:
|
||||
repository = _runtime_repository(app)
|
||||
if repository is None:
|
||||
return None
|
||||
|
||||
controls = _controls(app)
|
||||
store = _store(app)
|
||||
snapshot = RuntimeStateRecord(
|
||||
snapshot_at=datetime.now(UTC),
|
||||
is_running=controls.is_running,
|
||||
kill_switch_active=controls.kill_switch.is_active,
|
||||
kill_switch_reason=controls.kill_switch.reason,
|
||||
open_trade_count=await _open_trade_count(store),
|
||||
last_known_balances=await _latest_balances(store),
|
||||
note=note,
|
||||
)
|
||||
await repository.insert(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
|
||||
ctl = _controls(app)
|
||||
store = _store(app)
|
||||
repo = _runtime_repository(app)
|
||||
|
||||
restored_from_snapshot = False
|
||||
snapshot_at: str | None = None
|
||||
|
||||
latest = await repo.latest() if repo is not None else None
|
||||
if latest is not None:
|
||||
restored_from_snapshot = True
|
||||
snapshot_at = latest.snapshot_at.isoformat()
|
||||
ctl.is_running = latest.is_running
|
||||
if latest.kill_switch_active:
|
||||
r = latest.kill_switch_reason or "recovered"
|
||||
ctl.kill_switch.activate(reason=r)
|
||||
else:
|
||||
ctl.kill_switch.deactivate()
|
||||
ctl.mark_updated()
|
||||
|
||||
open_trades = await _open_trade_count(store)
|
||||
restart_guard_active = False
|
||||
if open_trades > 0:
|
||||
ctl.is_running = False
|
||||
if not ctl.kill_switch.is_active:
|
||||
ctl.kill_switch.activate(reason="recovery_open_trades_detected")
|
||||
ctl.mark_updated()
|
||||
restart_guard_active = True
|
||||
|
||||
report = RuntimeRecoveryReport(
|
||||
restored_from_snapshot=restored_from_snapshot,
|
||||
snapshot_at=snapshot_at,
|
||||
open_trades_detected=open_trades,
|
||||
restart_guard_active=restart_guard_active,
|
||||
)
|
||||
app.state.recovery_report = report
|
||||
|
||||
await _record_audit(
|
||||
app,
|
||||
event_type="runtime.startup_recovery",
|
||||
decision="applied",
|
||||
payload={
|
||||
"restored_from_snapshot": restored_from_snapshot,
|
||||
"open_trades_detected": open_trades,
|
||||
"restart_guard_active": restart_guard_active,
|
||||
},
|
||||
)
|
||||
|
||||
await _run_startup_reconciler(app)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
async def drain_background_workers(app: FastAPI) -> None:
|
||||
workers: list[object] = []
|
||||
|
||||
declared = getattr(app.state, "background_workers", None)
|
||||
if isinstance(declared, list):
|
||||
workers.extend(declared)
|
||||
|
||||
for attr_name in ("execution_writer", "opportunity_writer", "snapshot_writer"):
|
||||
worker = getattr(app.state, attr_name, None)
|
||||
if worker is not None:
|
||||
workers.append(worker)
|
||||
|
||||
seen: set[int] = set()
|
||||
for worker in workers:
|
||||
worker_id = id(worker)
|
||||
if worker_id in seen:
|
||||
continue
|
||||
seen.add(worker_id)
|
||||
|
||||
stop_member = getattr(worker, "stop", None)
|
||||
if stop_member is None or not callable(stop_member):
|
||||
continue
|
||||
|
||||
result = stop_member()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
async def graceful_shutdown(app: FastAPI) -> None:
|
||||
controls = _controls(app)
|
||||
controls.is_running = False
|
||||
controls.mark_updated()
|
||||
|
||||
await _record_audit(
|
||||
app,
|
||||
event_type="runtime.shutdown",
|
||||
decision="initiated",
|
||||
payload={"execution_status": "stopped"},
|
||||
)
|
||||
|
||||
await drain_background_workers(app)
|
||||
await persist_runtime_snapshot(app, note="graceful_shutdown")
|
||||
@@ -0,0 +1 @@
|
||||
"""Storage helpers."""
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import duckdb
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
|
||||
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(),
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP,
|
||||
status VARCHAR NOT NULL,
|
||||
realized_pnl DOUBLE,
|
||||
capital_used DOUBLE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
||||
snapshot_at TIMESTAMP NOT NULL,
|
||||
balances JSON,
|
||||
total_value_usd DOUBLE
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[duckdb.DuckDBPyConnection]:
|
||||
conn = duckdb.connect(str(self._db_path))
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def migrate(self) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(SCHEMA_SQL)
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.storage.repositories import (
|
||||
OrderRecord,
|
||||
OrderRepository,
|
||||
PnLRecord,
|
||||
PnLRepository,
|
||||
TradeRecord,
|
||||
TradeRepository,
|
||||
)
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AsyncExecutionWriter:
|
||||
def __init__(
|
||||
self,
|
||||
trade_repository: TradeRepository,
|
||||
order_repository: OrderRepository,
|
||||
pnl_repository: PnLRepository,
|
||||
max_queue_size: int = 50_000,
|
||||
) -> None:
|
||||
self._trade_repository = trade_repository
|
||||
self._order_repository = order_repository
|
||||
self._pnl_repository = pnl_repository
|
||||
self._queue: asyncio.Queue[TradeRecord | OrderRecord | PnLRecord] = asyncio.Queue(
|
||||
maxsize=max_queue_size
|
||||
)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run(), name="execution-writer")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task is not None:
|
||||
await self._task
|
||||
|
||||
async def enqueue(self, record: TradeRecord | OrderRecord | PnLRecord) -> None:
|
||||
await self._queue.put(record)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not (self._stop.is_set() and self._queue.empty()):
|
||||
try:
|
||||
record = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
try:
|
||||
if isinstance(record, TradeRecord):
|
||||
await self._trade_repository.insert(record)
|
||||
elif isinstance(record, OrderRecord):
|
||||
await self._order_repository.insert(record)
|
||||
else:
|
||||
await self._pnl_repository.insert(record)
|
||||
except Exception as exc:
|
||||
_LOG.error("execution_write_failed", error=str(exc))
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.storage.repositories import MarketSnapshotRecord, MarketSnapshotRepository
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MarketSnapshot:
|
||||
snapshot_at: datetime
|
||||
symbol: str
|
||||
source: str
|
||||
payload: dict[str, Any]
|
||||
latency_ms: float | None
|
||||
|
||||
|
||||
class AsyncMarketSnapshotWriter:
|
||||
def __init__(self, repository: MarketSnapshotRepository, max_queue_size: int = 50_000) -> None:
|
||||
self._repository = repository
|
||||
self._queue: asyncio.Queue[MarketSnapshot] = asyncio.Queue(maxsize=max_queue_size)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run(), name="market-snapshot-writer")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task is not None:
|
||||
await self._task
|
||||
|
||||
async def enqueue(self, snapshot: MarketSnapshot) -> None:
|
||||
await self._queue.put(snapshot)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not (self._stop.is_set() and self._queue.empty()):
|
||||
try:
|
||||
item = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._repository.insert(
|
||||
MarketSnapshotRecord(
|
||||
snapshot_at=item.snapshot_at,
|
||||
symbol=item.symbol,
|
||||
source=item.source,
|
||||
payload=item.payload,
|
||||
latency_ms=item.latency_ms,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOG.error("market_snapshot_write_failed", error=str(exc), symbol=item.symbol)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.storage.repositories import OpportunityRecord, OpportunityRepository
|
||||
|
||||
_LOG = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AsyncOpportunityWriter:
|
||||
def __init__(self, repository: OpportunityRepository, max_queue_size: int = 50_000) -> None:
|
||||
self._repository = repository
|
||||
self._queue: asyncio.Queue[OpportunityEvent] = asyncio.Queue(maxsize=max_queue_size)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run(), name="opportunity-writer")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task is not None:
|
||||
await self._task
|
||||
|
||||
async def enqueue(self, event: OpportunityEvent) -> None:
|
||||
await self._queue.put(event)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not (self._stop.is_set() and self._queue.empty()):
|
||||
try:
|
||||
event = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._repository.insert(
|
||||
OpportunityRecord(
|
||||
detected_at=event.detected_at,
|
||||
cycle=event.cycle,
|
||||
gross_pct=event.gross_pct,
|
||||
net_pct=event.net_pct,
|
||||
est_profit=event.est_profit,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOG.error(
|
||||
"opportunity_write_failed",
|
||||
error=str(exc),
|
||||
cycle=event.cycle,
|
||||
updated_pair=event.updated_pair,
|
||||
)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
@@ -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
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Experimental strategy modules."""
|
||||
|
||||
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig, StatArbSignal
|
||||
|
||||
__all__ = ["StatArbExperiment", "StatArbExperimentConfig", "StatArbSignal"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}{{ title or "Arbitrade" }}{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
{% block head_scripts %}{% endblock %}
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #0b1220;
|
||||
color: #e5eefb;
|
||||
}
|
||||
.shell {
|
||||
max-width: none;
|
||||
padding: 24px 32px 48px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #9fb2d0;
|
||||
}
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.label {
|
||||
color: #9fb2d0;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 18px;
|
||||
color: #7f95b7;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar form {
|
||||
margin: 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: #2d6cdf;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
}
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.button.danger {
|
||||
background: #ba3d4f;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #9fb2d0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e5eefb;
|
||||
font: inherit;
|
||||
}
|
||||
.field.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.field.checkbox input {
|
||||
width: auto;
|
||||
}
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
{% block extra_style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<main class="{% block main_class %}shell{% endblock %}">
|
||||
{% block header %}{% endblock %} {% block content %}{% endblock %}
|
||||
</main>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
|
||||
head_scripts %}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
{% endblock %} {% block header %} {% with page_title="Arbitrade Dashboard",
|
||||
page_subtitle="Live execution, P&L, and system state." %} {% include
|
||||
"_header.html" %} {% endwith %} {% endblock %} {% block main_class %}shell{%
|
||||
endblock %} {% block content %}
|
||||
|
||||
<section
|
||||
id="metrics-shell"
|
||||
hx-get="{{ metrics_endpoint }}"
|
||||
hx-target="this"
|
||||
hx-trigger="load, every 15s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% include "partials/metrics.html" %}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="overview-shell"
|
||||
hx-get="{{ overview_endpoint }}"
|
||||
hx-target="this"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% include "partials/overview.html" %}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="controls-shell"
|
||||
hx-get="{{ controls_endpoint }}"
|
||||
hx-target="this"
|
||||
hx-trigger="load, every 20s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% include "partials/controls.html" %}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="charts-shell"
|
||||
hx-get="{{ charts_endpoint }}"
|
||||
hx-target="this"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% include "partials/charts.html" %}
|
||||
</section>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
window.arbitradeRenderCharts = (payload) => {
|
||||
const chartHost = document.getElementById("opportunity-chart");
|
||||
if (!chartHost || typeof Chart === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = Chart.getChart(chartHost);
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
}
|
||||
|
||||
const data = JSON.parse(payload);
|
||||
if (!data.has_chart_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Chart(chartHost, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Net %",
|
||||
data: data.net_pct_values,
|
||||
borderColor: "#2d6cdf",
|
||||
backgroundColor: "rgba(45, 108, 223, 0.18)",
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "Est profit USD",
|
||||
data: data.est_profit_values,
|
||||
borderColor: "#52c41a",
|
||||
backgroundColor: "rgba(82, 196, 26, 0.12)",
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#e5eefb",
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
},
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: "left",
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
},
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
position: "right",
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const stream = new EventSource("{{ stream_endpoint }}");
|
||||
stream.addEventListener("metrics", (event) => {
|
||||
const panel = document.getElementById("metrics-panel");
|
||||
if (panel) {
|
||||
panel.outerHTML = JSON.parse(event.data);
|
||||
}
|
||||
});
|
||||
const overviewStream = new EventSource("{{ overview_stream_endpoint }}");
|
||||
overviewStream.addEventListener("overview", (event) => {
|
||||
const panel = document.getElementById("overview-panel");
|
||||
if (panel) {
|
||||
panel.outerHTML = JSON.parse(event.data);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user