Compare commits
61 Commits
7c86e838fa
..
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 |
@@ -5,7 +5,9 @@ __pycache__
|
|||||||
.pytest_cache
|
.pytest_cache
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
|
build
|
||||||
data
|
data
|
||||||
|
dist
|
||||||
logs
|
logs
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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,6 +1,11 @@
|
|||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
|
PG_HOST=192.168.88.35
|
||||||
|
PG_PORT=5432
|
||||||
|
PG_DATABASE=arbitrade
|
||||||
|
PG_USER=arbitrade
|
||||||
|
PG_PASSWORD=arbitrade
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_JSON=true
|
LOG_JSON=true
|
||||||
ALERTS_ENABLED=true
|
ALERTS_ENABLED=true
|
||||||
@@ -49,3 +54,8 @@ CUMULATIVE_LOSS_LIMIT_USD=10.0
|
|||||||
MAX_SOURCE_LATENCY_MS=
|
MAX_SOURCE_LATENCY_MS=
|
||||||
MAX_APPLY_LATENCY_MS=
|
MAX_APPLY_LATENCY_MS=
|
||||||
MAX_CONSECUTIVE_FAILURES=
|
MAX_CONSECUTIVE_FAILURES=
|
||||||
|
STRATEGY_ENABLE_STAT_ARB_EXPERIMENT=false
|
||||||
|
STRATEGY_STAT_ARB_LOOKBACK_WINDOW=120
|
||||||
|
STRATEGY_STAT_ARB_ENTRY_ZSCORE=2.0
|
||||||
|
STRATEGY_STAT_ARB_EXIT_ZSCORE=0.5
|
||||||
|
STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS=900.0
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ jobs:
|
|||||||
- name: Ruff
|
- name: Ruff
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
- name: Black
|
# - name: Black
|
||||||
run: black --check .
|
# run: black --check .
|
||||||
|
|
||||||
- name: MyPy
|
- name: MyPy
|
||||||
run: mypy src
|
run: mypy src
|
||||||
|
|
||||||
- name: Dependency audit
|
- name: Dependency audit
|
||||||
run: pip-audit --skip-editable
|
run: pip-audit -r requirements/latest-runtime.in
|
||||||
|
|
||||||
- name: Secret scan (worktree + git history)
|
- name: Secret scan (worktree + git history)
|
||||||
run: python scripts/security_scan.py
|
run: python scripts/security_scan.py
|
||||||
@@ -64,4 +64,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: git.allucanget.biz/${{ secrets.REGISTRY_NAMESPACE }}/arbitrade:${{ github.sha }}
|
tags: git.allucanget.biz/allucanget/arbitrade:latest
|
||||||
|
|||||||
+5
-1
@@ -31,10 +31,14 @@ Thumbs.db
|
|||||||
!.env.example
|
!.env.example
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
|
# Local build artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
# Local database / runtime data
|
# Local database / runtime data
|
||||||
data/*.duckdb
|
|
||||||
data/*.duckdb.wal
|
data/*.duckdb.wal
|
||||||
data/*.duckdb.tmp
|
data/*.duckdb.tmp
|
||||||
|
data/arbitrade.duckdb
|
||||||
logs/
|
logs/
|
||||||
ops/performance/latest_profile.json
|
ops/performance/latest_profile.json
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
|
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
|
||||||
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
|
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
|
||||||
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
|
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
|
||||||
|
- Added backtesting parameter sweep support (`scripts/backtest_sweep.py`) for theta, trade-capital, pair-universe, and staleness-threshold grid search.
|
||||||
|
- Added persisted sweep artifacts with ranked in-sample/out-of-sample results and promotion-ready candidate reporting.
|
||||||
|
- Added out-of-sample overfit guards via train/test time-window split and generalization-gap checks.
|
||||||
|
- Added dashboard controls for tradable pair universe selection and strategy mode/parameter configuration.
|
||||||
|
- Added feature-flagged statistical arbitrage experiment scaffold (`src/arbitrade/strategy/stat_arb.py`) with mean-reversion signal lifecycle.
|
||||||
|
- Added strategy feature-flag settings for statistical arbitrage experiment activation and z-score/holding-window controls.
|
||||||
|
- Added unit coverage for statistical arbitrage experiment behavior and new strategy settings validation rules.
|
||||||
|
- Added dedicated backtesting dashboard page (`/dashboard/backtesting`) with run controls for replay path, fee profile, balances, slippage, and execution latency.
|
||||||
|
- Added backtesting run/report endpoints (`/dashboard/backtesting/run`, `/dashboard/api/backtesting/reports`) and recent-report history surfaced in UI.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -27,6 +36,10 @@
|
|||||||
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
|
- 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.
|
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
|
||||||
- Added backtesting usage and replay format documentation to README.
|
- Added backtesting usage and replay format documentation to README.
|
||||||
|
- Dashboard controls now surface tradable pairs and strategy config snapshot values.
|
||||||
|
- CI now publishes `git.allucanget.biz/allucanget/arbitrade:latest`, and README now documents Coolify image deployment with runtime environment variables managed in Coolify.
|
||||||
|
- Dashboard strategy-mode validation now allows `stat_arb_experiment` only when feature flag is enabled.
|
||||||
|
- README now documents deferred cross-exchange architecture requirements, risk assumptions, and promotion milestones for strategy expansion.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -7,12 +7,14 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
RUN pip install --no-cache-dir --upgrade pip
|
||||||
|
|
||||||
|
COPY requirements /app/requirements
|
||||||
|
RUN pip install --no-cache-dir -r requirements/latest-runtime.in
|
||||||
|
|
||||||
COPY pyproject.toml README.md /app/
|
COPY pyproject.toml README.md /app/
|
||||||
COPY src /app/src
|
COPY src /app/src
|
||||||
COPY web /app/web
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir --no-deps .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 9090
|
||||||
|
|
||||||
CMD ["python", "-m", "arbitrade.main"]
|
CMD ["python", "-m", "arbitrade.main"]
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ Current stack:
|
|||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- FastAPI + HTMX/Jinja2
|
- FastAPI + HTMX/Jinja2
|
||||||
- DuckDB for dev/test/prod
|
- PostgreSQL for all environments (via asyncpg)
|
||||||
- Native Kraken WebSocket planned for market-data hot path
|
- Native Kraken WebSocket planned for market-data hot path
|
||||||
- Gitea Actions + Gitea container registry
|
- Gitea Actions + Gitea container registry
|
||||||
|
|
||||||
Project plan lives in [PLAN.md](PLAN.md).
|
|
||||||
Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md).
|
Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md).
|
||||||
|
Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Bootstrap complete for foundation layer:
|
|||||||
- typed settings and env loading
|
- typed settings and env loading
|
||||||
- structured logging
|
- structured logging
|
||||||
- encrypted secret helpers
|
- encrypted secret helpers
|
||||||
- DuckDB connection + base schema
|
- PostgreSQL connection + full schema migration
|
||||||
- FastAPI app with health endpoint
|
- FastAPI app with health endpoint
|
||||||
- Gitea Actions CI scaffold
|
- Gitea Actions CI scaffold
|
||||||
- Docker / docker-compose scaffold
|
- Docker / docker-compose scaffold
|
||||||
@@ -34,6 +34,50 @@ Not implemented yet:
|
|||||||
- trade execution
|
- trade execution
|
||||||
- dashboard beyond health/bootstrap page
|
- dashboard beyond health/bootstrap page
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
The arbitrage trading bot now includes a complete configuration management system that allows users to configure trading behavior, currency pairings, fees, and other application settings through a web interface. All user-configurable settings are persisted in the database while system variables remain in environment variables as per the settings split plan.
|
||||||
|
|
||||||
|
Key features include:
|
||||||
|
|
||||||
|
- Web-based configuration interface at `/dashboard/config/`
|
||||||
|
- Runtime hot-reloading of configuration changes
|
||||||
|
- Complete CRUD operations for all configuration entities
|
||||||
|
- Input validation and error handling
|
||||||
|
- Audit logging for all configuration changes
|
||||||
|
- Backtesting parameter configuration
|
||||||
|
- Fee configuration by pairing and market type
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Full page templates (`src/arbitrade/web/templates/`):
|
||||||
|
|
||||||
|
| Template | Route | Purpose |
|
||||||
|
| ------------------ | ------------------------ | ------------------------------------------------------- |
|
||||||
|
| `base.html` | — (root layout) | Dark theme, `.shell` container, HTMX, CSS variables |
|
||||||
|
| `dashboard.html` | `/`, `/dashboard` | Main dashboard: metrics, overview, controls, charts |
|
||||||
|
| `config.html` | `/dashboard/config` | Full configuration: fees, runtime, alerts, Kraken, risk |
|
||||||
|
| `audit.html` | `/dashboard/audit` | Audit trail with auto-refresh via HTMX |
|
||||||
|
| `backtesting.html` | `/dashboard/backtesting` | Backtesting panel with replay/sweep forms |
|
||||||
|
| `health.html` | `/health` | System health check |
|
||||||
|
|
||||||
|
Dashboard partials (`src/arbitrade/web/templates/partials/`):
|
||||||
|
|
||||||
|
| Partial | In page | Content |
|
||||||
|
| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| `metrics.html` | Dashboard | 6 KPI cards: P&L, win rate, avg duration, trade count, success %, profit factor |
|
||||||
|
| `overview.html` | Dashboard | Status, balances, fee tier, open trades list, opportunity feed |
|
||||||
|
| `controls.html` | Dashboard | Runtime status, kill switch, config snapshot, alerting status, execution controls (Start/Stop/Kill) |
|
||||||
|
| `charts.html` | Dashboard | Opportunity trend chart (Chart.js, Alpine toggle) |
|
||||||
|
| `config.html` | Config page | Config form: Runtime, Alerts, Kraken, Risk, Strategy sections |
|
||||||
|
| `config_fees.html` | Config page | Pair fee table + add/edit form |
|
||||||
|
| `backtesting_panel.html` | Backtesting page | Run status, replay/sweep forms, recent runs |
|
||||||
|
| `audit.html` | Audit page | Audit trail table: time, actor, event, decision, payload |
|
||||||
|
|
||||||
|
Legacy templates (`src/arbitrade/web/templates/dashboard/`):
|
||||||
|
|
||||||
|
- `config_settings.html`, `config_pairs.html`, `config_fees.html` — superseded by config page; retained for reference
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
@@ -87,6 +131,12 @@ Install app + dev dependencies:
|
|||||||
uv pip install -e .[dev]
|
uv pip install -e .[dev]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Dependency source of truth:
|
||||||
|
|
||||||
|
- Runtime dependencies live in `requirements/latest-runtime.in`.
|
||||||
|
- Dev dependencies live in `requirements/latest-dev.in`.
|
||||||
|
- `pyproject.toml` reads both files dynamically during package install.
|
||||||
|
|
||||||
Create local env file:
|
Create local env file:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -98,10 +148,14 @@ Minimum `.env` values:
|
|||||||
```env
|
```env
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8000
|
APP_PORT=9090
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_JSON=true
|
LOG_JSON=true
|
||||||
DUCKDB_PATH=./data/arbitrade.duckdb
|
PG_HOST=192.168.88.35
|
||||||
|
PG_PORT=5432
|
||||||
|
PG_DATABASE=arbitrade
|
||||||
|
PG_USER=arbitrade
|
||||||
|
PG_PASSWORD=arbitrade
|
||||||
FERNET_KEY=
|
FERNET_KEY=
|
||||||
KRAKEN_API_KEY=
|
KRAKEN_API_KEY=
|
||||||
KRAKEN_API_SECRET=
|
KRAKEN_API_SECRET=
|
||||||
@@ -126,20 +180,24 @@ python -m arbitrade.main
|
|||||||
|
|
||||||
Health endpoints:
|
Health endpoints:
|
||||||
|
|
||||||
- HTML: `http://localhost:8000/`
|
- HTML: `http://localhost:9090/`
|
||||||
- JSON: `http://localhost:8000/health`
|
- JSON: `http://localhost:9090/health`
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
DuckDB used everywhere: local dev, tests, production.
|
PostgreSQL used everywhere: local dev, tests, production.
|
||||||
|
|
||||||
Default database file:
|
Default connection:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
./data/arbitrade.duckdb
|
PG_HOST=192.168.88.35
|
||||||
|
PG_PORT=5432
|
||||||
|
PG_DATABASE=arbitrade
|
||||||
|
PG_USER=arbitrade
|
||||||
|
PG_PASSWORD=arbitrade
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema bootstrap runs automatically on app startup.
|
Schema bootstrap runs automatically on app startup via `PgStore.migrate()`.
|
||||||
|
|
||||||
Current tables:
|
Current tables:
|
||||||
|
|
||||||
@@ -169,7 +227,7 @@ DELETE FROM audit_events
|
|||||||
WHERE occurred_at < NOW() - INTERVAL 30 DAY;
|
WHERE occurred_at < NOW() - INTERVAL 30 DAY;
|
||||||
```
|
```
|
||||||
|
|
||||||
- Back up archive files and the main DuckDB file together.
|
- Back up archive files and the PostgreSQL database together.
|
||||||
- For production, run archive + backup as scheduled maintenance (cron/task scheduler).
|
- For production, run archive + backup as scheduled maintenance (cron/task scheduler).
|
||||||
|
|
||||||
## Quality Checks
|
## Quality Checks
|
||||||
@@ -201,7 +259,7 @@ mypy src
|
|||||||
Run dependency vulnerability audit:
|
Run dependency vulnerability audit:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pip-audit --skip-editable
|
pip-audit -r requirements/latest-runtime.in
|
||||||
```
|
```
|
||||||
|
|
||||||
Run secret scan (worktree + git history):
|
Run secret scan (worktree + git history):
|
||||||
@@ -242,6 +300,11 @@ Build locally:
|
|||||||
docker build -t arbitrade:local .
|
docker build -t arbitrade:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Container dependency install flow:
|
||||||
|
|
||||||
|
- Docker installs runtime dependencies from `requirements/latest-runtime.in`.
|
||||||
|
- Docker then installs the package with `--no-deps` so dependency resolution is driven by requirements files.
|
||||||
|
|
||||||
Run with compose:
|
Run with compose:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -252,8 +315,72 @@ Compose mounts local `data/` folder into container at `/app/data`.
|
|||||||
|
|
||||||
Important:
|
Important:
|
||||||
|
|
||||||
- [docker-compose.yml](docker-compose.yml) still uses registry image placeholder format.
|
- [docker-compose.yml](docker-compose.yml) uses `git.allucanget.biz/allucanget/arbitrade:latest` as the default image reference.
|
||||||
- Replace `git.allucanget.biz/OWNER/arbitrade:latest` with actual namespace image, likely `git.allucanget.biz/allucanget/arbitrade:latest`.
|
|
||||||
|
## Coolify Deployment (Prebuilt Image)
|
||||||
|
|
||||||
|
Use this when deploying from the image published by CI instead of building from Git inside Coolify.
|
||||||
|
|
||||||
|
### 1) Create application in Coolify
|
||||||
|
|
||||||
|
- In Coolify, create a new `Application` using `Docker Image` / `Public Image` / `Private Registry Image`.
|
||||||
|
- Image: `git.allucanget.biz/allucanget/arbitrade:latest`
|
||||||
|
- Registry: `git.allucanget.biz`
|
||||||
|
- If registry auth is required, configure the same registry credentials in Coolify.
|
||||||
|
|
||||||
|
### 2) Configure build and start behavior
|
||||||
|
|
||||||
|
Set these in Coolify application settings:
|
||||||
|
|
||||||
|
- Build Command: leave empty.
|
||||||
|
- Install Command: leave empty.
|
||||||
|
- Start Command: leave empty unless you explicitly want to override the image default.
|
||||||
|
- Port: `9090` (coolify uses `8000` internally)
|
||||||
|
|
||||||
|
### 3) Configure health check and networking
|
||||||
|
|
||||||
|
- Health Check Path: `/health`
|
||||||
|
- Exposed Port: `9090`
|
||||||
|
- Use Coolify-generated domain or attach your own domain.
|
||||||
|
|
||||||
|
### 4) Configure persistent storage
|
||||||
|
|
||||||
|
Add a persistent volume in Coolify:
|
||||||
|
|
||||||
|
- Mount Path: `/app/data`
|
||||||
|
|
||||||
|
This preserves PostgreSQL data and other runtime artifacts across restarts/redeploys.
|
||||||
|
|
||||||
|
### 5) Configure environment variables
|
||||||
|
|
||||||
|
Add runtime environment variables in Coolify (UI: Environment Variables):
|
||||||
|
|
||||||
|
- `APP_ENV=prod`
|
||||||
|
- `APP_HOST=0.0.0.0`
|
||||||
|
- `APP_PORT=9090`
|
||||||
|
- `PG_HOST=postgres`
|
||||||
|
`PG_PORT=5432`
|
||||||
|
`PG_DATABASE=arbitrade`
|
||||||
|
`PG_USER=arbitrade`
|
||||||
|
`PG_PASSWORD=arbitrade`
|
||||||
|
- `LOG_LEVEL=INFO`
|
||||||
|
- `LOG_JSON=true`
|
||||||
|
- `KRAKEN_API_KEY=...`
|
||||||
|
- `KRAKEN_API_SECRET=...`
|
||||||
|
- `KRAKEN_API_KEY_PERMISSIONS=query,trade`
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
- Configure `FERNET_KEY` in Coolify secrets (do not commit it).
|
||||||
|
- Keep all exchange keys/secrets in Coolify secret variables only.
|
||||||
|
|
||||||
|
Coolify should own runtime configuration through environment variables. CI only publishes the image.
|
||||||
|
|
||||||
|
### 6) Deploy and verify
|
||||||
|
|
||||||
|
- Trigger deploy in Coolify after CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
|
||||||
|
- Verify app boot logs show startup completed.
|
||||||
|
- Verify `GET /health` returns success on deployed URL.
|
||||||
|
|
||||||
## Gitea CI / Registry Setup
|
## Gitea CI / Registry Setup
|
||||||
|
|
||||||
@@ -265,13 +392,6 @@ Required Gitea Actions secrets:
|
|||||||
|
|
||||||
- `REGISTRY_USERNAME`
|
- `REGISTRY_USERNAME`
|
||||||
- `REGISTRY_TOKEN`
|
- `REGISTRY_TOKEN`
|
||||||
- `REGISTRY_NAMESPACE`
|
|
||||||
|
|
||||||
Expected namespace now likely:
|
|
||||||
|
|
||||||
```text
|
|
||||||
allucanget
|
|
||||||
```
|
|
||||||
|
|
||||||
Example registry login:
|
Example registry login:
|
||||||
|
|
||||||
@@ -282,115 +402,14 @@ docker login git.allucanget.biz
|
|||||||
Example pushed image tag shape:
|
Example pushed image tag shape:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git.allucanget.biz/allucanget/arbitrade:<tag>
|
git.allucanget.biz/allucanget/arbitrade:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Layout
|
## Architecture Docs
|
||||||
|
|
||||||
```text
|
Implementation detail moved into docs:
|
||||||
arbitrade/
|
|
||||||
├── .gitea/workflows/ci.yml
|
|
||||||
├── .github/instructions/TODO.md
|
|
||||||
├── PLAN.md
|
|
||||||
├── pyproject.toml
|
|
||||||
├── src/arbitrade/
|
|
||||||
│ ├── api/
|
|
||||||
│ ├── config/
|
|
||||||
│ ├── storage/
|
|
||||||
│ ├── logging_setup.py
|
|
||||||
│ └── main.py
|
|
||||||
├── tests/
|
|
||||||
└── web/templates/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Work
|
- [architecture overview](docs/architecture/README.md) - system context, building blocks, runtime, deployment, quality goals, risks.
|
||||||
|
- [current implementation snapshot](docs/architecture/current-implementation.md) - codebase state, active routes, backtesting, strategy flags, deployment flow.
|
||||||
|
|
||||||
Next planned implementation slice:
|
For navigation from README, use the docs above instead of this file for deep architecture detail.
|
||||||
|
|
||||||
- Kraken REST client skeleton
|
|
||||||
- native Kraken WebSocket client
|
|
||||||
- in-memory order book cache
|
|
||||||
- latency instrumentation
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
PowerShell blocks activation script:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
|
|
||||||
```
|
|
||||||
|
|
||||||
Then activate again:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
If app import fails, confirm editable install ran:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
uv pip install -e .[dev]
|
|
||||||
```
|
|
||||||
|
|
||||||
If DuckDB file missing, start app once or create `data/` directory manually.
|
|
||||||
|
|
||||||
## Security Hardening
|
|
||||||
|
|
||||||
Threat model notes:
|
|
||||||
|
|
||||||
- Primary risk surfaces: environment secrets, dashboard auth credentials, exchange API key scope, and dependency supply chain.
|
|
||||||
- Assumed attacker model: leaked repository content, leaked CI logs/artifacts, or unauthorized dashboard access.
|
|
||||||
- High-impact outcomes to prevent: credential exfiltration, unauthorized withdrawals, and unsafe live-trading control changes.
|
|
||||||
|
|
||||||
Hardening checklist:
|
|
||||||
|
|
||||||
- Use least-privilege Kraken API keys: query + trade only; never enable withdrawal.
|
|
||||||
- Rotate API keys immediately if secret scan flags a potential exposure.
|
|
||||||
- Keep dashboard auth enabled in non-local environments and avoid default/shared credentials.
|
|
||||||
- Run `pip-audit --skip-editable` in CI; treat vulnerability findings as release blockers.
|
|
||||||
- Run `python scripts/security_scan.py` before release and after major merges.
|
|
||||||
- Store secrets in environment/secret manager; never commit `.env` or key material.
|
|
||||||
|
|
||||||
## Performance Hardening
|
|
||||||
|
|
||||||
Profile scenarios:
|
|
||||||
|
|
||||||
- `book_update_burst`
|
|
||||||
- `execution_spike`
|
|
||||||
- `reconnect_storm`
|
|
||||||
|
|
||||||
## Backtesting
|
|
||||||
|
|
||||||
Run a deterministic replay backtest from a JSONL event stream:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts/backtest_replay.py --events path\to\replay.jsonl --starting-balances USD=1000.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Replay event format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[100.0,1.0]],"asks":[[101.0,1.0]]}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Events are replayed in timestamp order.
|
|
||||||
- The replay engine reuses the production detector, pre-trade validation, trade limits, and execution sequencer.
|
|
||||||
- The simulated execution path applies configurable slippage and execution latency so reports include deterministic trade/miss statistics.
|
|
||||||
Latency baseline and threshold artifacts:
|
|
||||||
|
|
||||||
- `ops/performance/latency_baseline.json`
|
|
||||||
- `ops/performance/latency_thresholds.json`
|
|
||||||
|
|
||||||
CI guardrail:
|
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` runs `scripts/check_latency_regression.py` and fails on regression.
|
|
||||||
|
|
||||||
Measured optimization impact (2026-06-01):
|
|
||||||
|
|
||||||
- `MetricsCalculator.compute()` switched from Python row scans to DuckDB SQL aggregates/quantiles.
|
|
||||||
- Benchmark (`scripts/benchmark_metrics_compute.py`):
|
|
||||||
- Python scan avg: `12.623 ms`
|
|
||||||
- SQL aggregate avg: `11.039 ms`
|
|
||||||
- Speedup: `1.14x`
|
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
arbitrade:
|
arbitrade:
|
||||||
image: git.allucanget.biz/OWNER/arbitrade:latest
|
image: git.allucanget.biz/allucanget/arbitrade:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "9090:9090"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,7 +39,7 @@ Key end-to-end latency baselines from `latency_baseline.json`:
|
|||||||
|
|
||||||
## Optimization Note
|
## Optimization Note
|
||||||
|
|
||||||
`MetricsCalculator.compute()` was optimized to use DuckDB SQL aggregations and quantiles, reducing Python-side row scans.
|
`MetricsCalculator.compute()` uses PostgreSQL SQL aggregations and percentiles, reducing Python-side row scans.
|
||||||
|
|
||||||
Measured benchmark (`scripts/benchmark_metrics_compute.py`):
|
Measured benchmark (`scripts/benchmark_metrics_compute.py`):
|
||||||
|
|
||||||
|
|||||||
+25
-34
@@ -1,6 +1,6 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling>=1.25.0"]
|
requires = ["setuptools>=69.0.0", "wheel>=0.43.0"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arbitrade"
|
name = "arbitrade"
|
||||||
@@ -8,42 +8,33 @@ version = "0.1.0"
|
|||||||
description = "Low-latency Kraken arbitrage bot"
|
description = "Low-latency Kraken arbitrage bot"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dynamic = ["dependencies", "optional-dependencies"]
|
||||||
"cryptography>=43.0.0",
|
|
||||||
"duckdb>=1.1.0",
|
|
||||||
"fastapi>=0.115.0",
|
|
||||||
"httptools>=0.6.1",
|
|
||||||
"httpx>=0.28.0",
|
|
||||||
"jinja2>=3.1.0",
|
|
||||||
"keyring>=25.0.0",
|
|
||||||
"orjson>=3.10.0",
|
|
||||||
"pydantic>=2.9.0",
|
|
||||||
"pydantic-settings>=2.5.0",
|
|
||||||
"structlog>=24.4.0",
|
|
||||||
"sortedcontainers>=2.4.0",
|
|
||||||
"uvicorn[standard]>=0.31.0",
|
|
||||||
"uvloop>=0.20.0; platform_system != 'Windows'",
|
|
||||||
"websockets>=13.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[tool.setuptools.dynamic]
|
||||||
dev = [
|
dependencies = {file = ["requirements/latest-runtime.in"]}
|
||||||
"black>=24.8.0",
|
|
||||||
"mypy>=1.11.0",
|
[tool.setuptools.dynamic.optional-dependencies]
|
||||||
"pre-commit>=3.8.0",
|
dev = {file = ["requirements/latest-dev.in"]}
|
||||||
"pytest>=8.3.0",
|
|
||||||
"pytest-asyncio>=0.24.0",
|
|
||||||
"respx>=0.21.1",
|
|
||||||
"ruff>=0.6.0",
|
|
||||||
"vcrpy>=6.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
arbitrade = "arbitrade.main:main"
|
arbitrade = "arbitrade.main:main"
|
||||||
arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
|
arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.setuptools]
|
||||||
packages = ["src/arbitrade"]
|
package-dir = {"" = "src"}
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
arbitrade = [
|
||||||
|
"web/templates/*.html",
|
||||||
|
"web/templates/config/*.html",
|
||||||
|
"web/templates/dashboard/*.html",
|
||||||
|
"web/templates/partials/*.html",
|
||||||
|
"storage/schema_pg.sql",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
@@ -55,7 +46,7 @@ target-version = "py312"
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "N", "ASYNC"]
|
select = ["E", "F", "I", "UP", "B", "N", "ASYNC"]
|
||||||
ignore = ["E203"]
|
ignore = ["E203", "E501"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
@@ -66,7 +57,7 @@ pretty = true
|
|||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["duckdb", "keyring", "sortedcontainers"]
|
module = ["asyncpg", "keyring", "sortedcontainers"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
@@ -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
|
||||||
+38
-21
@@ -6,10 +6,31 @@ from collections.abc import Mapping
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
||||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
|
||||||
|
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
|
||||||
|
if fee_rate is not None:
|
||||||
|
return fee_rate
|
||||||
|
if db_path is not None:
|
||||||
|
try:
|
||||||
|
conn = duckdb.connect(db_path)
|
||||||
|
row = conn.execute("""
|
||||||
|
SELECT maker_fee FROM kraken_account_snapshots
|
||||||
|
ORDER BY snapshot_at DESC LIMIT 1
|
||||||
|
""").fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row is not None and row[0] is not None:
|
||||||
|
return float(row[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0.0026 # ultimate fallback
|
||||||
|
|
||||||
|
|
||||||
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
||||||
graph = CurrencyGraph()
|
graph = CurrencyGraph()
|
||||||
graph.add_pair("USD", "BTC", "BTC/USD")
|
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||||
@@ -30,20 +51,20 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Run backtest.")
|
||||||
description="Run a deterministic replay backtest.")
|
|
||||||
parser.add_argument("--events", type=Path, required=True)
|
parser.add_argument("--events", type=Path, required=True)
|
||||||
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
||||||
parser.add_argument("--trade-capital", type=float, default=100.0)
|
parser.add_argument("--trade-capital", type=float, default=100.0)
|
||||||
parser.add_argument("--fee-rate", type=float, default=0.0026)
|
parser.add_argument("--fee-rate", type=float, default=None)
|
||||||
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
||||||
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cycles_by_pair, available_pairs = _build_graph()
|
cycles_by_pair, available_pairs = _build_graph()
|
||||||
events = load_replay_events(args.events)
|
events = load_replay_events(args.events)
|
||||||
|
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
|
||||||
config = BacktestConfig(
|
config = BacktestConfig(
|
||||||
fee_rate=args.fee_rate,
|
fee_rate=fee_rate,
|
||||||
trade_capital=args.trade_capital,
|
trade_capital=args.trade_capital,
|
||||||
slippage_bps=args.slippage_bps,
|
slippage_bps=args.slippage_bps,
|
||||||
execution_latency_ms=args.execution_latency_ms,
|
execution_latency_ms=args.execution_latency_ms,
|
||||||
@@ -55,27 +76,23 @@ def main() -> int:
|
|||||||
config=config,
|
config=config,
|
||||||
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
||||||
)
|
)
|
||||||
report = asyncio.run(
|
starting_balances = _parse_balances(args.starting_balances)
|
||||||
engine.run(events, starting_balances=_parse_balances(
|
r = asyncio.run(engine.run(events, starting_balances=starting_balances))
|
||||||
args.starting_balances))
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Backtest report:")
|
print("Backtest report:")
|
||||||
print(f"- processed_events: {report.processed_events}")
|
print(f"- processed_events: {r.processed_events}")
|
||||||
print(f"- opportunities_seen: {report.opportunities_seen}")
|
print(f"- opportunities_seen: {r.opportunities_seen}")
|
||||||
print(f"- trades_executed: {report.trades_executed}")
|
print(f"- trades_executed: {r.trades_executed}")
|
||||||
print(
|
print(f"- win_rate: {r.win_rate if r.win_rate is not None else 'n/a'}")
|
||||||
f"- win_rate: {report.win_rate if report.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(
|
print(f"- realized_pnl_usd: {r.realized_pnl_usd:.4f}")
|
||||||
f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
|
print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
|
||||||
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
|
print(f"- miss_reasons: {dict(r.miss_reasons)}")
|
||||||
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
|
|
||||||
print(f"- miss_reasons: {dict(report.miss_reasons)}")
|
|
||||||
print(
|
print(
|
||||||
"- execution_latency_ms: "
|
"- execution_latency_ms: "
|
||||||
f"p50={report.execution_latency_p50_ms or 0.0:.4f}, "
|
f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
|
||||||
f"p95={report.execution_latency_p95_ms or 0.0:.4f}, "
|
f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
|
||||||
f"p99={report.execution_latency_p99_ms or 0.0:.4f}"
|
f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -8,17 +8,19 @@ from time import perf_counter
|
|||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
from arbitrade.config.settings import Settings
|
||||||
from arbitrade.metrics import MetricsCalculator
|
from arbitrade.metrics import MetricsCalculator
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
|
||||||
|
|
||||||
def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float | None]:
|
async def _python_scan_compute(store: PgStore) -> tuple[float, float | None, float | None]:
|
||||||
with store.connect() as conn:
|
sql_s = """
|
||||||
trade_rows = conn.execute("""
|
SELECT started_at, finished_at, realized_pnl
|
||||||
SELECT started_at, finished_at, realized_pnl
|
FROM trades
|
||||||
FROM trades
|
WHERE finished_at IS NOT NULL
|
||||||
WHERE finished_at IS NOT NULL
|
"""
|
||||||
""").fetchall()
|
sql_d = "SELECT detected_at FROM opportunities"
|
||||||
opportunity_rows = conn.execute("SELECT detected_at FROM opportunities").fetchall()
|
async with store.pool.acquire() as conn:
|
||||||
|
trade_rows = await conn.fetch(sql_s)
|
||||||
|
orows = await conn.fetch(sql_d)
|
||||||
|
|
||||||
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
|
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
|
||||||
durations = [
|
durations = [
|
||||||
@@ -28,10 +30,10 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
|
|||||||
]
|
]
|
||||||
avg_duration = fmean(durations) if durations else None
|
avg_duration = fmean(durations) if durations else None
|
||||||
|
|
||||||
times = [row[0] for row in opportunity_rows if isinstance(row[0], datetime)]
|
times = [row[0] for row in orows if isinstance(row[0], datetime)]
|
||||||
if len(times) >= 2:
|
if len(times) >= 2:
|
||||||
span_seconds = (max(times) - min(times)).total_seconds()
|
ss = (max(times) - min(times)).total_seconds()
|
||||||
opm = len(times) / (span_seconds / 60.0) if span_seconds > 0.0 else float(len(times))
|
opm = len(times) / (ss / 60.0) if ss > 0.0 else float(len(times))
|
||||||
elif len(times) == 1:
|
elif len(times) == 1:
|
||||||
opm = 60.0
|
opm = 60.0
|
||||||
else:
|
else:
|
||||||
@@ -40,7 +42,7 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
|
|||||||
return realized, avg_duration, opm
|
return realized, avg_duration, opm
|
||||||
|
|
||||||
|
|
||||||
def _seed_dataset(store: DuckDBStore) -> None:
|
async def _seed_dataset(store: PgStore) -> None:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
trade_rows: list[tuple[object, ...]] = []
|
trade_rows: list[tuple[object, ...]] = []
|
||||||
@@ -86,11 +88,11 @@ def _seed_dataset(store: DuckDBStore) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
with store.connect() as conn:
|
async with store.pool.acquire() as conn:
|
||||||
conn.execute("DELETE FROM trades")
|
await conn.execute("DELETE FROM trades")
|
||||||
conn.execute("DELETE FROM opportunities")
|
await conn.execute("DELETE FROM opportunities")
|
||||||
conn.execute("DELETE FROM orders")
|
await conn.execute("DELETE FROM orders")
|
||||||
conn.executemany(
|
await conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO trades (
|
INSERT INTO trades (
|
||||||
trade_ref,
|
trade_ref,
|
||||||
@@ -106,7 +108,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
|
|||||||
""",
|
""",
|
||||||
trade_rows,
|
trade_rows,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO opportunities (
|
INSERT INTO opportunities (
|
||||||
detected_at,
|
detected_at,
|
||||||
@@ -119,7 +121,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
|
|||||||
""",
|
""",
|
||||||
opportunity_rows,
|
opportunity_rows,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO orders (
|
INSERT INTO orders (
|
||||||
trade_ref,
|
trade_ref,
|
||||||
@@ -140,28 +142,28 @@ def _seed_dataset(store: DuckDBStore) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
async def main() -> int:
|
||||||
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
|
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
|
||||||
settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
|
settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
|
||||||
store = DuckDBStore(settings)
|
store = PgStore(settings)
|
||||||
store.migrate()
|
store.migrate()
|
||||||
_seed_dataset(store)
|
await _seed_dataset(store)
|
||||||
|
|
||||||
calculator = MetricsCalculator(store)
|
calculator = MetricsCalculator(store)
|
||||||
|
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
_python_scan_compute(store)
|
await _python_scan_compute(store)
|
||||||
calculator.compute()
|
await calculator.compute()
|
||||||
|
|
||||||
runs = 20
|
runs = 20
|
||||||
start = perf_counter()
|
start = perf_counter()
|
||||||
for _ in range(runs):
|
for _ in range(runs):
|
||||||
_python_scan_compute(store)
|
await _python_scan_compute(store)
|
||||||
python_ms = (perf_counter() - start) * 1000.0 / runs
|
python_ms = (perf_counter() - start) * 1000.0 / runs
|
||||||
|
|
||||||
start = perf_counter()
|
start = perf_counter()
|
||||||
for _ in range(runs):
|
for _ in range(runs):
|
||||||
calculator.compute()
|
await calculator.compute()
|
||||||
sql_ms = (perf_counter() - start) * 1000.0 / runs
|
sql_ms = (perf_counter() - start) * 1000.0 / runs
|
||||||
|
|
||||||
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
|
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
|
||||||
|
|||||||
@@ -63,16 +63,13 @@ def scan_worktree() -> list[str]:
|
|||||||
|
|
||||||
for rule_name, pattern in PATTERNS:
|
for rule_name, pattern in PATTERNS:
|
||||||
if pattern.search(content):
|
if pattern.search(content):
|
||||||
findings.append(
|
findings.append(f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
|
||||||
f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
|
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def scan_git_history() -> list[str]:
|
def scan_git_history() -> list[str]:
|
||||||
cmd = ["git", "-C", str(WORKSPACE), "log", "--all",
|
cmd = ["git", "-C", str(WORKSPACE), "log", "--all", "-p", "--pretty=format:%H"]
|
||||||
"-p", "--pretty=format:%H"]
|
completed = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
||||||
completed = subprocess.run(
|
|
||||||
cmd, check=False, capture_output=True, text=True)
|
|
||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
return ["history_scan_failed"]
|
return ["history_scan_failed"]
|
||||||
|
|
||||||
|
|||||||
+164
-4
@@ -1,40 +1,200 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from arbitrade.alerting.notifier import build_notifier_from_settings
|
from arbitrade.alerting.notifier import build_notifier_from_settings
|
||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.api.control_state import DashboardControlState
|
||||||
from arbitrade.api.routes import public_router, router
|
from arbitrade.api.routes import public_router, router
|
||||||
|
from arbitrade.backtesting.runner import backtest_worker
|
||||||
|
from arbitrade.config.pairing_sync import run_pairing_sync_loop
|
||||||
|
from arbitrade.config.service import ConfigurationService
|
||||||
from arbitrade.config.settings import Settings
|
from arbitrade.config.settings import Settings
|
||||||
|
from arbitrade.exchange.fee_service import run_fee_sync_loop
|
||||||
|
from arbitrade.exchange.kraken_rest import KrakenRestClient
|
||||||
|
from arbitrade.exchange.kraken_ws import KrakenWsClient
|
||||||
|
from arbitrade.logging.db_sink import get_db_sink
|
||||||
|
from arbitrade.logging.maintenance import run_log_aggregation_loop, run_log_archive_loop
|
||||||
from arbitrade.logging_setup import configure_logging
|
from arbitrade.logging_setup import configure_logging
|
||||||
|
from arbitrade.market_data.feed import MarketDataFeed
|
||||||
|
from arbitrade.market_data.feed_builder import (
|
||||||
|
build_detector_from_enabled_pairings,
|
||||||
|
get_enabled_pair_symbols,
|
||||||
|
)
|
||||||
from arbitrade.metrics import MetricsCalculator
|
from arbitrade.metrics import MetricsCalculator
|
||||||
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
|
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter
|
||||||
from arbitrade.storage.repositories import AuditRepository, RuntimeStateRepository
|
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
from arbitrade.storage.repositories import (
|
||||||
|
AuditRepository,
|
||||||
|
MarketSnapshotRepository,
|
||||||
|
OpportunityRepository,
|
||||||
|
RuntimeStateRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOG = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_feed(app: FastAPI, *, kill_switch_only: bool = False) -> asyncio.Task[None] | None:
|
||||||
|
"""Create and start a MarketDataFeed task from enabled pairings.
|
||||||
|
|
||||||
|
If kill_switch_only=True, only create a kill-switch-bound stub (no detector/feed).
|
||||||
|
Returns the task or None if no enabled pairings.
|
||||||
|
"""
|
||||||
|
settings = app.state.settings
|
||||||
|
db = app.state.store
|
||||||
|
alert_notifier = getattr(app.state, "alert_notifier", None)
|
||||||
|
controls = app.state.dashboard_controls
|
||||||
|
|
||||||
|
# Build detector from enabled pairings
|
||||||
|
detector = await build_detector_from_enabled_pairings(
|
||||||
|
db,
|
||||||
|
fee_rate=0.0, # will be overridden by fee sync
|
||||||
|
max_depth_levels=controls.strategy_max_depth_levels,
|
||||||
|
min_profit_threshold=controls.strategy_profit_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
symbols = await get_enabled_pair_symbols(db)
|
||||||
|
if not symbols and not kill_switch_only:
|
||||||
|
_LOG.warning("no_enabled_pair_symbols_feed_not_started")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ws_client: KrakenWsClient | None = getattr(app.state, "ws_client", None)
|
||||||
|
if ws_client is None:
|
||||||
|
ws_client = KrakenWsClient(settings, alert_notifier=alert_notifier)
|
||||||
|
app.state.ws_client = ws_client
|
||||||
|
|
||||||
|
ws_client.set_subscribed_symbols(symbols)
|
||||||
|
|
||||||
|
snapshot_writer = AsyncMarketSnapshotWriter(MarketSnapshotRepository(db))
|
||||||
|
opportunity_writer = AsyncOpportunityWriter(OpportunityRepository(db))
|
||||||
|
|
||||||
|
feed = MarketDataFeed(
|
||||||
|
ws_client=ws_client,
|
||||||
|
snapshot_writer=snapshot_writer,
|
||||||
|
detector=detector,
|
||||||
|
opportunity_writer=opportunity_writer,
|
||||||
|
paper_trading_mode=settings.paper_trading_mode,
|
||||||
|
trade_capital=settings.trade_capital_usd,
|
||||||
|
max_trade_capital=settings.max_trade_capital_usd,
|
||||||
|
kill_switch=controls.kill_switch,
|
||||||
|
alert_notifier=alert_notifier,
|
||||||
|
audit_repository=getattr(app.state, "audit_repository", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.feed = feed
|
||||||
|
task = asyncio.create_task(feed.run(), name="market_data_feed")
|
||||||
|
app.state.feed_task = task
|
||||||
|
_LOG.info("market_data_feed_started", symbols=symbols)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings) -> FastAPI:
|
def create_app(settings: Settings) -> FastAPI:
|
||||||
configure_logging(settings.log_level, settings.log_json)
|
configure_logging(settings.log_level, settings.log_json)
|
||||||
|
|
||||||
db = DuckDBStore(settings)
|
db = PgStore(settings)
|
||||||
db.migrate()
|
|
||||||
|
kraken_client = KrakenRestClient(settings)
|
||||||
|
fee_sync_stop_event = asyncio.Event()
|
||||||
|
pairing_sync_stop_event = asyncio.Event()
|
||||||
|
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
|
||||||
|
asyncio.Queue()
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
await app.state.store.start()
|
||||||
|
await app.state.store.migrate()
|
||||||
|
get_db_sink().start_consumer(db)
|
||||||
|
await app.state.configuration_service.load_database_settings()
|
||||||
await restore_runtime_state(app)
|
await restore_runtime_state(app)
|
||||||
|
fee_sync_task = asyncio.create_task(
|
||||||
|
run_fee_sync_loop(
|
||||||
|
kraken_client,
|
||||||
|
db,
|
||||||
|
fee_sync_stop_event,
|
||||||
|
),
|
||||||
|
name="fee_sync_loop",
|
||||||
|
)
|
||||||
|
pairing_sync_task = asyncio.create_task(
|
||||||
|
run_pairing_sync_loop(
|
||||||
|
kraken_client,
|
||||||
|
db,
|
||||||
|
pairing_sync_stop_event,
|
||||||
|
),
|
||||||
|
name="pairing_sync_loop",
|
||||||
|
)
|
||||||
|
backtest_task = asyncio.create_task(
|
||||||
|
backtest_worker(backtest_queue, db), # type: ignore
|
||||||
|
name="backtest_worker",
|
||||||
|
)
|
||||||
|
# Start market data feed from enabled pairings
|
||||||
|
await _start_feed(app)
|
||||||
|
app.state.fee_sync_task = fee_sync_task
|
||||||
|
app.state.pairing_sync_task = pairing_sync_task
|
||||||
|
app.state.backtest_task = backtest_task
|
||||||
|
app.state.log_aggregation_task = asyncio.create_task(
|
||||||
|
run_log_aggregation_loop(db), name="log_aggregation"
|
||||||
|
)
|
||||||
|
app.state.log_archive_task = asyncio.create_task(
|
||||||
|
run_log_archive_loop(db), name="log_archive"
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
|
fee_sync_stop_event.set()
|
||||||
|
pairing_sync_stop_event.set()
|
||||||
|
# Stop feed
|
||||||
|
feed = getattr(app.state, "feed", None)
|
||||||
|
if feed is not None:
|
||||||
|
ws_client = getattr(app.state, "ws_client", None)
|
||||||
|
if ws_client is not None:
|
||||||
|
await ws_client.stop()
|
||||||
|
ft = getattr(app.state, "feed_task", None)
|
||||||
|
if ft is not None:
|
||||||
|
ft.cancel()
|
||||||
|
try:
|
||||||
|
await ft
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
fee_sync_task.cancel()
|
||||||
|
try:
|
||||||
|
await fee_sync_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
pairing_sync_task.cancel()
|
||||||
|
try:
|
||||||
|
await pairing_sync_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await backtest_queue.put(None) # poison pill
|
||||||
|
backtest_task.cancel()
|
||||||
|
try:
|
||||||
|
await backtest_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await kraken_client.close()
|
||||||
await graceful_shutdown(app)
|
await graceful_shutdown(app)
|
||||||
|
await app.state.store.stop()
|
||||||
|
await get_db_sink().stop_consumer()
|
||||||
|
|
||||||
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.store = db
|
app.state.store = db
|
||||||
|
app.state.kraken_client = kraken_client
|
||||||
|
app.state.fee_sync_stop_event = fee_sync_stop_event
|
||||||
|
app.state.pairing_sync_stop_event = pairing_sync_stop_event
|
||||||
|
app.state.backtest_queue = backtest_queue
|
||||||
app.state.metrics = MetricsCalculator(db)
|
app.state.metrics = MetricsCalculator(db)
|
||||||
app.state.audit_repository = AuditRepository(db)
|
app.state.audit_repository = AuditRepository(db)
|
||||||
app.state.runtime_state_repository = RuntimeStateRepository(db)
|
app.state.runtime_state_repository = RuntimeStateRepository(db)
|
||||||
app.state.alert_notifier = build_notifier_from_settings(settings)
|
app.state.alert_notifier = build_notifier_from_settings(settings)
|
||||||
|
svc = ConfigurationService(settings, db, app.state.audit_repository)
|
||||||
|
app.state.configuration_service = svc
|
||||||
|
app.state.backtest_recent_reports = []
|
||||||
app.state.dashboard_controls = DashboardControlState(
|
app.state.dashboard_controls = DashboardControlState(
|
||||||
is_running=not settings.kill_switch_active,
|
is_running=not settings.kill_switch_active,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from arbitrade.risk.kill_switch import KillSwitch
|
|||||||
class DashboardControlState:
|
class DashboardControlState:
|
||||||
is_running: bool = True
|
is_running: bool = True
|
||||||
kill_switch: KillSwitch = field(default_factory=KillSwitch)
|
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))
|
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
def mark_updated(self) -> None:
|
def mark_updated(self) -> None:
|
||||||
|
|||||||
+347
-462
@@ -1,349 +1,82 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from asyncio import Lock
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
import duckdb
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from arbitrade.alerting.notifier import SupportsAlerts, SupportsAlertStatus
|
|
||||||
from arbitrade.api.auth import require_dashboard_auth
|
from arbitrade.api.auth import require_dashboard_auth
|
||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.config.pairing_sync import sync_pairings_from_kraken
|
||||||
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
from arbitrade.dashboard.context import (
|
||||||
|
_backtesting_panel_context,
|
||||||
|
_recent_backtest_reports,
|
||||||
|
)
|
||||||
|
from arbitrade.dashboard.dashboard import (
|
||||||
|
_alert_status_snapshot,
|
||||||
|
_dashboard_audit,
|
||||||
|
_dashboard_backtesting_handler,
|
||||||
|
_dashboard_backtesting_job_export,
|
||||||
|
_dashboard_backtesting_job_handler,
|
||||||
|
_dashboard_charts,
|
||||||
|
_dashboard_config_context,
|
||||||
|
_dashboard_controls,
|
||||||
|
_dashboard_ctl_cfg,
|
||||||
|
_dashboard_ctl_kill,
|
||||||
|
_dashboard_ctl_start,
|
||||||
|
_dashboard_ctl_stop,
|
||||||
|
_dashboard_metrics,
|
||||||
|
_dashboard_overview,
|
||||||
|
_dashboard_pairings_response,
|
||||||
|
_dashboard_response,
|
||||||
|
_pairing_repo,
|
||||||
|
_toggle_pairing,
|
||||||
|
)
|
||||||
|
from arbitrade.storage.repositories import (
|
||||||
|
BacktestJobRepository,
|
||||||
|
ConfigPairingRepository,
|
||||||
|
LogRepository,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
|
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
|
||||||
public_router = APIRouter()
|
public_router = APIRouter()
|
||||||
templates = Jinja2Templates(
|
|
||||||
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_metric(value: float | None, *, precision: int = 2, suffix: str = "") -> str:
|
def _resolve_templates_directory() -> str:
|
||||||
if value is None:
|
# Support source layout, Docker runtime (/app), and installed package data.
|
||||||
return "—"
|
source_layout_path = Path(__file__).resolve().parents[3] / "web" / "templates"
|
||||||
return f"{value:.{precision}f}{suffix}"
|
if source_layout_path.is_dir():
|
||||||
|
return str(source_layout_path)
|
||||||
|
|
||||||
|
docker_runtime_path = Path.cwd() / "web" / "templates"
|
||||||
|
if docker_runtime_path.is_dir():
|
||||||
|
return str(docker_runtime_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
package_path = resources.files("arbitrade").joinpath("web", "templates")
|
||||||
|
if package_path.is_dir():
|
||||||
|
return str(package_path)
|
||||||
|
except (ModuleNotFoundError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return str(source_layout_path)
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_metrics(request: Request) -> dict[str, str]:
|
templates = Jinja2Templates(directory=_resolve_templates_directory())
|
||||||
metrics = request.app.state.metrics.compute()
|
_BACKTEST_ROOT = Path(__file__).resolve().parents[3]
|
||||||
return {
|
_BACKTEST_RUN_LOCK = Lock()
|
||||||
"realized_pnl": _format_metric(metrics.realized_pnl_usd, precision=2, suffix=" USD"),
|
|
||||||
"win_rate": _format_metric(
|
|
||||||
metrics.win_rate * 100.0 if metrics.win_rate is not None else None,
|
|
||||||
precision=1,
|
|
||||||
suffix="%",
|
|
||||||
),
|
|
||||||
"avg_trade_duration": _format_metric(
|
|
||||||
metrics.avg_trade_duration_seconds, precision=1, suffix=" s"
|
|
||||||
),
|
|
||||||
"opportunities_per_minute": _format_metric(
|
|
||||||
metrics.opportunities_per_minute, precision=1, suffix=" /min"
|
|
||||||
),
|
|
||||||
"fill_rate": _format_metric(
|
|
||||||
metrics.fill_rate * 100.0 if metrics.fill_rate is not None else None,
|
|
||||||
precision=1,
|
|
||||||
suffix="%",
|
|
||||||
),
|
|
||||||
"latency_p50": _format_metric(metrics.latency_p50_seconds, precision=3, suffix=" s"),
|
|
||||||
"latency_p95": _format_metric(metrics.latency_p95_seconds, precision=3, suffix=" s"),
|
|
||||||
"latency_p99": _format_metric(metrics.latency_p99_seconds, precision=3, suffix=" s"),
|
|
||||||
"generated_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _table_columns(conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]:
|
async def _health_response(request: Request) -> HTMLResponse:
|
||||||
rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall()
|
|
||||||
return {str(row[1]) for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_overview(request: Request) -> dict[str, object]:
|
|
||||||
store = request.app.state.store
|
|
||||||
with store.connect() as conn:
|
|
||||||
trade_columns = _table_columns(conn, "trades")
|
|
||||||
trade_ref_expr = "trade_ref" if "trade_ref" in trade_columns else "CAST(id AS VARCHAR)"
|
|
||||||
cycle_expr = "cycle" if "cycle" in trade_columns else "NULL"
|
|
||||||
if "finished_at" in trade_columns:
|
|
||||||
open_trade_filter = "finished_at IS NULL"
|
|
||||||
else:
|
|
||||||
open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')"
|
|
||||||
|
|
||||||
portfolio_row = conn.execute("""
|
|
||||||
SELECT balances, total_value_usd
|
|
||||||
FROM portfolio_snapshots
|
|
||||||
ORDER BY snapshot_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""").fetchone()
|
|
||||||
open_trades = conn.execute(f"""
|
|
||||||
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
|
|
||||||
FROM trades
|
|
||||||
WHERE {open_trade_filter}
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
LIMIT 5
|
|
||||||
""").fetchall()
|
|
||||||
pnl_total_row = conn.execute("""
|
|
||||||
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
|
|
||||||
FROM trades
|
|
||||||
""").fetchone()
|
|
||||||
latest_opportunities = conn.execute("""
|
|
||||||
SELECT cycle, net_pct, est_profit, detected_at
|
|
||||||
FROM opportunities
|
|
||||||
ORDER BY detected_at DESC
|
|
||||||
LIMIT 5
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
balances_value = "—"
|
|
||||||
total_value = "—"
|
|
||||||
if portfolio_row is not None:
|
|
||||||
balances_raw, total_value_raw = portfolio_row
|
|
||||||
balances_value = str(balances_raw) if balances_raw is not None else "—"
|
|
||||||
if total_value_raw is not None:
|
|
||||||
total_value = f"{float(total_value_raw):.2f} USD"
|
|
||||||
|
|
||||||
open_trade_rows = [
|
|
||||||
{
|
|
||||||
"trade_ref": str(row[0]),
|
|
||||||
"status": str(row[1]),
|
|
||||||
"started_at": row[2].isoformat() if isinstance(row[2], datetime) else "—",
|
|
||||||
"cycle": str(row[3]) if row[3] is not None else "—",
|
|
||||||
}
|
|
||||||
for row in open_trades
|
|
||||||
]
|
|
||||||
opportunity_rows = [
|
|
||||||
{
|
|
||||||
"cycle": str(row[0]),
|
|
||||||
"net_pct": f"{float(row[1]):.2f}%" if row[1] is not None else "—",
|
|
||||||
"est_profit": f"{float(row[2]):.2f} USD" if row[2] is not None else "—",
|
|
||||||
"detected_at": row[3].isoformat() if isinstance(row[3], datetime) else "—",
|
|
||||||
}
|
|
||||||
for row in latest_opportunities
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "live",
|
|
||||||
"generated_at": datetime.now(UTC).isoformat(),
|
|
||||||
"balances": balances_value,
|
|
||||||
"total_value": total_value,
|
|
||||||
"open_trade_count": len(open_trade_rows),
|
|
||||||
"open_trades": open_trade_rows,
|
|
||||||
"realized_pnl_total": f"{float(pnl_total_row[0]):.2f} USD" if pnl_total_row else "—",
|
|
||||||
"opportunities": opportunity_rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_charts(request: Request) -> dict[str, object]:
|
|
||||||
store = request.app.state.store
|
|
||||||
with store.connect() as conn:
|
|
||||||
opportunity_rows = conn.execute("""
|
|
||||||
SELECT detected_at, cycle, net_pct, est_profit
|
|
||||||
FROM opportunities
|
|
||||||
ORDER BY detected_at DESC
|
|
||||||
LIMIT 10
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
chart_rows = list(reversed(opportunity_rows))
|
|
||||||
labels = [
|
|
||||||
row[0].isoformat() if isinstance(row[0], datetime) else f"opportunity-{index + 1}"
|
|
||||||
for index, row in enumerate(chart_rows)
|
|
||||||
]
|
|
||||||
net_pct_values = [float(row[2]) if row[2] is not None else 0.0 for row in chart_rows]
|
|
||||||
est_profit_values = [float(row[3]) if row[3] is not None else 0.0 for row in chart_rows]
|
|
||||||
cycles = [str(row[1]) for row in chart_rows]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"labels": labels,
|
|
||||||
"net_pct_values": net_pct_values,
|
|
||||||
"est_profit_values": est_profit_values,
|
|
||||||
"cycles": cycles,
|
|
||||||
"has_chart_data": bool(chart_rows),
|
|
||||||
"generated_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_controls_state(request: Request) -> DashboardControlState:
|
|
||||||
return cast(DashboardControlState, request.app.state.dashboard_controls)
|
|
||||||
|
|
||||||
|
|
||||||
def _audit_repository(request: Request) -> AuditRepository | None:
|
|
||||||
repository = getattr(request.app.state, "audit_repository", None)
|
|
||||||
return cast(AuditRepository | None, repository)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_audit(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
actor: str,
|
|
||||||
event_type: str,
|
|
||||||
decision: str,
|
|
||||||
payload: dict[str, object] | None = None,
|
|
||||||
) -> None:
|
|
||||||
repository = _audit_repository(request)
|
|
||||||
if repository is None:
|
|
||||||
return
|
|
||||||
correlation_id = request.headers.get("x-request-id")
|
|
||||||
repository.insert(
|
|
||||||
AuditRecord(
|
|
||||||
occurred_at=datetime.now(UTC),
|
|
||||||
actor=actor,
|
|
||||||
event_type=event_type,
|
|
||||||
decision=decision,
|
|
||||||
payload=None if payload is None else {str(key): payload[key] for key in payload},
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_audit(request: Request, *, limit: int = 15) -> dict[str, object]:
|
|
||||||
repository = _audit_repository(request)
|
|
||||||
if repository is None:
|
|
||||||
return {
|
|
||||||
"entries": [],
|
|
||||||
"generated_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
records = repository.list_recent(limit=limit)
|
|
||||||
entries: list[dict[str, str]] = []
|
|
||||||
for record in records:
|
|
||||||
payload_text = "—"
|
|
||||||
if record.payload:
|
|
||||||
payload_text = json.dumps(record.payload)
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"occurred_at": record.occurred_at.isoformat(),
|
|
||||||
"actor": record.actor,
|
|
||||||
"event_type": record.event_type,
|
|
||||||
"decision": record.decision,
|
|
||||||
"payload": payload_text,
|
|
||||||
"correlation_id": record.correlation_id or "—",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"entries": entries,
|
|
||||||
"generated_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _alert_notifier(request: Request) -> SupportsAlerts | None:
|
|
||||||
notifier = getattr(request.app.state, "alert_notifier", None)
|
|
||||||
return cast(SupportsAlerts | None, notifier)
|
|
||||||
|
|
||||||
|
|
||||||
def _alert_status_snapshot(request: Request) -> dict[str, object]:
|
|
||||||
notifier = getattr(request.app.state, "alert_notifier", None)
|
|
||||||
if isinstance(notifier, SupportsAlertStatus):
|
|
||||||
return notifier.status_snapshot()
|
|
||||||
return {
|
|
||||||
"enabled": False,
|
|
||||||
"has_channels": False,
|
|
||||||
"configured_channels": [],
|
|
||||||
"min_severity": "—",
|
|
||||||
"dedup_seconds": 0.0,
|
|
||||||
"last_result": "unavailable",
|
|
||||||
"last_attempted_at": None,
|
|
||||||
"last_success_at": None,
|
|
||||||
"last_error": None,
|
|
||||||
"last_event": None,
|
|
||||||
"last_channel_results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_controls(request: Request) -> dict[str, object]:
|
|
||||||
controls = _dashboard_controls_state(request)
|
|
||||||
settings = request.app.state.settings
|
|
||||||
alert_status = _alert_status_snapshot(request)
|
|
||||||
last_event = alert_status.get("last_event")
|
|
||||||
last_event_title = "—"
|
|
||||||
if isinstance(last_event, dict):
|
|
||||||
title_value = last_event.get("title")
|
|
||||||
if isinstance(title_value, str):
|
|
||||||
last_event_title = title_value
|
|
||||||
|
|
||||||
configured_channels = alert_status.get("configured_channels")
|
|
||||||
channels_display = "—"
|
|
||||||
if isinstance(configured_channels, list) and configured_channels:
|
|
||||||
channels_display = ", ".join(str(channel) for channel in configured_channels)
|
|
||||||
|
|
||||||
dedup_seconds_raw = alert_status.get("dedup_seconds", 0.0)
|
|
||||||
dedup_seconds = float(dedup_seconds_raw) if isinstance(dedup_seconds_raw, int | float) else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"execution_status": "running" if controls.is_running else "stopped",
|
|
||||||
"kill_switch_status": "active" if controls.kill_switch.is_active else "inactive",
|
|
||||||
"kill_switch_reason": controls.kill_switch.reason or "—",
|
|
||||||
"paper_trading_mode": "enabled" if settings.paper_trading_mode else "disabled",
|
|
||||||
"trade_capital_usd": f"{float(settings.trade_capital_usd):.2f} USD",
|
|
||||||
"trade_capital_usd_value": f"{float(settings.trade_capital_usd):.2f}",
|
|
||||||
"max_trade_capital_usd": (
|
|
||||||
"—"
|
|
||||||
if settings.max_trade_capital_usd is None
|
|
||||||
else f"{float(settings.max_trade_capital_usd):.2f} USD"
|
|
||||||
),
|
|
||||||
"max_trade_capital_usd_value": (
|
|
||||||
""
|
|
||||||
if settings.max_trade_capital_usd is None
|
|
||||||
else f"{float(settings.max_trade_capital_usd):.2f}"
|
|
||||||
),
|
|
||||||
"max_concurrent_trades": (
|
|
||||||
"—" if settings.max_concurrent_trades is None else str(settings.max_concurrent_trades)
|
|
||||||
),
|
|
||||||
"max_concurrent_trades_value": (
|
|
||||||
"" if settings.max_concurrent_trades is None else str(settings.max_concurrent_trades)
|
|
||||||
),
|
|
||||||
"alerts_enabled": "enabled" if bool(alert_status.get("enabled", False)) else "disabled",
|
|
||||||
"alerts_channels": channels_display,
|
|
||||||
"alerts_min_severity": str(alert_status.get("min_severity", "—")),
|
|
||||||
"alerts_dedup_seconds": f"{dedup_seconds:.0f}",
|
|
||||||
"alerts_last_result": str(alert_status.get("last_result", "unavailable")),
|
|
||||||
"alerts_last_attempted_at": str(alert_status.get("last_attempted_at") or "—"),
|
|
||||||
"alerts_last_success_at": str(alert_status.get("last_success_at") or "—"),
|
|
||||||
"alerts_last_event_title": last_event_title,
|
|
||||||
"alerts_last_error": str(alert_status.get("last_error") or "—"),
|
|
||||||
"alerts_last_channel_results": [
|
|
||||||
str(item) for item in cast(list[object], alert_status.get("last_channel_results", []))
|
|
||||||
],
|
|
||||||
"updated_at": controls.updated_at.isoformat(),
|
|
||||||
"start_endpoint": "/dashboard/control/start",
|
|
||||||
"stop_endpoint": "/dashboard/control/stop",
|
|
||||||
"kill_switch_endpoint": "/dashboard/control/kill-switch",
|
|
||||||
"config_endpoint": "/dashboard/control/config",
|
|
||||||
"chart_endpoint": "/dashboard/fragment/charts",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_form_body(body: bytes) -> dict[str, str]:
|
|
||||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
|
||||||
return {key: values[-1] for key, values in parsed.items() if values}
|
|
||||||
|
|
||||||
|
|
||||||
def _form_bool(value: str | None) -> bool:
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
return value.lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _dashboard_response(
|
|
||||||
request: Request, template_name: str = "dashboard.html"
|
|
||||||
) -> HTMLResponse:
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name=template_name,
|
name="health.html",
|
||||||
context={
|
context={"title": "Arbitrade Health Check"},
|
||||||
"title": "Arbitrade Dashboard",
|
|
||||||
"request": request,
|
|
||||||
"metrics_endpoint": "/dashboard/fragment/metrics",
|
|
||||||
"overview_endpoint": "/dashboard/fragment/overview",
|
|
||||||
"controls_endpoint": "/dashboard/fragment/controls",
|
|
||||||
"charts_endpoint": "/dashboard/fragment/charts",
|
|
||||||
"audit_endpoint": "/dashboard/fragment/audit",
|
|
||||||
"stream_endpoint": "/dashboard/stream/metrics",
|
|
||||||
"overview_stream_endpoint": "/dashboard/stream/overview",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -357,12 +90,60 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
return await _dashboard_response(request)
|
return await _dashboard_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/health", response_class=HTMLResponse)
|
||||||
|
async def dashboard_health_page(request: Request) -> HTMLResponse:
|
||||||
|
return await _health_response(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="backtesting.html",
|
||||||
|
context={
|
||||||
|
"title": "Arbitrade Backtesting",
|
||||||
|
"request": request,
|
||||||
|
"panel_endpoint": "/dashboard/fragment/backtesting",
|
||||||
|
"dashboard_endpoint": "/dashboard",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/backtesting", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse:
|
||||||
|
ctx = await _backtesting_panel_context(request)
|
||||||
|
ctx["flash_message"] = ""
|
||||||
|
# Check if any pairings are enabled
|
||||||
|
repo = ConfigPairingRepository(request.app.state.store)
|
||||||
|
enabled = await repo.list_pairings(enabled_only=True)
|
||||||
|
ctx["no_enabled_pairings"] = len(enabled) == 0
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/backtesting_panel.html",
|
||||||
|
context={"request": request, **ctx},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/backtesting-pairings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_pairings_fragment(request: Request) -> HTMLResponse:
|
||||||
|
"""HTMX fragment: pairing checkboxes for backtest form."""
|
||||||
|
store = request.app.state.store
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = await repo.list_pairings()
|
||||||
|
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/backtesting_pairings.html",
|
||||||
|
context={"request": request, "pairings": pairings},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
|
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
|
||||||
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="partials/metrics.html",
|
name="partials/metrics.html",
|
||||||
context={"request": request, **_dashboard_metrics(request)},
|
context={"request": request, **(await _dashboard_metrics(request))},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -371,7 +152,7 @@ async def dashboard_overview(request: Request) -> HTMLResponse:
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="partials/overview.html",
|
name="partials/overview.html",
|
||||||
context={"request": request, **_dashboard_overview(request)},
|
context={"request": request, **(await _dashboard_overview(request))},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -389,7 +170,29 @@ async def dashboard_charts(request: Request) -> HTMLResponse:
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="partials/charts.html",
|
name="partials/charts.html",
|
||||||
context={"request": request, **_dashboard_charts(request)},
|
context={"request": request, **(await _dashboard_charts(request))},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/audit", response_class=HTMLResponse)
|
||||||
|
async def dashboard_audit_page(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="audit.html",
|
||||||
|
context={
|
||||||
|
"title": "Arbitrade Audit Trail",
|
||||||
|
"request": request,
|
||||||
|
**(await _dashboard_audit(request)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/audit/fragment", response_class=HTMLResponse)
|
||||||
|
async def dashboard_audit_fragment(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/audit.html",
|
||||||
|
context={"request": request, **(await _dashboard_audit(request))},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -398,7 +201,51 @@ async def dashboard_audit(request: Request) -> HTMLResponse:
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="partials/audit.html",
|
name="partials/audit.html",
|
||||||
context={"request": request, **_dashboard_audit(request)},
|
context={"request": request, **(await _dashboard_audit(request))},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/config/pairings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_config_pairings_page(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
enabled: str | None = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Standalone pairings management page."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="pairings.html",
|
||||||
|
context={
|
||||||
|
"title": "Currency Pairings",
|
||||||
|
"request": request,
|
||||||
|
"search": search or "",
|
||||||
|
"enabled": enabled or "all",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/config", response_class=HTMLResponse)
|
||||||
|
async def dashboard_config_page(request: Request) -> HTMLResponse:
|
||||||
|
d_context = await _dashboard_config_context(request)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="config.html",
|
||||||
|
context={
|
||||||
|
"title": "Arbitrade Configuration",
|
||||||
|
"request": request,
|
||||||
|
"config_endpoint": "/dashboard/control/config",
|
||||||
|
**d_context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/config", response_class=HTMLResponse)
|
||||||
|
async def dashboard_config_fragment(request: Request) -> HTMLResponse:
|
||||||
|
d_context = await _dashboard_config_context(request)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/config.html",
|
||||||
|
context={"request": request, **d_context},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -409,163 +256,76 @@ async def dashboard_alert_status(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
|
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
|
||||||
async def dashboard_audit_recent(request: Request) -> JSONResponse:
|
async def dashboard_audit_recent(request: Request) -> JSONResponse:
|
||||||
return JSONResponse(_dashboard_audit(request, limit=25))
|
return JSONResponse(await _dashboard_audit(request, limit=25))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/api/backtesting/reports", response_class=JSONResponse)
|
||||||
|
async def dashboard_backtesting_reports(request: Request) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"reports": await _recent_backtest_reports(request),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/backtesting/run", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
|
||||||
|
"""Submit a backtest job to the async queue. Returns panel with job list."""
|
||||||
|
return await _dashboard_backtesting_handler(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/backtesting/job/{job_id}/delete", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_delete(request: Request, job_id: str) -> HTMLResponse:
|
||||||
|
store = request.app.state.store
|
||||||
|
repo = BacktestJobRepository(store)
|
||||||
|
context = await _backtesting_panel_context(request)
|
||||||
|
await repo.delete_job(job_id)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/backtesting_panel.html",
|
||||||
|
context={"request": request, **context},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/backtesting/job/{job_id}", response_class=HTMLResponse)
|
||||||
|
async def dashboard_backtesting_job_detail(request: Request, job_id: str) -> HTMLResponse:
|
||||||
|
return await _dashboard_backtesting_job_handler(request, job_id=job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/backtesting/job/{job_id}/export", response_class=Response)
|
||||||
|
async def dashboard_backtesting_export(request: Request, job_id: str) -> Response:
|
||||||
|
return await _dashboard_backtesting_job_export(request, job_id=job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dashboard/control/start", response_class=HTMLResponse)
|
@router.post("/dashboard/control/start", response_class=HTMLResponse)
|
||||||
async def dashboard_control_start(request: Request) -> HTMLResponse:
|
async def dashboard_control_start(request: Request) -> HTMLResponse:
|
||||||
controls = _dashboard_controls_state(request)
|
return await _dashboard_ctl_start(request)
|
||||||
controls.is_running = True
|
|
||||||
controls.mark_updated()
|
|
||||||
notifier = _alert_notifier(request)
|
|
||||||
if notifier is not None:
|
|
||||||
await notifier.notify(
|
|
||||||
category="system",
|
|
||||||
severity="info",
|
|
||||||
title="Execution started",
|
|
||||||
message="Dashboard control started execution.",
|
|
||||||
)
|
|
||||||
_record_audit(
|
|
||||||
request,
|
|
||||||
actor="dashboard_user",
|
|
||||||
event_type="dashboard.control.start",
|
|
||||||
decision="approved",
|
|
||||||
payload={"execution_status": "running"},
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="partials/controls.html",
|
|
||||||
context={"request": request, **_dashboard_controls(request)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dashboard/control/stop", response_class=HTMLResponse)
|
@router.post("/dashboard/control/stop", response_class=HTMLResponse)
|
||||||
async def dashboard_control_stop(request: Request) -> HTMLResponse:
|
async def dashboard_control_stop(request: Request) -> HTMLResponse:
|
||||||
controls = _dashboard_controls_state(request)
|
return await _dashboard_ctl_stop(request)
|
||||||
controls.is_running = False
|
|
||||||
controls.mark_updated()
|
|
||||||
notifier = _alert_notifier(request)
|
|
||||||
if notifier is not None:
|
|
||||||
await notifier.notify(
|
|
||||||
category="system",
|
|
||||||
severity="warning",
|
|
||||||
title="Execution stopped",
|
|
||||||
message="Dashboard control stopped execution.",
|
|
||||||
)
|
|
||||||
_record_audit(
|
|
||||||
request,
|
|
||||||
actor="dashboard_user",
|
|
||||||
event_type="dashboard.control.stop",
|
|
||||||
decision="approved",
|
|
||||||
payload={"execution_status": "stopped"},
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="partials/controls.html",
|
|
||||||
context={"request": request, **_dashboard_controls(request)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
|
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
|
||||||
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
|
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
|
||||||
controls = _dashboard_controls_state(request)
|
return await _dashboard_ctl_kill(request)
|
||||||
form = _parse_form_body(await request.body())
|
|
||||||
reason = form.get("reason") or "manual"
|
|
||||||
controls.kill_switch.activate(reason=reason)
|
|
||||||
controls.is_running = False
|
|
||||||
controls.mark_updated()
|
|
||||||
notifier = _alert_notifier(request)
|
|
||||||
if notifier is not None:
|
|
||||||
await notifier.notify(
|
|
||||||
category="threshold",
|
|
||||||
severity="critical",
|
|
||||||
title="Kill switch activated",
|
|
||||||
message="Kill switch triggered from dashboard control.",
|
|
||||||
details={"reason": reason},
|
|
||||||
)
|
|
||||||
_record_audit(
|
|
||||||
request,
|
|
||||||
actor="dashboard_user",
|
|
||||||
event_type="dashboard.control.kill_switch",
|
|
||||||
decision="approved",
|
|
||||||
payload={"reason": reason},
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="partials/controls.html",
|
|
||||||
context={"request": request, **_dashboard_controls(request)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dashboard/control/config", response_class=HTMLResponse)
|
@router.post("/dashboard/control/config", response_class=HTMLResponse)
|
||||||
async def dashboard_control_config(request: Request) -> HTMLResponse:
|
async def dashboard_control_config(request: Request) -> HTMLResponse:
|
||||||
controls = _dashboard_controls_state(request)
|
return await _dashboard_ctl_cfg(request)
|
||||||
settings = request.app.state.settings
|
|
||||||
form = _parse_form_body(await request.body())
|
|
||||||
|
|
||||||
if "trade_capital_usd" in form and form["trade_capital_usd"]:
|
|
||||||
settings.trade_capital_usd = float(form["trade_capital_usd"])
|
|
||||||
if "max_trade_capital_usd" in form:
|
|
||||||
max_trade_capital_value = form["max_trade_capital_usd"].strip()
|
|
||||||
settings.max_trade_capital_usd = (
|
|
||||||
float(max_trade_capital_value) if max_trade_capital_value else None
|
|
||||||
)
|
|
||||||
if "max_concurrent_trades" in form:
|
|
||||||
max_concurrent_value = form["max_concurrent_trades"].strip()
|
|
||||||
settings.max_concurrent_trades = int(max_concurrent_value) if max_concurrent_value else None
|
|
||||||
|
|
||||||
settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode"))
|
|
||||||
controls.mark_updated()
|
|
||||||
|
|
||||||
notifier = _alert_notifier(request)
|
|
||||||
if notifier is not None:
|
|
||||||
await notifier.notify(
|
|
||||||
category="system",
|
|
||||||
severity="info",
|
|
||||||
title="Runtime config updated",
|
|
||||||
message="Dashboard control updated runtime risk and execution settings.",
|
|
||||||
details={
|
|
||||||
"trade_capital_usd": f"{settings.trade_capital_usd}",
|
|
||||||
"max_trade_capital_usd": (
|
|
||||||
"none"
|
|
||||||
if settings.max_trade_capital_usd is None
|
|
||||||
else f"{settings.max_trade_capital_usd}"
|
|
||||||
),
|
|
||||||
"max_concurrent_trades": (
|
|
||||||
"none"
|
|
||||||
if settings.max_concurrent_trades is None
|
|
||||||
else f"{settings.max_concurrent_trades}"
|
|
||||||
),
|
|
||||||
"paper_trading_mode": "true" if settings.paper_trading_mode else "false",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_record_audit(
|
|
||||||
request,
|
|
||||||
actor="dashboard_user",
|
|
||||||
event_type="dashboard.control.config",
|
|
||||||
decision="approved",
|
|
||||||
payload={
|
|
||||||
"trade_capital_usd": settings.trade_capital_usd,
|
|
||||||
"max_trade_capital_usd": settings.max_trade_capital_usd,
|
|
||||||
"max_concurrent_trades": settings.max_concurrent_trades,
|
|
||||||
"paper_trading_mode": settings.paper_trading_mode,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="partials/controls.html",
|
|
||||||
context={"request": request, **_dashboard_controls(request)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/stream/metrics")
|
@router.get("/dashboard/stream/metrics")
|
||||||
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
||||||
|
metrics = await _dashboard_metrics(request)
|
||||||
fragment = (
|
fragment = (
|
||||||
templates.get_template("partials/metrics.html")
|
templates.get_template("partials/metrics.html")
|
||||||
.render(
|
.render(
|
||||||
request=request,
|
request=request,
|
||||||
**_dashboard_metrics(request),
|
**metrics,
|
||||||
)
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.replace("\n", "")
|
.replace("\n", "")
|
||||||
@@ -580,9 +340,10 @@ async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
|||||||
|
|
||||||
@router.get("/dashboard/stream/overview")
|
@router.get("/dashboard/stream/overview")
|
||||||
async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
||||||
|
overview = await _dashboard_overview(request)
|
||||||
fragment = (
|
fragment = (
|
||||||
templates.get_template("partials/overview.html")
|
templates.get_template("partials/overview.html")
|
||||||
.render(request=request, **_dashboard_overview(request))
|
.render(request=request, **overview)
|
||||||
.strip()
|
.strip()
|
||||||
.replace("\n", "")
|
.replace("\n", "")
|
||||||
)
|
)
|
||||||
@@ -597,3 +358,127 @@ async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
|||||||
@public_router.get("/health", response_class=JSONResponse)
|
@public_router.get("/health", response_class=JSONResponse)
|
||||||
async def health() -> JSONResponse:
|
async def health() -> JSONResponse:
|
||||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pairing API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/api/pairings", response_class=JSONResponse)
|
||||||
|
async def dashboard_api_pairings(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
enabled: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
base: str | None = None,
|
||||||
|
quote: str | None = None,
|
||||||
|
sort: str = "base_asset",
|
||||||
|
order: str = "asc",
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""List pairings with optional filters."""
|
||||||
|
return await _dashboard_pairings_response(
|
||||||
|
request,
|
||||||
|
search=search,
|
||||||
|
enabled=enabled,
|
||||||
|
source=source,
|
||||||
|
base=base,
|
||||||
|
quote=quote,
|
||||||
|
sort=sort,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/pairings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_pairings_fragment(
|
||||||
|
request: Request,
|
||||||
|
search: str | None = None,
|
||||||
|
enabled: str | None = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""HTMX fragment: pairing table for config page."""
|
||||||
|
repo = _pairing_repo(request)
|
||||||
|
pairings = await repo.list_pairings()
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
sl = search.lower()
|
||||||
|
pairings = [
|
||||||
|
p for p in pairings if sl in p.base_asset.lower() or sl in p.quote_asset.lower()
|
||||||
|
]
|
||||||
|
if enabled is not None and enabled.lower() != "all":
|
||||||
|
eb = enabled.lower() == "true"
|
||||||
|
pairings = [p for p in pairings if p.enabled == eb]
|
||||||
|
|
||||||
|
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/pairings_table.html",
|
||||||
|
context={"request": request, "pairings": pairings},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/api/pairings/toggle")
|
||||||
|
async def dashboard_api_pairings_toggle(request: Request) -> HTMLResponse:
|
||||||
|
return await _toggle_pairing(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/api/pairings/sync", response_class=HTMLResponse)
|
||||||
|
async def dashboard_api_pairings_sync(request: Request) -> HTMLResponse:
|
||||||
|
"""Sync pairings from Kraken and return refreshed table."""
|
||||||
|
|
||||||
|
store = request.app.state.store
|
||||||
|
kraken = getattr(request.app.state, "kraken_client", None)
|
||||||
|
if kraken is not None:
|
||||||
|
await sync_pairings_from_kraken(kraken, store)
|
||||||
|
repo = _pairing_repo(request)
|
||||||
|
pairings = await repo.list_pairings()
|
||||||
|
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/pairings_table.html",
|
||||||
|
context={"request": request, "pairings": pairings},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Log routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/logs", response_class=HTMLResponse)
|
||||||
|
async def dashboard_logs_fragment(
|
||||||
|
request: Request,
|
||||||
|
level: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""HTMX fragment: log table for health page."""
|
||||||
|
repo = LogRepository(request.app.state.store)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
records = await repo.query(level=level, limit=per_page, offset=offset)
|
||||||
|
total = await repo.count_filtered(level=level)
|
||||||
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/log_table.html",
|
||||||
|
context={
|
||||||
|
"request": request,
|
||||||
|
"records": records,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"total": total,
|
||||||
|
"current_level": level or "all",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/api/logging/aggregate", response_class=JSONResponse)
|
||||||
|
async def dashboard_logging_aggregate(request: Request) -> JSONResponse:
|
||||||
|
from arbitrade.logging.maintenance import run_log_aggregation
|
||||||
|
|
||||||
|
await run_log_aggregation(request.app.state.store)
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboard/api/logging/archive", response_class=JSONResponse)
|
||||||
|
async def dashboard_logging_archive(request: Request) -> JSONResponse:
|
||||||
|
from arbitrade.logging.maintenance import run_log_archive
|
||||||
|
|
||||||
|
count = await run_log_archive(request.app.state.store)
|
||||||
|
return JSONResponse({"status": "ok", "archived": count})
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ from arbitrade.backtesting.replay import (
|
|||||||
ReplayClock,
|
ReplayClock,
|
||||||
load_replay_events,
|
load_replay_events,
|
||||||
)
|
)
|
||||||
|
from arbitrade.backtesting.sweep import (
|
||||||
|
PromotionCriteria,
|
||||||
|
SweepArtifacts,
|
||||||
|
SweepParameters,
|
||||||
|
SweepResult,
|
||||||
|
build_parameter_grid,
|
||||||
|
persist_sweep_results,
|
||||||
|
run_parameter_search,
|
||||||
|
split_events_time_windows,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ReplayClock",
|
"ReplayClock",
|
||||||
@@ -14,4 +24,12 @@ __all__ = [
|
|||||||
"BacktestReport",
|
"BacktestReport",
|
||||||
"BacktestReplayEngine",
|
"BacktestReplayEngine",
|
||||||
"load_replay_events",
|
"load_replay_events",
|
||||||
|
"SweepParameters",
|
||||||
|
"SweepResult",
|
||||||
|
"SweepArtifacts",
|
||||||
|
"PromotionCriteria",
|
||||||
|
"split_events_time_windows",
|
||||||
|
"build_parameter_grid",
|
||||||
|
"run_parameter_search",
|
||||||
|
"persist_sweep_results",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
|||||||
from arbitrade.market_data.order_book import OrderBook
|
from arbitrade.market_data.order_book import OrderBook
|
||||||
from arbitrade.risk.pre_trade import PreTradeValidator
|
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -56,7 +57,7 @@ class ReplayBookEvent:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BacktestConfig:
|
class BacktestConfig:
|
||||||
fee_rate: float = 0.0026
|
fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
|
||||||
min_profit_threshold: float = 0.0005
|
min_profit_threshold: float = 0.0005
|
||||||
trade_capital: float = 100.0
|
trade_capital: float = 100.0
|
||||||
quote_asset: str = "USD"
|
quote_asset: str = "USD"
|
||||||
@@ -153,8 +154,7 @@ def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
|
|||||||
or not isinstance(raw_level[1], int | float)
|
or not isinstance(raw_level[1], int | float)
|
||||||
):
|
):
|
||||||
raise ValueError("Each level must be [price, volume]")
|
raise ValueError("Each level must be [price, volume]")
|
||||||
levels.append(BookLevel(price=float(
|
levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
|
||||||
raw_level[0]), volume=float(raw_level[1])))
|
|
||||||
|
|
||||||
return tuple(levels)
|
return tuple(levels)
|
||||||
|
|
||||||
@@ -173,8 +173,7 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
|
|||||||
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
|
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
|
||||||
raise ValueError("Each event must include timestamp and symbol")
|
raise ValueError("Each event must include timestamp and symbol")
|
||||||
|
|
||||||
occurred_at = datetime.fromisoformat(
|
occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
|
||||||
timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
|
|
||||||
events.append(
|
events.append(
|
||||||
ReplayBookEvent(
|
ReplayBookEvent(
|
||||||
occurred_at=occurred_at,
|
occurred_at=occurred_at,
|
||||||
@@ -187,6 +186,89 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
|
|||||||
return sorted(events, key=lambda event: event.occurred_at)
|
return sorted(events, key=lambda event: event.occurred_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_replay_events_from_db(
|
||||||
|
store: PgStore,
|
||||||
|
*,
|
||||||
|
symbols: list[str] | None = None,
|
||||||
|
start: datetime | None = None,
|
||||||
|
end: datetime | None = None,
|
||||||
|
) -> list[ReplayBookEvent]:
|
||||||
|
"""Load replay events from market_snapshots table.
|
||||||
|
|
||||||
|
Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS).
|
||||||
|
Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]}
|
||||||
|
"""
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1"
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if symbols:
|
||||||
|
placeholders = ",".join(f"${i+1}" for i in range(len(symbols)))
|
||||||
|
query += f" AND symbol IN ({placeholders})"
|
||||||
|
params.extend(symbols)
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
params.append(start)
|
||||||
|
query += f" AND snapshot_at >= ${len(params)}"
|
||||||
|
|
||||||
|
if end is not None:
|
||||||
|
params.append(end)
|
||||||
|
query += f" AND snapshot_at <= ${len(params)}"
|
||||||
|
|
||||||
|
query += " ORDER BY snapshot_at ASC"
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
|
||||||
|
events: list[ReplayBookEvent] = []
|
||||||
|
for row in rows:
|
||||||
|
snapshot_at: datetime = row["snapshot_at"]
|
||||||
|
symbol: str = row["symbol"]
|
||||||
|
payload_raw = row["payload"]
|
||||||
|
|
||||||
|
if isinstance(payload_raw, str):
|
||||||
|
payload = orjson.loads(payload_raw)
|
||||||
|
elif isinstance(payload_raw, dict):
|
||||||
|
payload = payload_raw
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first = data[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
bids = _parse_kraken_book_levels(first.get("bids"))
|
||||||
|
asks = _parse_kraken_book_levels(first.get("asks"))
|
||||||
|
|
||||||
|
if bids or asks:
|
||||||
|
events.append(
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=snapshot_at,
|
||||||
|
symbol=symbol,
|
||||||
|
bids=bids,
|
||||||
|
asks=asks,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kraken_book_levels(
|
||||||
|
raw_levels: object | None,
|
||||||
|
) -> tuple[BookLevel, ...]:
|
||||||
|
"""Parse Kraken WS book level format: [{price, qty}, ...]."""
|
||||||
|
if not isinstance(raw_levels, list):
|
||||||
|
return ()
|
||||||
|
levels: list[BookLevel] = []
|
||||||
|
for level in raw_levels:
|
||||||
|
if isinstance(level, dict) and "price" in level and "qty" in level:
|
||||||
|
levels.append(BookLevel(price=float(level["price"]), volume=float(level["qty"])))
|
||||||
|
return tuple(levels)
|
||||||
|
|
||||||
|
|
||||||
class BacktestReplayEngine:
|
class BacktestReplayEngine:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -208,8 +290,7 @@ class BacktestReplayEngine:
|
|||||||
min_order_size_by_pair=config.min_order_size_by_pair,
|
min_order_size_by_pair=config.min_order_size_by_pair,
|
||||||
)
|
)
|
||||||
self._pre_trade = PreTradeValidator()
|
self._pre_trade = PreTradeValidator()
|
||||||
self._trade_limits = TradeLimitsGuard(
|
self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
|
||||||
max_concurrent_trades=config.max_concurrent_trades)
|
|
||||||
self._simulated_rest = _SimulatedRestClient(
|
self._simulated_rest = _SimulatedRestClient(
|
||||||
self._clock,
|
self._clock,
|
||||||
slippage_bps=config.slippage_bps,
|
slippage_bps=config.slippage_bps,
|
||||||
@@ -244,8 +325,7 @@ class BacktestReplayEngine:
|
|||||||
trades_executed = 0
|
trades_executed = 0
|
||||||
|
|
||||||
realized_pnl = 0.0
|
realized_pnl = 0.0
|
||||||
equity = float(starting_balances.get(
|
equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
|
||||||
self._config.quote_asset.upper(), 0.0))
|
|
||||||
peak_equity = equity
|
peak_equity = equity
|
||||||
max_drawdown = 0.0
|
max_drawdown = 0.0
|
||||||
|
|
||||||
@@ -288,8 +368,7 @@ class BacktestReplayEngine:
|
|||||||
result = await self._sequencer.execute(opportunity)
|
result = await self._sequencer.execute(opportunity)
|
||||||
self._trade_limits.close_trade(exposure)
|
self._trade_limits.close_trade(exposure)
|
||||||
|
|
||||||
execution_latencies.append(
|
execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
|
||||||
self._simulated_rest.last_trade_latency_ms)
|
|
||||||
fill_samples.append(self._simulated_rest.last_fill_ratio)
|
fill_samples.append(self._simulated_rest.last_fill_ratio)
|
||||||
|
|
||||||
if not result.success:
|
if not result.success:
|
||||||
@@ -312,8 +391,7 @@ class BacktestReplayEngine:
|
|||||||
|
|
||||||
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
|
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
|
||||||
win_rate = (wins / len(realized_samples)) if realized_samples else None
|
win_rate = (wins / len(realized_samples)) if realized_samples else None
|
||||||
fill_rate = (sum(fill_samples) / len(fill_samples)
|
fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
|
||||||
) if fill_samples else None
|
|
||||||
|
|
||||||
return BacktestReport(
|
return BacktestReport(
|
||||||
started_at=events[0].occurred_at if events else self._clock.now,
|
started_at=events[0].occurred_at if events else self._clock.now,
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pydantic import Field, field_validator, model_validator
|
from pydantic import Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -17,7 +16,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||||
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
|
||||||
app_port: int = Field(default=8000, alias="APP_PORT")
|
app_port: int = Field(default=9090, alias="APP_PORT")
|
||||||
|
|
||||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||||
log_json: bool = Field(default=True, alias="LOG_JSON")
|
log_json: bool = Field(default=True, alias="LOG_JSON")
|
||||||
@@ -55,7 +54,14 @@ class Settings(BaseSettings):
|
|||||||
email_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
|
email_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
|
||||||
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
|
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
|
||||||
|
|
||||||
duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
|
# PostgreSQL connection settings
|
||||||
|
pg_host: str = Field(default="192.168.88.35", alias="PG_HOST")
|
||||||
|
pg_port: int = Field(default=5432, alias="PG_PORT")
|
||||||
|
pg_database: str = Field(default="arbitrade", alias="PG_DATABASE")
|
||||||
|
pg_user: str = Field(default="arbitrade", alias="PG_USER")
|
||||||
|
pg_password: str = Field(default="arbitrade", alias="PG_PASSWORD")
|
||||||
|
pg_min_connections: int = Field(default=2, alias="PG_MIN_CONNECTIONS")
|
||||||
|
pg_max_connections: int = Field(default=10, alias="PG_MAX_CONNECTIONS")
|
||||||
|
|
||||||
kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
|
kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
|
||||||
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
|
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
|
||||||
@@ -75,6 +81,26 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
|
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
|
||||||
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
|
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
|
||||||
|
strategy_enable_stat_arb_experiment: bool = Field(
|
||||||
|
default=False,
|
||||||
|
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
|
||||||
|
)
|
||||||
|
strategy_stat_arb_lookback_window: int = Field(
|
||||||
|
default=120,
|
||||||
|
alias="STRATEGY_STAT_ARB_LOOKBACK_WINDOW",
|
||||||
|
)
|
||||||
|
strategy_stat_arb_entry_zscore: float = Field(
|
||||||
|
default=2.0,
|
||||||
|
alias="STRATEGY_STAT_ARB_ENTRY_ZSCORE",
|
||||||
|
)
|
||||||
|
strategy_stat_arb_exit_zscore: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
alias="STRATEGY_STAT_ARB_EXIT_ZSCORE",
|
||||||
|
)
|
||||||
|
strategy_stat_arb_max_holding_seconds: float = Field(
|
||||||
|
default=900.0,
|
||||||
|
alias="STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS",
|
||||||
|
)
|
||||||
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
|
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
|
||||||
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
|
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
|
||||||
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
|
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
|
||||||
@@ -139,6 +165,19 @@ class Settings(BaseSettings):
|
|||||||
if self.alert_dedup_seconds < 0.0:
|
if self.alert_dedup_seconds < 0.0:
|
||||||
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
|
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
|
||||||
|
|
||||||
|
if self.strategy_stat_arb_lookback_window < 2:
|
||||||
|
raise ValueError("STRATEGY_STAT_ARB_LOOKBACK_WINDOW must be >= 2")
|
||||||
|
if self.strategy_stat_arb_entry_zscore <= 0.0:
|
||||||
|
raise ValueError("STRATEGY_STAT_ARB_ENTRY_ZSCORE must be > 0")
|
||||||
|
if self.strategy_stat_arb_exit_zscore < 0.0:
|
||||||
|
raise ValueError("STRATEGY_STAT_ARB_EXIT_ZSCORE must be >= 0")
|
||||||
|
if self.strategy_stat_arb_entry_zscore <= self.strategy_stat_arb_exit_zscore:
|
||||||
|
raise ValueError(
|
||||||
|
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
|
||||||
|
)
|
||||||
|
if self.strategy_stat_arb_max_holding_seconds <= 0.0:
|
||||||
|
raise ValueError("STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -47,7 +47,7 @@ def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, Ord
|
|||||||
|
|
||||||
detector = IncrementalCycleDetector(
|
detector = IncrementalCycleDetector(
|
||||||
index,
|
index,
|
||||||
fee_rate=0.001,
|
fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
|
||||||
min_profit_threshold=0.001,
|
min_profit_threshold=0.001,
|
||||||
max_depth_levels=5,
|
max_depth_levels=5,
|
||||||
max_book_age_seconds=10.0,
|
max_book_age_seconds=10.0,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -279,3 +279,34 @@ class KrakenRestClient:
|
|||||||
"/0/private/CancelOrder",
|
"/0/private/CancelOrder",
|
||||||
data={"txid": order_id},
|
data={"txid": order_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def trade_volume(self, *, pair: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Query Kraken TradeVolume for fee tier, 30d volume, and fee schedule.
|
||||||
|
|
||||||
|
Returns dict with keys: currency, volume, fees (dict of tiers),
|
||||||
|
fees_maker (dict of tier->fee mappings), fee_tier (current tier).
|
||||||
|
If pair provided, returns pair-specific fee info.
|
||||||
|
"""
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
if pair is not None:
|
||||||
|
data["pair"] = pair
|
||||||
|
return await self._throttled_private_call(
|
||||||
|
"/0/private/TradeVolume",
|
||||||
|
data=data if data else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def trade_balance(self, *, asset: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Query Kraken TradeBalance for equity, trade balance, margin info.
|
||||||
|
|
||||||
|
Returns dict with keys: eb (equivalent balance/equity),
|
||||||
|
tb (trade balance), m (margin amount), n (unrealized net P&L),
|
||||||
|
c (cost basis), v (current valuation), e (equity).
|
||||||
|
If asset provided, returns asset-class-specific balance.
|
||||||
|
"""
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
if asset is not None:
|
||||||
|
data["asset"] = asset
|
||||||
|
return await self._throttled_private_call(
|
||||||
|
"/0/private/TradeBalance",
|
||||||
|
data=data if data else None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class KrakenWsClient:
|
|||||||
self._alert_notifier = alert_notifier
|
self._alert_notifier = alert_notifier
|
||||||
self._has_connected_once = False
|
self._has_connected_once = False
|
||||||
self._was_disconnected = False
|
self._was_disconnected = False
|
||||||
|
self._subscribed_symbols: list[str] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_stale(self) -> bool:
|
def is_stale(self) -> bool:
|
||||||
@@ -44,29 +45,63 @@ class KrakenWsClient:
|
|||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
|
||||||
|
def set_subscribed_symbols(self, symbols: list[str]) -> None:
|
||||||
|
"""Set the list of symbols to subscribe to on (re)connect."""
|
||||||
|
self._subscribed_symbols = list(symbols)
|
||||||
|
|
||||||
|
async def _subscribe(self, ws: Any) -> None:
|
||||||
|
"""Send Kraken WS v2 subscribe message for book channel."""
|
||||||
|
if not self._subscribed_symbols:
|
||||||
|
_LOG.warning("kraken_ws_no_symbols_to_subscribe")
|
||||||
|
return
|
||||||
|
depth = 10
|
||||||
|
if hasattr(self._settings, "kraken_ws_book_depth"):
|
||||||
|
depth = self._settings.kraken_ws_book_depth
|
||||||
|
msg = orjson.dumps(
|
||||||
|
{
|
||||||
|
"method": "subscribe",
|
||||||
|
"params": {
|
||||||
|
"channel": "book",
|
||||||
|
"symbol": self._subscribed_symbols,
|
||||||
|
"depth": depth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await ws.send(msg)
|
||||||
|
_LOG.info(
|
||||||
|
"kraken_ws_subscribed",
|
||||||
|
symbol_count=len(self._subscribed_symbols),
|
||||||
|
symbols=self._subscribed_symbols,
|
||||||
|
)
|
||||||
|
|
||||||
async def connect_stream(self) -> AsyncIterator[WsMessage]:
|
async def connect_stream(self) -> AsyncIterator[WsMessage]:
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
url = self._settings.kraken_ws_url
|
||||||
self._settings.kraken_ws_url, max_size=2_000_000
|
async with websockets.connect(url, max_size=2_000_000) as ws:
|
||||||
) as ws:
|
_LOG.info("kraken_ws_connected", url=url)
|
||||||
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
|
|
||||||
if self._has_connected_once and self._was_disconnected:
|
if self._has_connected_once and self._was_disconnected:
|
||||||
await self._notify(
|
await self._notify(
|
||||||
category="system",
|
category="system",
|
||||||
severity="info",
|
severity="info",
|
||||||
title="WebSocket reconnected",
|
title="WebSocket reconnected",
|
||||||
message="Kraken WebSocket connection restored.",
|
message="Kraken WebSocket connection restored.",
|
||||||
details={"url": self._settings.kraken_ws_url},
|
details={"url": url},
|
||||||
)
|
)
|
||||||
self._has_connected_once = True
|
self._has_connected_once = True
|
||||||
self._was_disconnected = False
|
self._was_disconnected = False
|
||||||
delay = 1.0
|
delay = 1.0
|
||||||
|
await self._subscribe(ws)
|
||||||
async for raw in self._recv_loop(ws):
|
async for raw in self._recv_loop(ws):
|
||||||
yield raw
|
yield raw
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay)
|
log = (
|
||||||
|
"kraken_ws_disconnected_first_time"
|
||||||
|
if not self._has_connected_once
|
||||||
|
else "kraken_ws_disconnected"
|
||||||
|
)
|
||||||
|
_LOG.warning(log, error=str(exc), reconnect_in=delay)
|
||||||
self._was_disconnected = True
|
self._was_disconnected = True
|
||||||
await self._notify(
|
await self._notify(
|
||||||
category="system",
|
category="system",
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class TriangularExecutionSequencer:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
@@ -265,7 +265,7 @@ class TriangularExecutionSequencer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
|
|||||||
@@ -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
|
import structlog
|
||||||
|
|
||||||
|
from arbitrade.logging.db_sink import db_sink_processor
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
|
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
|
||||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||||
@@ -17,6 +19,7 @@ def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
|
|||||||
structlog.stdlib.add_log_level,
|
structlog.stdlib.add_log_level,
|
||||||
structlog.stdlib.add_logger_name,
|
structlog.stdlib.add_logger_name,
|
||||||
timestamper,
|
timestamper,
|
||||||
|
db_sink_processor,
|
||||||
]
|
]
|
||||||
|
|
||||||
if json_logs:
|
if json_logs:
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class MarketDataFeed:
|
|||||||
symbol=delta.symbol,
|
symbol=delta.symbol,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -172,7 +172,7 @@ class MarketDataFeed:
|
|||||||
|
|
||||||
for event in opportunities:
|
for event in opportunities:
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="detector",
|
actor="detector",
|
||||||
@@ -207,7 +207,7 @@ class MarketDataFeed:
|
|||||||
net_pct=event.net_pct,
|
net_pct=event.net_pct,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
@@ -228,7 +228,7 @@ class MarketDataFeed:
|
|||||||
updated_pair=event.updated_pair,
|
updated_pair=event.updated_pair,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
@@ -250,7 +250,7 @@ class MarketDataFeed:
|
|||||||
reason=self._kill_switch.reason,
|
reason=self._kill_switch.reason,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -275,7 +275,7 @@ class MarketDataFeed:
|
|||||||
reason=self._stop_conditions_guard.halted_reason,
|
reason=self._stop_conditions_guard.halted_reason,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -298,7 +298,7 @@ class MarketDataFeed:
|
|||||||
reason=self._loss_limit_guard.halted_reason,
|
reason=self._loss_limit_guard.halted_reason,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -329,7 +329,7 @@ class MarketDataFeed:
|
|||||||
required_by_asset=required_balances,
|
required_by_asset=required_balances,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -358,7 +358,7 @@ class MarketDataFeed:
|
|||||||
exposure_by_asset=exposure_by_asset,
|
exposure_by_asset=exposure_by_asset,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="risk_manager",
|
actor="risk_manager",
|
||||||
@@ -420,7 +420,7 @@ class MarketDataFeed:
|
|||||||
updated_pair=event.updated_pair,
|
updated_pair=event.updated_pair,
|
||||||
)
|
)
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
@@ -459,7 +459,7 @@ class MarketDataFeed:
|
|||||||
self._trade_limits_guard.close_trade(exposure_by_asset)
|
self._trade_limits_guard.close_trade(exposure_by_asset)
|
||||||
|
|
||||||
if self._audit_repository is not None:
|
if self._audit_repository is not None:
|
||||||
self._audit_repository.insert(
|
await self._audit_repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="execution_engine",
|
actor="execution_engine",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Build production MarketDataFeed components from enabled pairings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from arbitrade.detection.engine import IncrementalCycleDetector
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
from arbitrade.storage.repositories import ConfigPairingRepository
|
||||||
|
|
||||||
|
_LOG = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_detector_from_enabled_pairings(
|
||||||
|
store: PgStore,
|
||||||
|
*,
|
||||||
|
fee_rate: float = 0.0,
|
||||||
|
max_depth_levels: int = 10,
|
||||||
|
min_profit_threshold: float = 0.0005,
|
||||||
|
) -> IncrementalCycleDetector | None:
|
||||||
|
"""Build an IncrementalCycleDetector using only enabled pairings from DB.
|
||||||
|
|
||||||
|
Returns None if no enabled pairings exist.
|
||||||
|
"""
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = await repo.list_pairings(enabled_only=True)
|
||||||
|
if not pairings:
|
||||||
|
_LOG.warning("no_enabled_pairings_found_detector_not_created")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build CurrencyGraph from enabled pairings and discover cycles
|
||||||
|
graph = CurrencyGraph()
|
||||||
|
for p in pairings:
|
||||||
|
symbol = f"{p.base_asset}/{p.quote_asset}"
|
||||||
|
graph.add_pair(p.base_asset, p.quote_asset, symbol)
|
||||||
|
|
||||||
|
cycles = graph.triangular_cycles()
|
||||||
|
if not cycles:
|
||||||
|
_LOG.warning("no_triangular_cycles_from_enabled_pairings")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cycles_by_pair = graph.index_cycles_by_pair(cycles)
|
||||||
|
_LOG.info(
|
||||||
|
"detector_built_from_enabled_pairings",
|
||||||
|
enabled_count=len(pairings),
|
||||||
|
cycle_count=len(cycles),
|
||||||
|
)
|
||||||
|
|
||||||
|
return IncrementalCycleDetector(
|
||||||
|
cycles_by_pair,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
max_depth_levels=max_depth_levels,
|
||||||
|
min_profit_threshold=min_profit_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_enabled_pair_symbols(store: PgStore) -> list[str]:
|
||||||
|
"""Return list of enabled pair symbols (e.g. ['BTC/USD', 'ETH/BTC'])."""
|
||||||
|
repo = ConfigPairingRepository(store)
|
||||||
|
pairings = await repo.list_pairings(enabled_only=True)
|
||||||
|
return [f"{p.base_asset}/{p.quote_asset}" for p in pairings if p.enabled]
|
||||||
+59
-70
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -19,114 +19,103 @@ class PerformanceMetrics:
|
|||||||
|
|
||||||
|
|
||||||
class MetricsCalculator:
|
class MetricsCalculator:
|
||||||
def __init__(self, store: DuckDBStore) -> None:
|
def __init__(self, store: PgStore) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
|
|
||||||
def compute(self) -> PerformanceMetrics:
|
async def compute(self) -> PerformanceMetrics:
|
||||||
with self._store.connect() as conn:
|
async with self._store.pool.acquire() as conn:
|
||||||
trade_metrics = conn.execute("""
|
tm = await conn.fetchrow("""
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
|
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
|
||||||
COUNT(*) AS total_trades,
|
COUNT(*) AS total_trades,
|
||||||
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
|
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
|
||||||
AVG(EPOCH(finished_at) - EPOCH(started_at)) AS avg_trade_duration_seconds,
|
AVG(EXTRACT(EPOCH FROM finished_at - started_at)) AS avg_trade_duration_seconds,
|
||||||
quantile_cont(
|
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p50_seconds,
|
||||||
EPOCH(finished_at) - EPOCH(started_at),
|
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p95_seconds,
|
||||||
0.50
|
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p99_seconds
|
||||||
) AS latency_p50_seconds,
|
|
||||||
quantile_cont(
|
|
||||||
EPOCH(finished_at) - EPOCH(started_at),
|
|
||||||
0.95
|
|
||||||
) AS latency_p95_seconds,
|
|
||||||
quantile_cont(
|
|
||||||
EPOCH(finished_at) - EPOCH(started_at),
|
|
||||||
0.99
|
|
||||||
) AS latency_p99_seconds
|
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE finished_at IS NOT NULL
|
WHERE finished_at IS NOT NULL
|
||||||
""").fetchone()
|
""")
|
||||||
|
|
||||||
opportunity_metrics = conn.execute("""
|
om = await conn.fetchrow("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS opportunity_count,
|
COUNT(*) AS opportunity_count,
|
||||||
MIN(detected_at) AS first_detected_at,
|
MIN(detected_at) AS first_detected_at,
|
||||||
MAX(detected_at) AS last_detected_at
|
MAX(detected_at) AS last_detected_at
|
||||||
FROM opportunities
|
FROM opportunities
|
||||||
""").fetchone()
|
""")
|
||||||
|
|
||||||
fill_metrics = conn.execute("""
|
fm = await conn.fetchrow("""
|
||||||
SELECT AVG(filled_volume / volume) AS fill_rate
|
SELECT AVG(filled_volume / volume) AS fill_rate
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE volume > 0 AND filled_volume IS NOT NULL
|
WHERE volume > 0 AND filled_volume IS NOT NULL
|
||||||
""").fetchone()
|
""")
|
||||||
|
|
||||||
realized_pnl_usd = (
|
r_pnl_usd = (
|
||||||
float(trade_metrics[0]) if trade_metrics and trade_metrics[0] is not None else 0.0
|
float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
|
||||||
)
|
)
|
||||||
total_trades = (
|
tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
|
||||||
int(trade_metrics[1]) if trade_metrics and trade_metrics[1] is not None else 0
|
wt = int(tm["winning_trades"]) if tm and tm["winning_trades"] is not None else 0
|
||||||
)
|
wr = wt / tt if tt > 0 else None
|
||||||
winning_trades = (
|
|
||||||
int(trade_metrics[2]) if trade_metrics and trade_metrics[2] is not None else 0
|
|
||||||
)
|
|
||||||
win_rate = winning_trades / total_trades if total_trades > 0 else None
|
|
||||||
|
|
||||||
avg_trade_duration_seconds = (
|
atd = (
|
||||||
float(trade_metrics[3]) if trade_metrics and trade_metrics[3] is not None else None
|
float(tm["avg_trade_duration_seconds"])
|
||||||
)
|
if tm and tm["avg_trade_duration_seconds"] is not None
|
||||||
|
|
||||||
opportunity_count = (
|
|
||||||
int(opportunity_metrics[0])
|
|
||||||
if opportunity_metrics is not None and opportunity_metrics[0] is not None
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
first_detected_at = (
|
|
||||||
opportunity_metrics[1]
|
|
||||||
if opportunity_metrics is not None and isinstance(opportunity_metrics[1], datetime)
|
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
last_detected_at = (
|
|
||||||
opportunity_metrics[2]
|
oc = (
|
||||||
if opportunity_metrics is not None and isinstance(opportunity_metrics[2], datetime)
|
int(om["opportunity_count"])
|
||||||
|
if om is not None and om["opportunity_count"] is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
fo = (
|
||||||
|
om["first_detected_at"]
|
||||||
|
if om is not None and isinstance(om["first_detected_at"], datetime)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
lo = (
|
||||||
|
om["last_detected_at"]
|
||||||
|
if om is not None and isinstance(om["last_detected_at"], datetime)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
opportunities_per_minute: float | None
|
opportunities_per_minute: float | None
|
||||||
if (
|
if oc >= 2 and fo is not None and lo is not None:
|
||||||
opportunity_count >= 2
|
span_seconds = (lo - fo).total_seconds()
|
||||||
and first_detected_at is not None
|
|
||||||
and last_detected_at is not None
|
|
||||||
):
|
|
||||||
span_seconds = (last_detected_at - first_detected_at).total_seconds()
|
|
||||||
opportunities_per_minute = (
|
opportunities_per_minute = (
|
||||||
opportunity_count / (span_seconds / 60.0)
|
oc / (span_seconds / 60.0) if span_seconds > 0.0 else float(oc)
|
||||||
if span_seconds > 0.0
|
|
||||||
else float(opportunity_count)
|
|
||||||
)
|
)
|
||||||
elif opportunity_count == 1:
|
elif oc == 1:
|
||||||
opportunities_per_minute = 60.0
|
opportunities_per_minute = 60.0
|
||||||
else:
|
else:
|
||||||
opportunities_per_minute = None
|
opportunities_per_minute = None
|
||||||
|
|
||||||
fill_rate = float(fill_metrics[0]) if fill_metrics and fill_metrics[0] is not None else None
|
fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
|
||||||
|
|
||||||
latency_p50_seconds = (
|
lp50 = (
|
||||||
float(trade_metrics[4]) if trade_metrics and trade_metrics[4] is not None else None
|
float(tm["latency_p50_seconds"])
|
||||||
|
if tm and tm["latency_p50_seconds"] is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
latency_p95_seconds = (
|
lp95 = (
|
||||||
float(trade_metrics[5]) if trade_metrics and trade_metrics[5] is not None else None
|
float(tm["latency_p95_seconds"])
|
||||||
|
if tm and tm["latency_p95_seconds"] is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
latency_p99_seconds = (
|
lp99 = (
|
||||||
float(trade_metrics[6]) if trade_metrics and trade_metrics[6] is not None else None
|
float(tm["latency_p99_seconds"])
|
||||||
|
if tm and tm["latency_p99_seconds"] is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return PerformanceMetrics(
|
return PerformanceMetrics(
|
||||||
realized_pnl_usd=realized_pnl_usd,
|
realized_pnl_usd=r_pnl_usd,
|
||||||
win_rate=win_rate,
|
win_rate=wr,
|
||||||
avg_trade_duration_seconds=avg_trade_duration_seconds,
|
avg_trade_duration_seconds=atd,
|
||||||
opportunities_per_minute=opportunities_per_minute,
|
opportunities_per_minute=opportunities_per_minute,
|
||||||
fill_rate=fill_rate,
|
fill_rate=fill_rate,
|
||||||
latency_p50_seconds=latency_p50_seconds,
|
latency_p50_seconds=lp50,
|
||||||
latency_p95_seconds=latency_p95_seconds,
|
latency_p95_seconds=lp95,
|
||||||
latency_p99_seconds=latency_p99_seconds,
|
latency_p99_seconds=lp99,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.api.control_state import DashboardControlState
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.pg_store import PgStore
|
||||||
from arbitrade.storage.repositories import (
|
from arbitrade.storage.repositories import (
|
||||||
AuditRecord,
|
AuditRecord,
|
||||||
AuditRepository,
|
AuditRepository,
|
||||||
@@ -29,8 +29,8 @@ def _controls(app: FastAPI) -> DashboardControlState:
|
|||||||
return cast(DashboardControlState, app.state.dashboard_controls)
|
return cast(DashboardControlState, app.state.dashboard_controls)
|
||||||
|
|
||||||
|
|
||||||
def _store(app: FastAPI) -> DuckDBStore:
|
def _store(app: FastAPI) -> PgStore:
|
||||||
return cast(DuckDBStore, app.state.store)
|
return cast(PgStore, app.state.store)
|
||||||
|
|
||||||
|
|
||||||
def _audit_repository(app: FastAPI) -> AuditRepository | None:
|
def _audit_repository(app: FastAPI) -> AuditRepository | None:
|
||||||
@@ -43,34 +43,34 @@ def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
|
|||||||
return repository if isinstance(repository, RuntimeStateRepository) else None
|
return repository if isinstance(repository, RuntimeStateRepository) else None
|
||||||
|
|
||||||
|
|
||||||
def _open_trade_count(store: DuckDBStore) -> int:
|
async def _open_trade_count(store: PgStore) -> int:
|
||||||
with store.connect() as conn:
|
async with store.pool.acquire() as conn:
|
||||||
row = conn.execute("""
|
row = await conn.fetchrow("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE finished_at IS NULL
|
WHERE finished_at IS NULL
|
||||||
""").fetchone()
|
""")
|
||||||
return int(row[0]) if row is not None else 0
|
return int(row[0]) if row is not None else 0
|
||||||
|
|
||||||
|
|
||||||
def _latest_balances(store: DuckDBStore) -> dict[str, Any] | None:
|
async def _latest_balances(store: PgStore) -> dict[str, Any] | None:
|
||||||
with store.connect() as conn:
|
async with store.pool.acquire() as conn:
|
||||||
row = conn.execute("""
|
row = await conn.fetchrow("""
|
||||||
SELECT balances
|
SELECT balances
|
||||||
FROM portfolio_snapshots
|
FROM portfolio_snapshots
|
||||||
ORDER BY snapshot_at DESC
|
ORDER BY snapshot_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""").fetchone()
|
""")
|
||||||
|
|
||||||
if row is None or row[0] is None:
|
if row is None or row["balances"] is None:
|
||||||
return None
|
return None
|
||||||
raw_balances = row[0]
|
raw_balances = row["balances"]
|
||||||
if isinstance(raw_balances, str):
|
if isinstance(raw_balances, str):
|
||||||
return {"raw": raw_balances}
|
return {"raw": raw_balances}
|
||||||
return {"raw": str(raw_balances)}
|
return {"raw": str(raw_balances)}
|
||||||
|
|
||||||
|
|
||||||
def _record_audit(
|
async def _record_audit(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
*,
|
*,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
@@ -80,7 +80,7 @@ def _record_audit(
|
|||||||
repository = _audit_repository(app)
|
repository = _audit_repository(app)
|
||||||
if repository is None:
|
if repository is None:
|
||||||
return
|
return
|
||||||
repository.insert(
|
await repository.insert(
|
||||||
AuditRecord(
|
AuditRecord(
|
||||||
occurred_at=datetime.now(UTC),
|
occurred_at=datetime.now(UTC),
|
||||||
actor="runtime",
|
actor="runtime",
|
||||||
@@ -106,7 +106,9 @@ async def _run_startup_reconciler(app: FastAPI) -> None:
|
|||||||
await result
|
await result
|
||||||
|
|
||||||
|
|
||||||
def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> RuntimeStateRecord | None:
|
async def persist_runtime_snapshot(
|
||||||
|
app: FastAPI, *, note: str | None = None
|
||||||
|
) -> RuntimeStateRecord | None:
|
||||||
repository = _runtime_repository(app)
|
repository = _runtime_repository(app)
|
||||||
if repository is None:
|
if repository is None:
|
||||||
return None
|
return None
|
||||||
@@ -118,42 +120,41 @@ def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> Runtim
|
|||||||
is_running=controls.is_running,
|
is_running=controls.is_running,
|
||||||
kill_switch_active=controls.kill_switch.is_active,
|
kill_switch_active=controls.kill_switch.is_active,
|
||||||
kill_switch_reason=controls.kill_switch.reason,
|
kill_switch_reason=controls.kill_switch.reason,
|
||||||
open_trade_count=_open_trade_count(store),
|
open_trade_count=await _open_trade_count(store),
|
||||||
last_known_balances=_latest_balances(store),
|
last_known_balances=await _latest_balances(store),
|
||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
repository.insert(snapshot)
|
await repository.insert(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
|
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
|
||||||
controls = _controls(app)
|
ctl = _controls(app)
|
||||||
store = _store(app)
|
store = _store(app)
|
||||||
runtime_repository = _runtime_repository(app)
|
repo = _runtime_repository(app)
|
||||||
|
|
||||||
restored_from_snapshot = False
|
restored_from_snapshot = False
|
||||||
snapshot_at: str | None = None
|
snapshot_at: str | None = None
|
||||||
|
|
||||||
latest = runtime_repository.latest() if runtime_repository is not None else None
|
latest = await repo.latest() if repo is not None else None
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
restored_from_snapshot = True
|
restored_from_snapshot = True
|
||||||
snapshot_at = latest.snapshot_at.isoformat()
|
snapshot_at = latest.snapshot_at.isoformat()
|
||||||
controls.is_running = latest.is_running
|
ctl.is_running = latest.is_running
|
||||||
if latest.kill_switch_active:
|
if latest.kill_switch_active:
|
||||||
controls.kill_switch.activate(
|
r = latest.kill_switch_reason or "recovered"
|
||||||
reason=latest.kill_switch_reason or "recovered")
|
ctl.kill_switch.activate(reason=r)
|
||||||
else:
|
else:
|
||||||
controls.kill_switch.deactivate()
|
ctl.kill_switch.deactivate()
|
||||||
controls.mark_updated()
|
ctl.mark_updated()
|
||||||
|
|
||||||
open_trades = _open_trade_count(store)
|
open_trades = await _open_trade_count(store)
|
||||||
restart_guard_active = False
|
restart_guard_active = False
|
||||||
if open_trades > 0:
|
if open_trades > 0:
|
||||||
controls.is_running = False
|
ctl.is_running = False
|
||||||
if not controls.kill_switch.is_active:
|
if not ctl.kill_switch.is_active:
|
||||||
controls.kill_switch.activate(
|
ctl.kill_switch.activate(reason="recovery_open_trades_detected")
|
||||||
reason="recovery_open_trades_detected")
|
ctl.mark_updated()
|
||||||
controls.mark_updated()
|
|
||||||
restart_guard_active = True
|
restart_guard_active = True
|
||||||
|
|
||||||
report = RuntimeRecoveryReport(
|
report = RuntimeRecoveryReport(
|
||||||
@@ -164,7 +165,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
|
|||||||
)
|
)
|
||||||
app.state.recovery_report = report
|
app.state.recovery_report = report
|
||||||
|
|
||||||
_record_audit(
|
await _record_audit(
|
||||||
app,
|
app,
|
||||||
event_type="runtime.startup_recovery",
|
event_type="runtime.startup_recovery",
|
||||||
decision="applied",
|
decision="applied",
|
||||||
@@ -213,7 +214,7 @@ async def graceful_shutdown(app: FastAPI) -> None:
|
|||||||
controls.is_running = False
|
controls.is_running = False
|
||||||
controls.mark_updated()
|
controls.mark_updated()
|
||||||
|
|
||||||
_record_audit(
|
await _record_audit(
|
||||||
app,
|
app,
|
||||||
event_type="runtime.shutdown",
|
event_type="runtime.shutdown",
|
||||||
decision="initiated",
|
decision="initiated",
|
||||||
@@ -221,4 +222,4 @@ async def graceful_shutdown(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
await drain_background_workers(app)
|
await drain_background_workers(app)
|
||||||
persist_runtime_snapshot(app, note="graceful_shutdown")
|
await persist_runtime_snapshot(app, note="graceful_shutdown")
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
|
||||||
|
|
||||||
_LOG = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at TIMESTAMP DEFAULT current_timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS opportunities (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
detected_at TIMESTAMP NOT NULL,
|
|
||||||
cycle VARCHAR NOT NULL,
|
|
||||||
gross_pct DOUBLE,
|
|
||||||
net_pct DOUBLE,
|
|
||||||
est_profit DOUBLE,
|
|
||||||
executed BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trades (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
trade_ref VARCHAR NOT NULL,
|
|
||||||
started_at TIMESTAMP NOT NULL,
|
|
||||||
finished_at TIMESTAMP,
|
|
||||||
status VARCHAR NOT NULL,
|
|
||||||
realized_pnl DOUBLE,
|
|
||||||
estimated_pnl DOUBLE,
|
|
||||||
capital_used DOUBLE,
|
|
||||||
cycle VARCHAR,
|
|
||||||
leg_count INTEGER
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
trade_ref VARCHAR NOT NULL,
|
|
||||||
order_ref VARCHAR NOT NULL,
|
|
||||||
leg_index INTEGER NOT NULL,
|
|
||||||
pair VARCHAR NOT NULL,
|
|
||||||
side VARCHAR NOT NULL,
|
|
||||||
volume DOUBLE NOT NULL,
|
|
||||||
user_ref INTEGER,
|
|
||||||
status VARCHAR,
|
|
||||||
filled_volume DOUBLE,
|
|
||||||
avg_price DOUBLE,
|
|
||||||
raw_response JSON,
|
|
||||||
recorded_at TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pnl_events (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
trade_ref VARCHAR NOT NULL,
|
|
||||||
recorded_at TIMESTAMP NOT NULL,
|
|
||||||
kind VARCHAR NOT NULL,
|
|
||||||
pnl_usd DOUBLE NOT NULL,
|
|
||||||
source VARCHAR NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
|
||||||
snapshot_at TIMESTAMP NOT NULL,
|
|
||||||
balances JSON,
|
|
||||||
total_value_usd DOUBLE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS market_snapshots (
|
|
||||||
snapshot_at TIMESTAMP NOT NULL,
|
|
||||||
symbol VARCHAR NOT NULL,
|
|
||||||
source VARCHAR NOT NULL,
|
|
||||||
payload JSON NOT NULL,
|
|
||||||
latency_ms DOUBLE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
id UUID DEFAULT uuid(),
|
|
||||||
occurred_at TIMESTAMP NOT NULL,
|
|
||||||
actor VARCHAR NOT NULL,
|
|
||||||
event_type VARCHAR NOT NULL,
|
|
||||||
decision VARCHAR NOT NULL,
|
|
||||||
payload JSON,
|
|
||||||
correlation_id VARCHAR
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
|
|
||||||
snapshot_at TIMESTAMP NOT NULL,
|
|
||||||
is_running BOOLEAN NOT NULL,
|
|
||||||
kill_switch_active BOOLEAN NOT NULL,
|
|
||||||
kill_switch_reason VARCHAR,
|
|
||||||
open_trade_count INTEGER NOT NULL,
|
|
||||||
last_known_balances JSON,
|
|
||||||
note VARCHAR
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class DuckDBStore:
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
|
||||||
self._db_path = Path(settings.duckdb_path)
|
|
||||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._use_memory_fallback = False
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def connect(self) -> Iterator[duckdb.DuckDBPyConnection]:
|
|
||||||
try:
|
|
||||||
conn = duckdb.connect(str(self._db_path))
|
|
||||||
except duckdb.IOException:
|
|
||||||
if not self._use_memory_fallback:
|
|
||||||
_LOG.warning(
|
|
||||||
"duckdb_path_unavailable_falling_back_to_memory", path=str(self._db_path)
|
|
||||||
)
|
|
||||||
self._use_memory_fallback = True
|
|
||||||
conn = duckdb.connect(":memory:")
|
|
||||||
try:
|
|
||||||
yield conn
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def migrate(self) -> None:
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.execute(SCHEMA_SQL)
|
|
||||||
@@ -55,11 +55,11 @@ class AsyncExecutionWriter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(record, TradeRecord):
|
if isinstance(record, TradeRecord):
|
||||||
self._trade_repository.insert(record)
|
await self._trade_repository.insert(record)
|
||||||
elif isinstance(record, OrderRecord):
|
elif isinstance(record, OrderRecord):
|
||||||
self._order_repository.insert(record)
|
await self._order_repository.insert(record)
|
||||||
else:
|
else:
|
||||||
self._pnl_repository.insert(record)
|
await self._pnl_repository.insert(record)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_LOG.error("execution_write_failed", error=str(exc))
|
_LOG.error("execution_write_failed", error=str(exc))
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class AsyncMarketSnapshotWriter:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._repository.insert(
|
await self._repository.insert(
|
||||||
MarketSnapshotRecord(
|
MarketSnapshotRecord(
|
||||||
snapshot_at=item.snapshot_at,
|
snapshot_at=item.snapshot_at,
|
||||||
symbol=item.symbol,
|
symbol=item.symbol,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AsyncOpportunityWriter:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._repository.insert(
|
await self._repository.insert(
|
||||||
OpportunityRecord(
|
OpportunityRecord(
|
||||||
detected_at=event.detected_at,
|
detected_at=event.detected_at,
|
||||||
cycle=event.cycle,
|
cycle=event.cycle,
|
||||||
|
|||||||
@@ -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)
|
||||||
+1050
-131
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,
|
||||||
|
)
|
||||||
@@ -14,9 +14,8 @@
|
|||||||
color: #e5eefb;
|
color: #e5eefb;
|
||||||
}
|
}
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 1120px;
|
max-width: none;
|
||||||
margin: 0 auto;
|
padding: 24px 32px 48px;
|
||||||
padding: 32px 20px 48px;
|
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -141,7 +140,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="{% block main_class %}shell{% endblock %}">
|
<main class="{% block main_class %}shell{% endblock %}">
|
||||||
{% block content %}{% endblock %}
|
{% block header %}{% endblock %} {% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
@@ -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>
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
{% extends "base.html" %} {% block title %}{{ title }}{% endblock %} {% block
|
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
|
||||||
head_scripts %}
|
head_scripts %}
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %}
|
{% endblock %} {% block header %} {% with page_title="Arbitrade Dashboard",
|
||||||
<section class="hero">
|
page_subtitle="Live execution, P&L, and system state." %} {% include
|
||||||
<div>
|
"_header.html" %} {% endwith %} {% endblock %} {% block main_class %}shell{%
|
||||||
<h1 class="title">Arbitrade Dashboard</h1>
|
endblock %} {% block content %}
|
||||||
<p class="subtitle">Live execution, P&L, and system state.</p>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar">
|
|
||||||
<a
|
|
||||||
class="button"
|
|
||||||
href="{{ metrics_endpoint }}"
|
|
||||||
hx-get="{{ metrics_endpoint }}"
|
|
||||||
hx-target="#metrics-panel"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>Refresh metrics</a
|
|
||||||
>
|
|
||||||
<a class="button secondary" href="/health">Health</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="metrics-shell"
|
id="metrics-shell"
|
||||||
@@ -61,15 +47,6 @@ head_scripts %}
|
|||||||
{% include "partials/charts.html" %}
|
{% include "partials/charts.html" %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
id="audit-shell"
|
|
||||||
hx-get="{{ audit_endpoint }}"
|
|
||||||
hx-target="this"
|
|
||||||
hx-trigger="load, every 20s"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
{% include "partials/audit.html" %}
|
|
||||||
</section>
|
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.arbitradeRenderCharts = (payload) => {
|
window.arbitradeRenderCharts = (payload) => {
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}Currency Pairings{% endblock %} {%
|
||||||
|
block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h2>Currency Pairings</h2>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create Pairing Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Create New Pairing</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/dashboard/config/pairs/create">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="base_asset" class="form-label">Base Asset</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="base_asset"
|
||||||
|
name="base_asset"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="quote_asset" class="form-label"
|
||||||
|
>Quote Asset</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="quote_asset"
|
||||||
|
name="quote_asset"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="source" class="form-label">Source</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
placeholder="e.g., Kraken, Binance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-check-input"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="enabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Create Pairing
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Pairings Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Existing Pairings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Base Asset</th>
|
||||||
|
<th>Quote Asset</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pairing in pairings %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ pairing.base_asset }}</td>
|
||||||
|
<td>{{ pairing.quote_asset }}</td>
|
||||||
|
<td>
|
||||||
|
{% if pairing.enabled %}
|
||||||
|
<span class="badge bg-success">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ pairing.source or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/dashboard/config/pairs/delete"
|
||||||
|
style="display: inline"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="base_asset"
|
||||||
|
value="{{ pairing.base_asset }}"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="quote_asset"
|
||||||
|
value="{{ pairing.quote_asset }}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-sm btn-danger"
|
||||||
|
onclick="
|
||||||
|
return confirm(
|
||||||
|
'Are you sure you want to delete this pairing?',
|
||||||
|
);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}Application Settings{% endblock %} {%
|
||||||
|
block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h2>Application Settings</h2>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Configure Settings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/dashboard/config/settings/save">
|
||||||
|
{% for section_name, settings in settings_by_section.items() %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>{{ section_name }}</h5>
|
||||||
|
{% for setting in settings %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="setting_{{ setting.key }}" class="form-label"
|
||||||
|
>{{ setting.key }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="setting_{{ setting.key }}"
|
||||||
|
name="setting_{{ setting.key }}"
|
||||||
|
value="{{ setting.value_json }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
|
||||||
|
main_class %}shell{% endblock %} {% block header %} {% with
|
||||||
|
page_title="Currency Pairings",
|
||||||
|
page_subtitle="Enable/disable pairings, search, and sync from Kraken." %}
|
||||||
|
{% include "_header.html" %} {% endwith %} {% endblock %} {% block content %}
|
||||||
|
|
||||||
|
<div class="toolbar" style="margin-bottom: 16px; display: flex; gap: 8px">
|
||||||
|
<input
|
||||||
|
id="pairing-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search pairings…"
|
||||||
|
value="{{ search or '' }}"
|
||||||
|
style="flex: 1; max-width: 300px"
|
||||||
|
hx-get="/dashboard/fragment/pairings"
|
||||||
|
hx-target="#pairings-table-container"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-include="#pairing-enabled-filter"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
id="pairing-enabled-filter"
|
||||||
|
name="enabled"
|
||||||
|
hx-get="/dashboard/fragment/pairings"
|
||||||
|
hx-target="#pairings-table-container"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#pairing-search"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="true" {{ 'selected' if enabled == 'true' else '' }}>
|
||||||
|
Enabled
|
||||||
|
</option>
|
||||||
|
<option value="false" {{ 'selected' if enabled == 'false' else '' }}>
|
||||||
|
Disabled
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
hx-post="/dashboard/api/pairings/sync"
|
||||||
|
hx-target="#pairings-table-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
Sync from Kraken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pairings-table-container">
|
||||||
|
{% include "partials/pairings_table.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% for p in pairings %}
|
||||||
|
<label
|
||||||
|
style="
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="symbols"
|
||||||
|
value="{{ p.base_asset }}/{{ p.quote_asset }}"
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
p.enabled
|
||||||
|
%}checked{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
/>
|
||||||
|
{{ p.base_asset }}/{{ p.quote_asset }}
|
||||||
|
</label>
|
||||||
|
{% endfor %} {% if not pairings %}
|
||||||
|
<span style="opacity: 0.5"
|
||||||
|
>No pairings available. Sync from Kraken in config page.</span
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<div id="backtesting-shell" class="panel">
|
||||||
|
<div
|
||||||
|
class="grid"
|
||||||
|
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"
|
||||||
|
>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Run Status</div>
|
||||||
|
<div class="value">{{ status }}</div>
|
||||||
|
<div class="meta">{{ message }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Latest Report</div>
|
||||||
|
{% if latest_report and latest_report.report and 'processed_events' in
|
||||||
|
latest_report.report %}
|
||||||
|
<div class="meta">Run at {{ latest_report.run_at }}</div>
|
||||||
|
<div class="meta">Events: {{ latest_report.events_path }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
Processed: {{ latest_report.report.processed_events }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Opportunities: {{ latest_report.report.opportunities_seen }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">Trades: {{ latest_report.report.trades_executed }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
Realized P&L: {{
|
||||||
|
'%.4f'|format(latest_report.report.realized_pnl_usd) }} USD
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Max drawdown: {{ '%.4f'|format(latest_report.report.max_drawdown_usd) }}
|
||||||
|
USD
|
||||||
|
</div>
|
||||||
|
{% elif latest_report %}
|
||||||
|
<div class="meta">
|
||||||
|
Job {{ latest_report.get('job_id','')[:8] }}... {{ latest_report.status
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
{{ latest_report.get('error','Waiting for worker...') }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="meta">No runs yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card" style="margin-top: 16px">
|
||||||
|
<div class="label">Run Backtest</div>
|
||||||
|
|
||||||
|
{% if no_enabled_pairings %}
|
||||||
|
<div
|
||||||
|
class="flash"
|
||||||
|
style="
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #ffe58f;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
No enabled pairings found. Enable at least one pairing on the
|
||||||
|
<a href="/dashboard/config/pairings" style="color: #ffe58f"
|
||||||
|
>Pairings page</a
|
||||||
|
>
|
||||||
|
before running a backtest.
|
||||||
|
</div>
|
||||||
|
{% endif %} {% if flash_message %}
|
||||||
|
<div
|
||||||
|
class="flash"
|
||||||
|
style="
|
||||||
|
background: rgba(82, 196, 26, 0.15);
|
||||||
|
border: 1px solid rgba(82, 196, 26, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #b7eb8f;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
"
|
||||||
|
hx-trigger="load delay:5s"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="delete"
|
||||||
|
>
|
||||||
|
{{ flash_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="form-grid"
|
||||||
|
hx-post="{{ run_endpoint }}"
|
||||||
|
hx-target="#backtesting-shell"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="source" value="db" />
|
||||||
|
<input type="hidden" name="symbols" value="" />
|
||||||
|
<div class="meta" style="margin-bottom: 12px">
|
||||||
|
Pairings managed in
|
||||||
|
<a href="/dashboard/config/pairings">Configuration → Pairings</a>. Only
|
||||||
|
enabled pairings are backtested.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Required fields -->
|
||||||
|
<label class="field">
|
||||||
|
<span>Starting balances <span style="color: #ff4d4f">*</span></span>
|
||||||
|
<input
|
||||||
|
name="starting_balances"
|
||||||
|
type="text"
|
||||||
|
value="{{ starting_balances }}"
|
||||||
|
placeholder="USD=1000.0,BTC=0.0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Start time <span style="color: #ff4d4f">*</span></span>
|
||||||
|
<input
|
||||||
|
name="start_time"
|
||||||
|
type="text"
|
||||||
|
value="{{ start_time | default('') }}"
|
||||||
|
placeholder="2025-01-01T00:00:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>End time <span style="color: #ff4d4f">*</span></span>
|
||||||
|
<input
|
||||||
|
name="end_time"
|
||||||
|
type="text"
|
||||||
|
value="{{ end_time | default('') }}"
|
||||||
|
placeholder="2025-01-02T00:00:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Min profit threshold <span style="color: #ff4d4f">*</span></span>
|
||||||
|
<input
|
||||||
|
name="min_profit_threshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
value="{{ min_profit_threshold }}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Advanced -->
|
||||||
|
<details style="grid-column: 1 / -1; margin-top: 8px">
|
||||||
|
<summary style="cursor: pointer; opacity: 0.7; font-size: 0.85rem">
|
||||||
|
Advanced options (fee profile, slippage, latency)
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
class="form-grid"
|
||||||
|
style="
|
||||||
|
margin-top: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<label class="field">
|
||||||
|
<span>Fee profile</span>
|
||||||
|
<select name="fee_profile">
|
||||||
|
{% set sel = "selected" if fee_profile == "api" else "" %}
|
||||||
|
<option value="api" {{ sel }}>api (from Kraken)</option>
|
||||||
|
{% set sel = "selected" if fee_profile == "standard" else "" %}
|
||||||
|
<option value="standard" {{ sel }}>standard</option>
|
||||||
|
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
|
||||||
|
<option value="maker_heavy" {{ sel }}>maker_heavy</option>
|
||||||
|
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
|
||||||
|
<option value="taker_heavy" {{ sel }}>taker_heavy</option>
|
||||||
|
{% set sel = "selected" if fee_profile == "custom" else "" %}
|
||||||
|
<option value="custom" {{ sel }}>custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Custom fee rate (if custom profile)</span>
|
||||||
|
<input
|
||||||
|
name="custom_fee_rate"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
value="{{ custom_fee_rate }}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Slippage (bps)</span>
|
||||||
|
<input
|
||||||
|
name="slippage_bps"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value="{{ slippage_bps }}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Execution latency (ms)</span>
|
||||||
|
<input
|
||||||
|
name="execution_latency_ms"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value="{{ execution_latency_ms }}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Submit Job</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="margin-top: 16px">
|
||||||
|
<div class="label">Recent Jobs</div>
|
||||||
|
{% if recent_reports %}
|
||||||
|
<div style="overflow-x: auto">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.14)">
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">Job</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">
|
||||||
|
Events
|
||||||
|
</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">
|
||||||
|
Trades
|
||||||
|
</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">P&L</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #9fb2d0"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in recent_reports %}
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.06)">
|
||||||
|
<td style="padding: 8px">
|
||||||
|
<button
|
||||||
|
class="button secondary"
|
||||||
|
style="padding: 2px 8px; font-size: 0.8rem"
|
||||||
|
hx-get="/dashboard/backtesting/job/{{ item.job_id }}"
|
||||||
|
hx-target="#job-detail-{{ loop.index }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
{{ item.job_id[:8] }}...
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px">{{ item.status }}</td>
|
||||||
|
<td style="padding: 8px; color: #7f95b7">{{ item.events_path }}</td>
|
||||||
|
<td style="padding: 8px">
|
||||||
|
{{ item.report.trades_executed if item.report else "—" }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px">
|
||||||
|
{{ '%.2f'|format(item.report.realized_pnl_usd) if item.report and
|
||||||
|
item.report.realized_pnl_usd else "—" }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px; color: #7f95b7">{{ item.run_at[:19] }}</td>
|
||||||
|
<td style="padding: 8px">
|
||||||
|
<button
|
||||||
|
class="button danger"
|
||||||
|
style="padding: 2px 8px; font-size: 0.8rem"
|
||||||
|
hx-post="/dashboard/backtesting/job/{{ item.job_id }}/delete"
|
||||||
|
hx-target="#backtesting-shell"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
onclick="return confirm('Delete job {{ item.job_id[:8] }}...?');"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="job-detail-{{ loop.index }}"></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="meta">No jobs submitted yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<div class="chart-head">
|
<div class="chart-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="label">Opportunity Trend</div>
|
<div class="label">Opportunity Trend</div>
|
||||||
<div class="meta">Recent opportunities from DuckDB. Updated {{ generated_at }}</div>
|
<div class="meta">Recent opportunities from PostgreSQL. Updated {{ generated_at }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="button secondary" x-on:click="expanded = !expanded">
|
<button type="button" class="button secondary" x-on:click="expanded = !expanded">
|
||||||
<span x-text="expanded ? 'Hide chart' : 'Show chart'"></span>
|
<span x-text="expanded ? 'Hide chart' : 'Show chart'"></span>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div id="config-panel" class="panel" style="margin-top: 16px">
|
||||||
|
{% if flash_message %}
|
||||||
|
<div
|
||||||
|
class="flash"
|
||||||
|
style="
|
||||||
|
background: rgba(82, 196, 26, 0.15);
|
||||||
|
border: 1px solid rgba(82, 196, 26, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #b7eb8f;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
"
|
||||||
|
hx-trigger="load delay:3s"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="delete"
|
||||||
|
>
|
||||||
|
{{ flash_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form
|
||||||
|
class="form-grid"
|
||||||
|
hx-post="{{ config_endpoint }}"
|
||||||
|
hx-target="#config-panel"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
style="
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{% include "config/runtime.html" %} {% include "config/alerts.html" %} {%
|
||||||
|
include "config/kraken.html" %} {% include "config/risk.html" %}
|
||||||
|
<div style="grid-column: 1 / -1">
|
||||||
|
<button type="submit" class="button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
+4
-46
@@ -16,6 +16,10 @@
|
|||||||
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
|
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
|
||||||
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
|
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
|
||||||
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
|
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
|
||||||
|
<div class="meta">Tradable pairs: {{ tradable_pairs_display }}</div>
|
||||||
|
<div class="meta">Strategy mode: {{ strategy_mode }}</div>
|
||||||
|
<div class="meta">Profit threshold: {{ strategy_profit_threshold }}</div>
|
||||||
|
<div class="meta">Max depth levels: {{ strategy_max_depth_levels }}</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="label">Alerting</div>
|
<div class="label">Alerting</div>
|
||||||
@@ -71,51 +75,5 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
|
||||||
<div class="label">Edit Config</div>
|
|
||||||
<form
|
|
||||||
class="form-grid"
|
|
||||||
hx-post="{{ config_endpoint }}"
|
|
||||||
hx-target="#controls-panel"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<label class="field">
|
|
||||||
<span>Trade capital USD</span>
|
|
||||||
<input
|
|
||||||
name="trade_capital_usd"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value="{{ trade_capital_usd_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max trade capital USD</span>
|
|
||||||
<input
|
|
||||||
name="max_trade_capital_usd"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value="{{ max_trade_capital_usd_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max concurrent trades</span>
|
|
||||||
<input
|
|
||||||
name="max_concurrent_trades"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value="{{ max_concurrent_trades_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field checkbox">
|
|
||||||
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
|
|
||||||
<input name="paper_trading_mode" type="checkbox" {{ check }} />
|
|
||||||
<span>Paper trading mode</span>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="button">Save config</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<div id="log-table-container">
|
||||||
|
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
|
||||||
|
<select
|
||||||
|
name="level"
|
||||||
|
hx-get="/dashboard/fragment/logs"
|
||||||
|
hx-target="#log-table-container"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<option value="" {{ 'selected' if current_level == 'all' else '' }}>All</option>
|
||||||
|
<option value="INFO" {{ 'selected' if current_level == 'INFO' else '' }}>INFO</option>
|
||||||
|
<option value="WARNING" {{ 'selected' if current_level == 'WARNING' else '' }}>WARNING</option>
|
||||||
|
<option value="ERROR" {{ 'selected' if current_level == 'ERROR' else '' }}>ERROR</option>
|
||||||
|
<option value="CRITICAL" {{ 'selected' if current_level == 'CRITICAL' else '' }}>CRITICAL</option>
|
||||||
|
</select>
|
||||||
|
<span style="opacity: 0.6; font-size: 0.85rem">{{ total }} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left">
|
||||||
|
<th style="padding: 6px 8px">Time</th>
|
||||||
|
<th style="padding: 6px 8px">Level</th>
|
||||||
|
<th style="padding: 6px 8px">Logger</th>
|
||||||
|
<th style="padding: 6px 8px">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in records %}
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255,255,255,0.04)">
|
||||||
|
<td style="padding: 4px 8px; white-space: nowrap">
|
||||||
|
{{ r.recorded_at.strftime('%H:%M:%S') if r.recorded_at else '—' }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 4px 8px">
|
||||||
|
<span class="badge level-{{ r.level.lower() }}">{{ r.level }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 4px 8px; opacity: 0.7; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
|
||||||
|
{{ r.logger }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 4px 8px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
|
||||||
|
{{ r.message }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not records %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">No log entries found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="toolbar" style="display: flex; gap: 8px; justify-content: center; margin-top: 12px">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<button
|
||||||
|
class="button secondary"
|
||||||
|
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page - 1 }}"
|
||||||
|
hx-target="#log-table-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>Previous</button>
|
||||||
|
{% endif %}
|
||||||
|
<span style="opacity: 0.6; font-size: 0.85rem; padding: 0 8px">Page {{ page }} / {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<button
|
||||||
|
class="button secondary"
|
||||||
|
hx-get="/dashboard/fragment/logs?level={{ current_level }}&page={{ page + 1 }}"
|
||||||
|
hx-target="#log-table-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>Next</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+10
-2
@@ -6,7 +6,7 @@
|
|||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="label">Balances</div>
|
<div class="label">Balances</div>
|
||||||
<div class="value">{{ balances }}</div>
|
<div class="value">{{ balances | safe }}</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="label">Open Trades</div>
|
<div class="label">Open Trades</div>
|
||||||
@@ -16,6 +16,13 @@
|
|||||||
<div class="label">Realized P&L</div>
|
<div class="label">Realized P&L</div>
|
||||||
<div class="value">{{ realized_pnl_total }}</div>
|
<div class="value">{{ realized_pnl_total }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Fee Tier</div>
|
||||||
|
<div class="value">{{ fee_tier }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
Maker {{ maker_fee }} / Taker {{ taker_fee }} · {{ fee_source }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -44,9 +51,10 @@
|
|||||||
class="value"
|
class="value"
|
||||||
style="font-size: 1rem; font-weight: 500; word-break: break-word"
|
style="font-size: 1rem; font-weight: 500; word-break: break-word"
|
||||||
>
|
>
|
||||||
{{ balances }}
|
{{ balances | safe }}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">Total value {{ total_value }}</div>
|
<div class="meta">Total value {{ total_value }}</div>
|
||||||
|
<div class="meta">Equity {{ equity }}</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="label">Opportunity Feed</div>
|
<div class="label">Opportunity Feed</div>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<table
|
||||||
|
class="pairings-table"
|
||||||
|
style="width: 100%; border-collapse: collapse; font-size: 0.85rem"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<th style="padding: 6px 8px">Base</th>
|
||||||
|
<th style="padding: 6px 8px">Quote</th>
|
||||||
|
<th style="padding: 6px 8px">Source</th>
|
||||||
|
<th style="padding: 6px 8px; text-align: center">Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in pairings %}
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.05)">
|
||||||
|
<td style="padding: 6px 8px">{{ p.base_asset }}</td>
|
||||||
|
<td style="padding: 6px 8px">{{ p.quote_asset }}</td>
|
||||||
|
<td style="padding: 6px 8px; opacity: 0.6">{{ p.source }}</td>
|
||||||
|
<td style="padding: 6px 8px; text-align: center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
hx-post="/dashboard/api/pairings/toggle"
|
||||||
|
hx-vals='{"base_asset": "{{ p.base_asset }}", "quote_asset": "{{ p.quote_asset }}"}'
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#pairings-table-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-include="#pairing-search"
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
p.enabled
|
||||||
|
%}checked{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %} {% if not pairings %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">
|
||||||
|
No pairings found. Click "Sync from Kraken" to fetch available pairs.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""End-to-end tests — require full app startup with PostgreSQL."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Integration tests for PostgreSQL schema and connectivity."""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""pytest configuration for integration tests.
|
||||||
|
|
||||||
|
Integration tests require a live PostgreSQL server at the configured host.
|
||||||
|
They are skipped automatically if the server is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool:
|
||||||
|
"""Skip integration tests unless --integration is passed."""
|
||||||
|
if "integration" in str(collection_path) and not config.getoption("--integration", False):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||||
|
parser.addoption(
|
||||||
|
"--integration",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Run integration tests (requires PostgreSQL)",
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.settings import get_settings
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _pg() -> AsyncIterator[PgStore]:
|
||||||
|
s = get_settings()
|
||||||
|
store = PgStore(s)
|
||||||
|
try:
|
||||||
|
await store.start()
|
||||||
|
await store.migrate()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_repository_inserts_and_lists_recent() -> None:
|
||||||
|
async with _pg() as store:
|
||||||
|
repository = AuditRepository(store)
|
||||||
|
|
||||||
|
await repository.insert(
|
||||||
|
AuditRecord(
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
actor="dashboard_user",
|
||||||
|
event_type="dashboard.control.start",
|
||||||
|
decision="approved",
|
||||||
|
payload={"execution_status": "running"},
|
||||||
|
correlation_id="req-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent = await repository.list_recent(limit=5)
|
||||||
|
|
||||||
|
assert len(recent) == 1
|
||||||
|
assert recent[0].actor == "dashboard_user"
|
||||||
|
assert recent[0].event_type == "dashboard.control.start"
|
||||||
|
assert recent[0].decision == "approved"
|
||||||
|
assert recent[0].payload == {"execution_status": "running"}
|
||||||
|
assert recent[0].correlation_id == "req-1"
|
||||||
@@ -170,7 +170,6 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
|||||||
assert 'hx-get="/dashboard/fragment/metrics"' in page.text
|
assert 'hx-get="/dashboard/fragment/metrics"' in page.text
|
||||||
assert 'hx-get="/dashboard/fragment/controls"' in page.text
|
assert 'hx-get="/dashboard/fragment/controls"' in page.text
|
||||||
assert 'hx-get="/dashboard/fragment/charts"' in page.text
|
assert 'hx-get="/dashboard/fragment/charts"' in page.text
|
||||||
assert 'hx-get="/dashboard/fragment/audit"' in page.text
|
|
||||||
|
|
||||||
assert fragment.status_code == 200
|
assert fragment.status_code == 200
|
||||||
assert "Realized P&L" in fragment.text
|
assert "Realized P&L" in fragment.text
|
||||||
@@ -197,11 +196,12 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
|||||||
|
|
||||||
assert controls.status_code == 200
|
assert controls.status_code == 200
|
||||||
assert "Runtime Status" in controls.text
|
assert "Runtime Status" in controls.text
|
||||||
assert ">running<" in controls.text
|
assert "running" in controls.text
|
||||||
assert "Alerting" in controls.text
|
assert "Alerting" in controls.text
|
||||||
assert "Last result" in controls.text
|
assert "Last result" in controls.text
|
||||||
assert "Paper trading mode" in controls.text
|
assert "Paper trading" in controls.text
|
||||||
assert "Trade capital USD" in controls.text
|
assert "Tradable pairs" in controls.text
|
||||||
|
assert "Strategy mode" in controls.text
|
||||||
|
|
||||||
assert charts.status_code == 200
|
assert charts.status_code == 200
|
||||||
assert "Opportunity Trend" in charts.text
|
assert "Opportunity Trend" in charts.text
|
||||||
@@ -229,6 +229,10 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
|||||||
"trade_capital_usd": "250.50",
|
"trade_capital_usd": "250.50",
|
||||||
"max_trade_capital_usd": "300.00",
|
"max_trade_capital_usd": "300.00",
|
||||||
"max_concurrent_trades": "4",
|
"max_concurrent_trades": "4",
|
||||||
|
"tradable_pairs": "BTC/USD, ETH/BTC, BTC/USD",
|
||||||
|
"strategy_mode": "paper",
|
||||||
|
"strategy_profit_threshold": "0.0025",
|
||||||
|
"strategy_max_depth_levels": "7",
|
||||||
"paper_trading_mode": "on",
|
"paper_trading_mode": "on",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -247,10 +251,18 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
|||||||
assert "250.50 USD" in config_response.text
|
assert "250.50 USD" in config_response.text
|
||||||
assert "300.00 USD" in config_response.text
|
assert "300.00 USD" in config_response.text
|
||||||
assert "4" in config_response.text
|
assert "4" in config_response.text
|
||||||
|
assert "BTC/USD, ETH/BTC" in config_response.text
|
||||||
|
assert "paper" in config_response.text
|
||||||
|
assert "0.002500" in config_response.text
|
||||||
|
assert "7" in config_response.text
|
||||||
assert app.state.settings.trade_capital_usd == 250.5
|
assert app.state.settings.trade_capital_usd == 250.5
|
||||||
assert app.state.settings.max_trade_capital_usd == 300.0
|
assert app.state.settings.max_trade_capital_usd == 300.0
|
||||||
assert app.state.settings.max_concurrent_trades == 4
|
assert app.state.settings.max_concurrent_trades == 4
|
||||||
assert app.state.settings.paper_trading_mode is True
|
assert app.state.settings.paper_trading_mode is True
|
||||||
|
assert app.state.dashboard_controls.tradable_pairs == ["BTC/USD", "ETH/BTC"]
|
||||||
|
assert app.state.dashboard_controls.strategy_mode == "paper"
|
||||||
|
assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025
|
||||||
|
assert app.state.dashboard_controls.strategy_max_depth_levels == 7
|
||||||
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
@@ -319,3 +331,54 @@ async def test_dashboard_alert_status_api_exposes_notifier_snapshot(tmp_path) ->
|
|||||||
assert payload["enabled"] is True
|
assert payload["enabled"] is True
|
||||||
assert "configured_channels" in payload
|
assert "configured_channels" in payload
|
||||||
assert "last_result" in payload
|
assert "last_result" in payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backtesting_page_run_and_recent_reports_api(tmp_path) -> None:
|
||||||
|
app = create_app(Settings(DUCKDB_PATH=tmp_path / "backtesting-ui.duckdb"))
|
||||||
|
|
||||||
|
events_file = tmp_path / "replay.jsonl"
|
||||||
|
events_file.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
'{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[99.5,10.0]],"asks":[[100.0,10.0]]}',
|
||||||
|
'{"timestamp":"2026-06-01T12:00:01Z","symbol":"ETH/BTC","bids":[[0.051,10.0]],"asks":[[0.050,10.0]]}',
|
||||||
|
'{"timestamp":"2026-06-01T12:00:02Z","symbol":"ETH/USD","bids":[[110.0,10.0]],"asks":[[110.5,10.0]]}',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
page = await client.get("/dashboard/backtesting")
|
||||||
|
fragment = await client.get("/dashboard/fragment/backtesting")
|
||||||
|
run = await client.post(
|
||||||
|
"/dashboard/backtesting/run",
|
||||||
|
data={
|
||||||
|
"source": "db",
|
||||||
|
"starting_balances": "USD=1000.0",
|
||||||
|
"trade_capital": "100.0",
|
||||||
|
"min_profit_threshold": "0.0005",
|
||||||
|
"fee_profile": "api",
|
||||||
|
"slippage_bps": "4.0",
|
||||||
|
"execution_latency_ms": "20.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
reports = await client.get("/dashboard/api/backtesting/reports")
|
||||||
|
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "Backtesting" in page.text
|
||||||
|
assert "/dashboard/fragment/backtesting" in page.text
|
||||||
|
|
||||||
|
assert fragment.status_code == 200
|
||||||
|
assert "Run Backtest" in fragment.text
|
||||||
|
assert "Recent Jobs" in fragment.text
|
||||||
|
|
||||||
|
assert run.status_code == 200
|
||||||
|
assert "submitted" in run.text
|
||||||
|
assert "queued" in run.text
|
||||||
|
|
||||||
|
assert reports.status_code == 200
|
||||||
|
payload = reports.json()
|
||||||
|
assert len(payload["reports"]) >= 1
|
||||||
|
assert payload["reports"][0]["status"] == "pending"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.settings import get_settings
|
||||||
|
from arbitrade.detection.engine import OpportunityEvent
|
||||||
|
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||||
|
from arbitrade.storage.executions import AsyncExecutionWriter
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _FakeRestClient:
|
||||||
|
calls: int = 0
|
||||||
|
|
||||||
|
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
|
||||||
|
self.calls += 1
|
||||||
|
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_event() -> OpportunityEvent:
|
||||||
|
return OpportunityEvent(
|
||||||
|
detected_at=datetime.now(UTC),
|
||||||
|
cycle="USD->BTC->ETH->USD",
|
||||||
|
updated_pair="BTC/USD",
|
||||||
|
gross_rate=1.04,
|
||||||
|
net_rate=1.03,
|
||||||
|
gross_pct=4.0,
|
||||||
|
net_pct=3.0,
|
||||||
|
est_profit=0.03,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _pg() -> AsyncIterator[PgStore]:
|
||||||
|
s = get_settings()
|
||||||
|
store = PgStore(s)
|
||||||
|
try:
|
||||||
|
await store.start()
|
||||||
|
await store.migrate()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execution_writer_persists_trade_order_and_pnl() -> None:
|
||||||
|
async with _pg() as store:
|
||||||
|
writer = AsyncExecutionWriter(
|
||||||
|
TradeRepository(store),
|
||||||
|
OrderRepository(store),
|
||||||
|
PnLRepository(store),
|
||||||
|
max_queue_size=10,
|
||||||
|
)
|
||||||
|
await writer.start()
|
||||||
|
|
||||||
|
client = _FakeRestClient()
|
||||||
|
sequencer = TriangularExecutionSequencer(
|
||||||
|
client,
|
||||||
|
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||||
|
execution_writer=writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await sequencer.execute(_sample_event())
|
||||||
|
await writer.stop()
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert client.calls == 3
|
||||||
|
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
trades = await conn.fetch(
|
||||||
|
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
|
||||||
|
)
|
||||||
|
orders = await conn.fetch(
|
||||||
|
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
|
||||||
|
"FROM orders ORDER BY leg_index"
|
||||||
|
)
|
||||||
|
pnls = await conn.fetch("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events")
|
||||||
|
|
||||||
|
assert len(trades) == 1
|
||||||
|
assert trades[0]["status"] == "filled"
|
||||||
|
assert trades[0]["estimated_pnl"] == 0.03
|
||||||
|
assert trades[0]["capital_used"] == 1.0
|
||||||
|
assert trades[0]["cycle"] == "USD->BTC->ETH->USD"
|
||||||
|
assert trades[0]["leg_count"] == 3
|
||||||
|
|
||||||
|
assert len(orders) == 3
|
||||||
|
assert orders[0]["leg_index"] == 0
|
||||||
|
assert orders[1]["leg_index"] == 1
|
||||||
|
assert orders[2]["leg_index"] == 2
|
||||||
|
assert orders[0]["status"] == "submitted"
|
||||||
|
|
||||||
|
assert len(pnls) == 1
|
||||||
|
assert pnls[0]["kind"] == "estimated"
|
||||||
|
assert pnls[0]["pnl_usd"] == 0.03
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.settings import get_settings
|
||||||
|
from arbitrade.metrics import MetricsCalculator
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _pg() -> AsyncIterator[PgStore]:
|
||||||
|
s = get_settings()
|
||||||
|
store = PgStore(s)
|
||||||
|
try:
|
||||||
|
await store.start()
|
||||||
|
await store.migrate()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_metrics_calculator_summarizes_execution_data() -> None:
|
||||||
|
async with _pg() as store:
|
||||||
|
started = datetime.now(UTC)
|
||||||
|
finished = started + timedelta(seconds=30)
|
||||||
|
started_two = started + timedelta(minutes=1)
|
||||||
|
finished_two = started_two + timedelta(seconds=90)
|
||||||
|
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (
|
||||||
|
trade_ref, started_at, finished_at, status,
|
||||||
|
realized_pnl, estimated_pnl, capital_used, cycle, leg_count
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9),
|
||||||
|
($10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||||
|
""",
|
||||||
|
"trade-1",
|
||||||
|
started,
|
||||||
|
finished,
|
||||||
|
"filled",
|
||||||
|
12.5,
|
||||||
|
10.0,
|
||||||
|
100.0,
|
||||||
|
"USD->BTC->ETH->USD",
|
||||||
|
3,
|
||||||
|
"trade-2",
|
||||||
|
started_two,
|
||||||
|
finished_two,
|
||||||
|
"filled",
|
||||||
|
-4.5,
|
||||||
|
-2.0,
|
||||||
|
200.0,
|
||||||
|
"USD->ETH->BTC->USD",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO opportunities (detected_at, cycle, gross_pct, net_pct, est_profit, executed)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6),
|
||||||
|
($7, $8, $9, $10, $11, $12),
|
||||||
|
($13, $14, $15, $16, $17, $18)
|
||||||
|
""",
|
||||||
|
started,
|
||||||
|
"USD->BTC->ETH->USD",
|
||||||
|
4.0,
|
||||||
|
3.0,
|
||||||
|
0.03,
|
||||||
|
True,
|
||||||
|
started_two,
|
||||||
|
"USD->ETH->BTC->USD",
|
||||||
|
2.0,
|
||||||
|
1.0,
|
||||||
|
0.01,
|
||||||
|
False,
|
||||||
|
started_two + timedelta(seconds=30),
|
||||||
|
"USD->BTC->ETH->USD",
|
||||||
|
5.0,
|
||||||
|
4.0,
|
||||||
|
0.04,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (
|
||||||
|
trade_ref, order_ref, leg_index, pair, side, volume,
|
||||||
|
user_ref, status, filled_volume, avg_price, raw_response, recorded_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12),
|
||||||
|
($13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
|
""",
|
||||||
|
"trade-1",
|
||||||
|
"order-1",
|
||||||
|
0,
|
||||||
|
"BTC/USD",
|
||||||
|
"buy",
|
||||||
|
2.0,
|
||||||
|
101,
|
||||||
|
"closed",
|
||||||
|
2.0,
|
||||||
|
100.0,
|
||||||
|
"{}",
|
||||||
|
started,
|
||||||
|
"trade-2",
|
||||||
|
"order-2",
|
||||||
|
0,
|
||||||
|
"ETH/USD",
|
||||||
|
"sell",
|
||||||
|
4.0,
|
||||||
|
202,
|
||||||
|
"closed",
|
||||||
|
3.0,
|
||||||
|
200.0,
|
||||||
|
"{}",
|
||||||
|
started_two,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = await MetricsCalculator(store).compute()
|
||||||
|
|
||||||
|
assert metrics.realized_pnl_usd == 8.0
|
||||||
|
assert metrics.win_rate == 0.5
|
||||||
|
assert metrics.avg_trade_duration_seconds == 60.0
|
||||||
|
assert metrics.opportunities_per_minute == 2.0
|
||||||
|
assert metrics.fill_rate == 0.875
|
||||||
|
assert metrics.latency_p50_seconds == 60.0
|
||||||
|
assert metrics.latency_p95_seconds == 87.0
|
||||||
|
assert metrics.latency_p99_seconds == pytest.approx(89.4)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.settings import get_settings
|
||||||
|
from arbitrade.detection.engine import OpportunityEvent
|
||||||
|
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
from arbitrade.storage.repositories import OpportunityRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _pg() -> AsyncIterator[PgStore]:
|
||||||
|
s = get_settings()
|
||||||
|
store = PgStore(s)
|
||||||
|
try:
|
||||||
|
await store.start()
|
||||||
|
await store.migrate()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_opportunity_writer_persists_events() -> None:
|
||||||
|
async with _pg() as store:
|
||||||
|
repository = OpportunityRepository(store)
|
||||||
|
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
|
||||||
|
await writer.start()
|
||||||
|
|
||||||
|
event = OpportunityEvent(
|
||||||
|
detected_at=datetime.now(UTC),
|
||||||
|
cycle="USD->BTC->ETH->USD",
|
||||||
|
updated_pair="BTC/USD",
|
||||||
|
gross_rate=1.04,
|
||||||
|
net_rate=1.03,
|
||||||
|
gross_pct=4.0,
|
||||||
|
net_pct=3.0,
|
||||||
|
est_profit=0.03,
|
||||||
|
)
|
||||||
|
|
||||||
|
await writer.enqueue(event)
|
||||||
|
await writer.stop()
|
||||||
|
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["cycle"] == "USD->BTC->ETH->USD"
|
||||||
|
assert rows[0]["gross_pct"] == 4.0
|
||||||
|
assert rows[0]["net_pct"] == 3.0
|
||||||
|
assert rows[0]["est_profit"] == 0.03
|
||||||
|
assert rows[0]["executed"] is False
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"""Integration tests: verify PostgreSQL schema and connection.
|
||||||
|
|
||||||
|
These tests connect to the PostgreSQL server at 192.168.88.35 and
|
||||||
|
validate that all expected tables, columns, and constraints exist.
|
||||||
|
They are skipped if the server is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from arbitrade.config.settings import get_settings
|
||||||
|
from arbitrade.storage.pg_store import PgStore
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
# ── expected schema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
EXPECTED_TABLES: dict[str, list[str]] = {
|
||||||
|
"schema_migrations": ["version", "applied_at"],
|
||||||
|
"config_sections": ["id", "name", "description", "updated_at"],
|
||||||
|
"config_settings": [
|
||||||
|
"key",
|
||||||
|
"section",
|
||||||
|
"value_json",
|
||||||
|
"value_type",
|
||||||
|
"is_secret",
|
||||||
|
"is_runtime_reloadable",
|
||||||
|
"updated_at",
|
||||||
|
"updated_by",
|
||||||
|
],
|
||||||
|
"config_pairings": [
|
||||||
|
"id",
|
||||||
|
"base_asset",
|
||||||
|
"quote_asset",
|
||||||
|
"enabled",
|
||||||
|
"source",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
],
|
||||||
|
"config_backtesting_defaults": [
|
||||||
|
"id",
|
||||||
|
"starting_balances",
|
||||||
|
"trade_capital",
|
||||||
|
"min_profit_threshold",
|
||||||
|
"slippage_bps",
|
||||||
|
"execution_latency_ms",
|
||||||
|
"fee_source",
|
||||||
|
],
|
||||||
|
"opportunities": [
|
||||||
|
"id",
|
||||||
|
"detected_at",
|
||||||
|
"cycle",
|
||||||
|
"gross_pct",
|
||||||
|
"net_pct",
|
||||||
|
"est_profit",
|
||||||
|
"executed",
|
||||||
|
],
|
||||||
|
"trades": [
|
||||||
|
"id",
|
||||||
|
"trade_ref",
|
||||||
|
"started_at",
|
||||||
|
"finished_at",
|
||||||
|
"status",
|
||||||
|
"realized_pnl",
|
||||||
|
"estimated_pnl",
|
||||||
|
"capital_used",
|
||||||
|
"cycle",
|
||||||
|
"leg_count",
|
||||||
|
],
|
||||||
|
"orders": [
|
||||||
|
"id",
|
||||||
|
"trade_ref",
|
||||||
|
"order_ref",
|
||||||
|
"leg_index",
|
||||||
|
"pair",
|
||||||
|
"side",
|
||||||
|
"volume",
|
||||||
|
"user_ref",
|
||||||
|
"status",
|
||||||
|
"filled_volume",
|
||||||
|
"avg_price",
|
||||||
|
"raw_response",
|
||||||
|
"recorded_at",
|
||||||
|
],
|
||||||
|
"pnl_events": [
|
||||||
|
"id",
|
||||||
|
"trade_ref",
|
||||||
|
"recorded_at",
|
||||||
|
"kind",
|
||||||
|
"pnl_usd",
|
||||||
|
"source",
|
||||||
|
],
|
||||||
|
"portfolio_snapshots": ["snapshot_at", "balances", "total_value_usd"],
|
||||||
|
"market_snapshots": ["snapshot_at", "symbol", "source", "payload", "latency_ms"],
|
||||||
|
"audit_events": [
|
||||||
|
"id",
|
||||||
|
"occurred_at",
|
||||||
|
"actor",
|
||||||
|
"event_type",
|
||||||
|
"decision",
|
||||||
|
"payload",
|
||||||
|
"correlation_id",
|
||||||
|
],
|
||||||
|
"runtime_state_snapshots": [
|
||||||
|
"snapshot_at",
|
||||||
|
"is_running",
|
||||||
|
"kill_switch_active",
|
||||||
|
"kill_switch_reason",
|
||||||
|
"open_trade_count",
|
||||||
|
"last_known_balances",
|
||||||
|
"note",
|
||||||
|
],
|
||||||
|
"kraken_account_snapshots": [
|
||||||
|
"snapshot_at",
|
||||||
|
"fee_tier",
|
||||||
|
"maker_fee",
|
||||||
|
"taker_fee",
|
||||||
|
"thirty_day_volume",
|
||||||
|
"trade_balance_raw",
|
||||||
|
"fee_schedule_raw",
|
||||||
|
],
|
||||||
|
"backtest_jobs": [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"events_path",
|
||||||
|
"config",
|
||||||
|
"report",
|
||||||
|
"error",
|
||||||
|
"created_at",
|
||||||
|
"started_at",
|
||||||
|
"finished_at",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tables that should have a primary key
|
||||||
|
TABLES_WITH_PRIMARY_KEY: dict[str, str | list[str]] = {
|
||||||
|
"schema_migrations": "version",
|
||||||
|
"config_sections": "id",
|
||||||
|
"config_settings": "key",
|
||||||
|
"config_pairings": "id",
|
||||||
|
"config_backtesting_defaults": "id",
|
||||||
|
"opportunities": "id",
|
||||||
|
"trades": "id",
|
||||||
|
"orders": "id",
|
||||||
|
"pnl_events": "id",
|
||||||
|
"audit_events": "id",
|
||||||
|
"backtest_jobs": "id",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tables with a UNIQUE constraint beyond the primary key
|
||||||
|
TABLES_WITH_UNIQUE_CONSTRAINTS: dict[str, list[str]] = {
|
||||||
|
"config_sections": ["name"],
|
||||||
|
"config_pairings": ["base_asset, quote_asset"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _pg_lifecycle() -> AsyncIterator[PgStore]:
|
||||||
|
"""Connect, yield store, then disconnect."""
|
||||||
|
settings = get_settings()
|
||||||
|
store = PgStore(settings)
|
||||||
|
try:
|
||||||
|
await store.start()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await store.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(name="pg")
|
||||||
|
async def pg_fixture() -> AsyncIterator[PgStore]:
|
||||||
|
async with _pg_lifecycle() as store:
|
||||||
|
yield store
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_actual_tables(store: PgStore) -> dict[str, list[str]]:
|
||||||
|
"""Return {table_name: [column_name, ...]} for the public schema."""
|
||||||
|
actual: dict[str, list[str]] = {}
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT table_name, column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position"
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
tbl: str = row["table_name"]
|
||||||
|
col: str = row["column_name"]
|
||||||
|
actual.setdefault(tbl, []).append(col)
|
||||||
|
return actual
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_row_count(store: PgStore, table: str) -> int:
|
||||||
|
async with store.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(f"SELECT COUNT(*) AS cnt FROM {table}")
|
||||||
|
return int(row["cnt"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pg_connect(pg: PgStore) -> None:
|
||||||
|
"""Can connect to PostgreSQL and ping the server."""
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
val = await conn.fetchval("SELECT 1 AS val")
|
||||||
|
assert val == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pgcrypto_extension(pg: PgStore) -> None:
|
||||||
|
"""The pgcrypto extension is available (gen_random_uuid)."""
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
val = await conn.fetchval("SELECT gen_random_uuid()")
|
||||||
|
assert val is not None
|
||||||
|
# The result should be a UUID object
|
||||||
|
assert len(str(val)) == 36 # UUID string length
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schema_migration_applies(pg: PgStore) -> None:
|
||||||
|
"""Migrate creates all expected tables."""
|
||||||
|
await pg.migrate()
|
||||||
|
actual = await _get_actual_tables(pg)
|
||||||
|
|
||||||
|
for table in EXPECTED_TABLES:
|
||||||
|
assert table in actual, (
|
||||||
|
f"Table '{table}' missing after migration. " f"Found tables: {sorted(actual)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_is_idempotent(pg: PgStore) -> None:
|
||||||
|
"""Running migrate twice does not raise."""
|
||||||
|
await pg.migrate()
|
||||||
|
await pg.migrate() # second call should be a no-op
|
||||||
|
actual = await _get_actual_tables(pg)
|
||||||
|
for table in EXPECTED_TABLES:
|
||||||
|
assert table in actual
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_table_columns(pg: PgStore) -> None:
|
||||||
|
"""Every expected table has the correct columns."""
|
||||||
|
await pg.migrate()
|
||||||
|
actual = await _get_actual_tables(pg)
|
||||||
|
|
||||||
|
for table, expected_cols in EXPECTED_TABLES.items():
|
||||||
|
actual_cols = actual.get(table, [])
|
||||||
|
for col in expected_cols:
|
||||||
|
assert col in actual_cols, (
|
||||||
|
f"Column '{col}' missing from table '{table}'. " f"Actual columns: {actual_cols}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_primary_keys(pg: PgStore) -> None:
|
||||||
|
"""Tables that should have primary keys do."""
|
||||||
|
await pg.migrate()
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
for table, expected_pk in TABLES_WITH_PRIMARY_KEY.items():
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
|
||||||
|
"JOIN information_schema.key_column_usage kcu "
|
||||||
|
"ON tc.constraint_name = kcu.constraint_name "
|
||||||
|
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
|
||||||
|
"AND tc.constraint_type = 'PRIMARY KEY' "
|
||||||
|
"ORDER BY kcu.ordinal_position",
|
||||||
|
table,
|
||||||
|
)
|
||||||
|
pk_columns = [r["column_name"] for r in rows]
|
||||||
|
expected_list = [expected_pk] if isinstance(expected_pk, str) else expected_pk
|
||||||
|
for col in expected_list:
|
||||||
|
assert col in pk_columns, (
|
||||||
|
f"Table '{table}' should have PK column '{col}'. "
|
||||||
|
f"Actual PK columns: {pk_columns}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unique_constraints(pg: PgStore) -> None:
|
||||||
|
"""Tables that should have UNIQUE constraints do."""
|
||||||
|
await pg.migrate()
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
for table, expected_ucs in TABLES_WITH_UNIQUE_CONSTRAINTS.items():
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
|
||||||
|
"JOIN information_schema.key_column_usage kcu "
|
||||||
|
"ON tc.constraint_name = kcu.constraint_name "
|
||||||
|
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
|
||||||
|
"AND tc.constraint_type = 'UNIQUE'",
|
||||||
|
table,
|
||||||
|
)
|
||||||
|
uc_columns = {r["column_name"] for r in rows}
|
||||||
|
for expected_cols in expected_ucs:
|
||||||
|
cols = [c.strip() for c in expected_cols.split(",")]
|
||||||
|
for col in cols:
|
||||||
|
assert col in uc_columns, (
|
||||||
|
f"Table '{table}' should have UNIQUE column '{col}'. "
|
||||||
|
f"Actual UNIQUE columns: {uc_columns}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_table_row_count_is_zero(pg: PgStore) -> None:
|
||||||
|
"""All tables start empty after migration."""
|
||||||
|
await pg.migrate()
|
||||||
|
for table in EXPECTED_TABLES:
|
||||||
|
count = await _table_row_count(pg, table)
|
||||||
|
assert count == 0, (
|
||||||
|
f"Table '{table}' should be empty after migration, " f"but has {count} rows"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schema_migration_version_recorded(pg: PgStore) -> None:
|
||||||
|
"""schema_migrations has the expected version after migrate."""
|
||||||
|
from arbitrade.storage.pg_store import SCHEMA_VERSION
|
||||||
|
|
||||||
|
await pg.migrate()
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT MAX(version) AS v FROM schema_migrations")
|
||||||
|
assert row is not None
|
||||||
|
assert row["v"] == SCHEMA_VERSION, (
|
||||||
|
f"Expected schema version {SCHEMA_VERSION}, " f"got {row['v']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_and_query_row(pg: PgStore) -> None:
|
||||||
|
"""Can INSERT a row and SELECT it back (round-trip for a simple table)."""
|
||||||
|
await pg.migrate()
|
||||||
|
async with pg.pool.acquire() as conn:
|
||||||
|
# ConfigSections round-trip
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO config_sections (name, description) VALUES ($1, $2)",
|
||||||
|
"test_section",
|
||||||
|
"A test section for integration test",
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT name, description FROM config_sections WHERE name = $1",
|
||||||
|
"test_section",
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "test_section"
|
||||||
|
assert row["description"] == "A test section for integration test"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM config_sections WHERE name = $1",
|
||||||
|
"test_section",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_pairings_upsert(pg: PgStore) -> None:
|
||||||
|
"""ON CONFLICT ... DO UPDATE works on config_pairings (unique constraint)."""
|
||||||
|
await pg.migrate()
|
||||||
|
from arbitrade.config.service import ConfigPairing
|
||||||
|
from arbitrade.storage.repositories import ConfigPairingRepository
|
||||||
|
|
||||||
|
repo = ConfigPairingRepository(pg)
|
||||||
|
|
||||||
|
# Insert
|
||||||
|
p1 = await repo.upsert_pairing(
|
||||||
|
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=True, source="kraken")
|
||||||
|
)
|
||||||
|
assert p1.id is not None
|
||||||
|
assert p1.base_asset == "XBT"
|
||||||
|
assert p1.enabled is True
|
||||||
|
|
||||||
|
# Upsert (update)
|
||||||
|
p2 = await repo.upsert_pairing(
|
||||||
|
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=False, source="manual")
|
||||||
|
)
|
||||||
|
assert p2.id == p1.id # same row
|
||||||
|
assert p2.enabled is False
|
||||||
|
assert p2.source == "manual"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
deleted = await repo.delete_pairing("XBT", "USD")
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_list_recent(pg: PgStore) -> None:
|
||||||
|
"""AuditRepository.list_recent returns records in desc order."""
|
||||||
|
await pg.migrate()
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
||||||
|
|
||||||
|
repo = AuditRepository(pg)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Insert a few records
|
||||||
|
for i in range(3):
|
||||||
|
await repo.insert(
|
||||||
|
AuditRecord(
|
||||||
|
occurred_at=now,
|
||||||
|
actor="test",
|
||||||
|
event_type="integration_test",
|
||||||
|
decision=f"decision_{i}",
|
||||||
|
payload={"index": i},
|
||||||
|
correlation_id=f"corr_{i}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent = await repo.list_recent(limit=5)
|
||||||
|
assert len(recent) >= 3
|
||||||
|
assert recent[0].decision in ("decision_2", "decision_1", "decision_0")
|
||||||
|
# Verify payload serialization worked
|
||||||
|
first = recent[0]
|
||||||
|
if first.payload:
|
||||||
|
assert "index" in first.payload
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
|
||||||
from arbitrade.storage.db import DuckDBStore
|
|
||||||
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_repository_inserts_and_lists_recent(tmp_path) -> None:
|
|
||||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "audit.duckdb")
|
|
||||||
store = DuckDBStore(settings)
|
|
||||||
store.migrate()
|
|
||||||
repository = AuditRepository(store)
|
|
||||||
|
|
||||||
repository.insert(
|
|
||||||
AuditRecord(
|
|
||||||
occurred_at=datetime.now(UTC),
|
|
||||||
actor="dashboard_user",
|
|
||||||
event_type="dashboard.control.start",
|
|
||||||
decision="approved",
|
|
||||||
payload={"execution_status": "running"},
|
|
||||||
correlation_id="req-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
recent = repository.list_recent(limit=5)
|
|
||||||
|
|
||||||
assert len(recent) == 1
|
|
||||||
assert recent[0].actor == "dashboard_user"
|
|
||||||
assert recent[0].event_type == "dashboard.control.start"
|
|
||||||
assert recent[0].decision == "approved"
|
|
||||||
assert recent[0].payload == {"execution_status": "running"}
|
|
||||||
assert recent[0].correlation_id == "req-1"
|
|
||||||
@@ -67,13 +67,11 @@ def test_backtest_replay_engine_runs_deterministically() -> None:
|
|||||||
engine = BacktestReplayEngine(
|
engine = BacktestReplayEngine(
|
||||||
cycles_by_pair=cycles_by_pair,
|
cycles_by_pair=cycles_by_pair,
|
||||||
available_pairs=available_pairs,
|
available_pairs=available_pairs,
|
||||||
config=BacktestConfig(trade_capital=100.0,
|
config=BacktestConfig(trade_capital=100.0, slippage_bps=5.0, execution_latency_ms=10.0),
|
||||||
slippage_bps=5.0, execution_latency_ms=10.0),
|
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
report = asyncio.run(engine.run(
|
report = asyncio.run(engine.run(replay_events, starting_balances={"USD": 1000.0}))
|
||||||
replay_events, starting_balances={"USD": 1000.0}))
|
|
||||||
|
|
||||||
assert report.processed_events == 3
|
assert report.processed_events == 3
|
||||||
assert report.opportunities_seen >= 0
|
assert report.opportunities_seen >= 0
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from arbitrade.backtesting.replay import ReplayBookEvent
|
||||||
|
from arbitrade.backtesting.sweep import (
|
||||||
|
PromotionCriteria,
|
||||||
|
SweepResult,
|
||||||
|
build_parameter_grid,
|
||||||
|
run_parameter_search,
|
||||||
|
split_events_time_windows,
|
||||||
|
)
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph
|
||||||
|
from arbitrade.exchange.models import BookLevel
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cycles() -> dict[str, list]:
|
||||||
|
graph = CurrencyGraph()
|
||||||
|
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||||
|
graph.add_pair("BTC", "ETH", "ETH/BTC")
|
||||||
|
graph.add_pair("ETH", "USD", "ETH/USD")
|
||||||
|
return graph.index_cycles_by_pair(graph.triangular_cycles())
|
||||||
|
|
||||||
|
|
||||||
|
def _events() -> list[ReplayBookEvent]:
|
||||||
|
base_time = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
||||||
|
rows: list[ReplayBookEvent] = []
|
||||||
|
for index in range(12):
|
||||||
|
tick = base_time + timedelta(seconds=index)
|
||||||
|
rows.extend(
|
||||||
|
[
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=tick,
|
||||||
|
symbol="BTC/USD",
|
||||||
|
bids=(BookLevel(price=99.5, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=100.0, volume=10.0),),
|
||||||
|
),
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=tick,
|
||||||
|
symbol="ETH/BTC",
|
||||||
|
bids=(BookLevel(price=0.051, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=0.050, volume=10.0),),
|
||||||
|
),
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=tick,
|
||||||
|
symbol="ETH/USD",
|
||||||
|
bids=(BookLevel(price=110.0, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=110.5, volume=10.0),),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_events_time_windows_returns_non_empty_train_and_test() -> None:
|
||||||
|
train, test = split_events_time_windows(_events(), train_ratio=0.7)
|
||||||
|
|
||||||
|
assert train
|
||||||
|
assert test
|
||||||
|
assert train[-1].occurred_at <= test[0].occurred_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parameter_grid_expands_combinations() -> None:
|
||||||
|
grid = build_parameter_grid(
|
||||||
|
theta_values=[0.0005, 0.001],
|
||||||
|
trade_capital_values=[100.0],
|
||||||
|
pair_universes=[["BTC/USD", "ETH/BTC", "ETH/USD"]],
|
||||||
|
staleness_threshold_values=[3.0, 5.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(grid) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_parameter_search_produces_ranked_results_with_overfit_guard() -> None:
|
||||||
|
artifacts = run_parameter_search(
|
||||||
|
events=_events(),
|
||||||
|
cycles_by_pair=_build_cycles(),
|
||||||
|
parameter_grid=build_parameter_grid(
|
||||||
|
theta_values=[0.0005, 0.001],
|
||||||
|
trade_capital_values=[75.0, 100.0],
|
||||||
|
pair_universes=[["BTC/USD", "ETH/BTC", "ETH/USD"]],
|
||||||
|
staleness_threshold_values=[5.0],
|
||||||
|
),
|
||||||
|
starting_balances={"USD": 2000.0},
|
||||||
|
train_ratio=0.7,
|
||||||
|
promotion_criteria=PromotionCriteria(
|
||||||
|
min_test_realized_pnl_usd=-1000.0,
|
||||||
|
min_test_win_rate=0.0,
|
||||||
|
min_test_fill_rate=0.0,
|
||||||
|
max_test_drawdown_usd=1_000_000.0,
|
||||||
|
max_generalization_gap_ratio=0.9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert artifacts.results
|
||||||
|
assert artifacts.results[0].test_score >= artifacts.results[-1].test_score
|
||||||
|
|
||||||
|
first: SweepResult = artifacts.results[0]
|
||||||
|
assert first.train_event_count > 0
|
||||||
|
assert first.test_event_count > 0
|
||||||
|
assert first.generalization_gap_ratio >= 0.0
|
||||||
|
assert isinstance(first.promotion_ready, bool)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""End-to-end test for configuration management system."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.service import ConfigurationService
|
||||||
|
from arbitrade.config.settings import Settings
|
||||||
|
from arbitrade.storage.repositories import AuditRepository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_end_to_end_config_workflow():
|
||||||
|
"""Test complete configuration workflow."""
|
||||||
|
# Create mocks
|
||||||
|
settings = Mock(spec=Settings)
|
||||||
|
cursor = Mock()
|
||||||
|
cursor.fetchone.return_value = None
|
||||||
|
cursor.fetchall.return_value = []
|
||||||
|
cursor.execute.return_value = cursor
|
||||||
|
cntx = MagicMock()
|
||||||
|
cntx.__enter__.return_value = cursor
|
||||||
|
store = Mock()
|
||||||
|
store.connect.return_value = cntx
|
||||||
|
audit_repo = Mock(spec=AuditRepository)
|
||||||
|
|
||||||
|
# Create service
|
||||||
|
service = ConfigurationService(settings, store, audit_repo)
|
||||||
|
|
||||||
|
# Test initial state
|
||||||
|
assert service.get_config_version() == 0
|
||||||
|
assert service.get_last_updated_at() is None
|
||||||
|
|
||||||
|
# Test setting a value
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
# Mock the setting creation
|
||||||
|
mock_created_setting = Mock()
|
||||||
|
mock_created_setting.updated_at = "2023-01-01T00:00:00"
|
||||||
|
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
|
||||||
|
mock_repo_instance.get_setting = AsyncMock(return_value=None)
|
||||||
|
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
|
||||||
|
mock_repo_instance.list_settings = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
# Set a setting
|
||||||
|
await service.set_setting("test_key", "test_value", "test_user")
|
||||||
|
|
||||||
|
# Verify setting was retrieved
|
||||||
|
result = service.get_setting("test_key", "default")
|
||||||
|
assert result == "test_value"
|
||||||
|
|
||||||
|
# Verify version incremented
|
||||||
|
assert service.get_config_version() == 1
|
||||||
|
assert service.get_last_updated_at() is not None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_end_to_end_config_workflow()
|
||||||
|
print("End-to-end test passed!")
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Unit tests for configuration repositories."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.service import (
|
||||||
|
ConfigPairing,
|
||||||
|
ConfigSetting,
|
||||||
|
)
|
||||||
|
from arbitrade.storage.repositories import (
|
||||||
|
ConfigBacktestingDefaultsRepository,
|
||||||
|
ConfigPairingRepository,
|
||||||
|
ConfigSettingRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_store():
|
||||||
|
"""Create a mock database store with async pool."""
|
||||||
|
store = MagicMock()
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.fetchone = AsyncMock(return_value=None)
|
||||||
|
conn.fetchall = AsyncMock(return_value=[])
|
||||||
|
conn.fetch = AsyncMock(return_value=[])
|
||||||
|
conn.execute = AsyncMock(return_value=conn)
|
||||||
|
store.pool = MagicMock()
|
||||||
|
cm = AsyncMock()
|
||||||
|
cm.__aenter__.return_value = conn
|
||||||
|
store.pool.acquire.return_value = cm
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _make_row(mapping: dict):
|
||||||
|
row = MagicMock()
|
||||||
|
row.__getitem__.side_effect = lambda k: mapping[k]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
SETTING_ROW = {
|
||||||
|
"key": "test_key",
|
||||||
|
"section": "test_section",
|
||||||
|
"value_json": "test_value",
|
||||||
|
"value_type": "str",
|
||||||
|
"is_secret": False,
|
||||||
|
"is_runtime_reloadable": False,
|
||||||
|
"updated_at": "2023-01-01T00:00:00",
|
||||||
|
"updated_by": "test_user",
|
||||||
|
}
|
||||||
|
|
||||||
|
PAIRING_ROW = {
|
||||||
|
"id": 1,
|
||||||
|
"base_asset": "BTC",
|
||||||
|
"quote_asset": "USD",
|
||||||
|
"enabled": True,
|
||||||
|
"source": "Kraken",
|
||||||
|
"created_at": "2023-01-01T00:00:00",
|
||||||
|
"updated_at": "2023-01-01T00:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_setting_repository_initialization(mock_store):
|
||||||
|
"""Test ConfigSettingRepository initialization."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
assert repo._store == mock_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_setting_repository_create_setting(mock_store):
|
||||||
|
"""Test creating a configuration setting."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
|
||||||
|
|
||||||
|
setting = ConfigSetting(
|
||||||
|
key="test_key",
|
||||||
|
section="test_section",
|
||||||
|
value_json="test_value",
|
||||||
|
value_type="str",
|
||||||
|
is_secret=False,
|
||||||
|
is_runtime_reloadable=False,
|
||||||
|
updated_by="test_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await repo.create_setting(setting)
|
||||||
|
|
||||||
|
assert result.key == "test_key"
|
||||||
|
assert result.section == "test_section"
|
||||||
|
assert result.value_json == "test_value"
|
||||||
|
assert result.value_type == "str"
|
||||||
|
assert result.updated_by == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_setting_repository_get_setting(mock_store):
|
||||||
|
"""Test getting a configuration setting."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
|
||||||
|
|
||||||
|
result = await repo.get_setting("test_key")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.key == "test_key"
|
||||||
|
assert result.section == "test_section"
|
||||||
|
assert result.value_json == "test_value"
|
||||||
|
assert result.value_type == "str"
|
||||||
|
assert result.updated_by == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_setting_repository_update_setting(mock_store):
|
||||||
|
"""Test updating a configuration setting."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
|
||||||
|
|
||||||
|
setting = ConfigSetting(
|
||||||
|
key="test_key",
|
||||||
|
section="test_section",
|
||||||
|
value_json="updated_value",
|
||||||
|
value_type="str",
|
||||||
|
is_secret=False,
|
||||||
|
is_runtime_reloadable=False,
|
||||||
|
updated_by="test_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await repo.update_setting("test_key", setting)
|
||||||
|
|
||||||
|
assert result.key == "test_key"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_setting_repository_list_settings(mock_store):
|
||||||
|
"""Test listing configuration settings."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
|
||||||
|
row1 = _make_row({**SETTING_ROW, "key": "test_key1", "value_json": "test_value1"})
|
||||||
|
row2 = _make_row({**SETTING_ROW, "key": "test_key2", "value_json": "test_value2"})
|
||||||
|
conn.fetch = AsyncMock(return_value=[row1, row2])
|
||||||
|
|
||||||
|
result = await repo.list_settings()
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].key == "test_key1"
|
||||||
|
assert result[1].key == "test_key2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_setting_repository_get_latest_updated_at(mock_store):
|
||||||
|
"""Test getting latest updated timestamp."""
|
||||||
|
repo = ConfigSettingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
|
||||||
|
row = _make_row({"latest_updated_at": "2023-01-01T00:00:00"})
|
||||||
|
conn.fetchrow = AsyncMock(return_value=row)
|
||||||
|
|
||||||
|
result = await repo.get_latest_updated_at()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_pairing_repository_initialization(mock_store):
|
||||||
|
"""Test ConfigPairingRepository initialization."""
|
||||||
|
repo = ConfigPairingRepository(mock_store)
|
||||||
|
assert repo._store == mock_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_pairing_repository_create_pairing(mock_store):
|
||||||
|
"""Test creating a currency pairing."""
|
||||||
|
repo = ConfigPairingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
|
||||||
|
|
||||||
|
pairing = ConfigPairing(base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken")
|
||||||
|
|
||||||
|
result = await repo.create_pairing(pairing)
|
||||||
|
|
||||||
|
assert result.base_asset == "BTC"
|
||||||
|
assert result.quote_asset == "USD"
|
||||||
|
assert result.enabled is True
|
||||||
|
assert result.source == "Kraken"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_pairing_repository_get_pairing(mock_store):
|
||||||
|
"""Test getting a currency pairing."""
|
||||||
|
repo = ConfigPairingRepository(mock_store)
|
||||||
|
conn = await mock_store.pool.acquire().__aenter__()
|
||||||
|
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
|
||||||
|
|
||||||
|
result = await repo.get_pairing("BTC", "USD")
|
||||||
|
|
||||||
|
assert result.base_asset == "BTC"
|
||||||
|
assert result.quote_asset == "USD"
|
||||||
|
assert result.enabled is True
|
||||||
|
assert result.source == "Kraken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_backtesting_defaults_repository_initialization(mock_store):
|
||||||
|
"""Test ConfigBacktestingDefaultsRepository initialization."""
|
||||||
|
repo = ConfigBacktestingDefaultsRepository(mock_store)
|
||||||
|
assert repo._store == mock_store
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Unit tests for configuration management system."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.service import ConfigurationService
|
||||||
|
from arbitrade.config.settings import Settings
|
||||||
|
from arbitrade.storage.repositories import AuditRepository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings():
|
||||||
|
"""Create a mock settings object."""
|
||||||
|
settings = Mock(spec=Settings)
|
||||||
|
settings.app_env = "test"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_store():
|
||||||
|
"""Create a mock database store (sync — repos are patched)."""
|
||||||
|
store = MagicMock()
|
||||||
|
store.pool = MagicMock()
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_audit_repo():
|
||||||
|
"""Create a mock audit repository."""
|
||||||
|
audit_repo = Mock(spec=AuditRepository)
|
||||||
|
return audit_repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_service_initialization(mock_settings, mock_store, mock_audit_repo):
|
||||||
|
"""Test that ConfigurationService initializes correctly."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
|
||||||
|
assert service._settings == mock_settings
|
||||||
|
assert service._store == mock_store
|
||||||
|
assert service._audit_repo == mock_audit_repo
|
||||||
|
assert service._config_version == 0
|
||||||
|
assert isinstance(service._loaded_settings, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_service_get_setting(mock_settings, mock_store, mock_audit_repo):
|
||||||
|
"""Test getting configuration settings."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
service._loaded_settings = {"test_key": "test_value"}
|
||||||
|
|
||||||
|
assert service.get_setting("test_key", "default") == "test_value"
|
||||||
|
assert service.get_setting("non_existing", "default") == "default"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configuration_service_set_setting(mock_settings, mock_store, mock_audit_repo):
|
||||||
|
"""Test setting configuration settings."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
mock_created_setting = Mock()
|
||||||
|
mock_created_setting.updated_at = "2023-01-01T00:00:00"
|
||||||
|
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
|
||||||
|
mock_repo_instance.get_setting = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await service.set_setting("test_key", "test_value", "test_user")
|
||||||
|
|
||||||
|
mock_repo_instance.create_setting.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configuration_service_hot_reload_detection(
|
||||||
|
mock_settings, mock_store, mock_audit_repo
|
||||||
|
):
|
||||||
|
"""Test hot-reload detection functionality."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
|
||||||
|
assert await service.is_config_outdated() is False
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
|
||||||
|
assert await service.is_config_outdated() is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configuration_service_reload_if_changed(mock_settings, mock_store, mock_audit_repo):
|
||||||
|
"""Test hot-reload functionality."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
|
||||||
|
mock_repo_instance.list_settings = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
|
||||||
|
|
||||||
|
result = await service.reload_if_changed()
|
||||||
|
assert result is True
|
||||||
|
assert service.get_config_version() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configuration_service_get_config_version(mock_settings, mock_store, mock_audit_repo):
|
||||||
|
"""Test getting configuration version."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
assert service.get_config_version() == 0
|
||||||
|
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
mock_created_setting = Mock()
|
||||||
|
mock_created_setting.updated_at = "2023-01-01T00:00:00"
|
||||||
|
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
|
||||||
|
mock_repo_instance.get_setting = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await service.set_setting("test_key", "test_value", "test_user")
|
||||||
|
assert service.get_config_version() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configuration_service_get_last_updated_at(
|
||||||
|
mock_settings, mock_store, mock_audit_repo
|
||||||
|
):
|
||||||
|
"""Test getting last updated timestamp."""
|
||||||
|
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
|
||||||
|
assert service.get_last_updated_at() is None
|
||||||
|
|
||||||
|
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
|
||||||
|
mock_repo_instance = Mock()
|
||||||
|
mock_repo_class.return_value = mock_repo_instance
|
||||||
|
|
||||||
|
mock_created_setting = Mock()
|
||||||
|
mock_created_setting.updated_at = "2023-01-01T00:00:00"
|
||||||
|
mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
|
||||||
|
mock_repo_instance.get_setting = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await service.set_setting("test_key", "test_value", "test_user")
|
||||||
|
assert service.get_last_updated_at() is not None
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
|
||||||
from arbitrade.detection.engine import OpportunityEvent
|
|
||||||
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
|
||||||
from arbitrade.storage.db import DuckDBStore
|
|
||||||
from arbitrade.storage.executions import AsyncExecutionWriter
|
|
||||||
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _FakeRestClient:
|
|
||||||
calls: int = 0
|
|
||||||
|
|
||||||
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
|
|
||||||
self.calls += 1
|
|
||||||
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
|
|
||||||
|
|
||||||
|
|
||||||
def _sample_event() -> OpportunityEvent:
|
|
||||||
return OpportunityEvent(
|
|
||||||
detected_at=datetime.now(UTC),
|
|
||||||
cycle="USD->BTC->ETH->USD",
|
|
||||||
updated_pair="BTC/USD",
|
|
||||||
gross_rate=1.04,
|
|
||||||
net_rate=1.03,
|
|
||||||
gross_pct=4.0,
|
|
||||||
net_pct=3.0,
|
|
||||||
est_profit=0.03,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
|
|
||||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "exec.duckdb")
|
|
||||||
store = DuckDBStore(settings)
|
|
||||||
store.migrate()
|
|
||||||
writer = AsyncExecutionWriter(
|
|
||||||
TradeRepository(store),
|
|
||||||
OrderRepository(store),
|
|
||||||
PnLRepository(store),
|
|
||||||
max_queue_size=10,
|
|
||||||
)
|
|
||||||
await writer.start()
|
|
||||||
|
|
||||||
client = _FakeRestClient()
|
|
||||||
sequencer = TriangularExecutionSequencer(
|
|
||||||
client,
|
|
||||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
|
||||||
execution_writer=writer,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await sequencer.execute(_sample_event())
|
|
||||||
await writer.stop()
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
assert client.calls == 3
|
|
||||||
|
|
||||||
with store.connect() as conn:
|
|
||||||
trades = conn.execute(
|
|
||||||
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
|
|
||||||
).fetchall()
|
|
||||||
orders = conn.execute(
|
|
||||||
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
|
|
||||||
"FROM orders ORDER BY leg_index"
|
|
||||||
).fetchall()
|
|
||||||
pnls = conn.execute("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
|
|
||||||
|
|
||||||
assert len(trades) == 1
|
|
||||||
assert trades[0][1] == "filled"
|
|
||||||
assert trades[0][2] == 0.03
|
|
||||||
assert trades[0][3] == 1.0
|
|
||||||
assert trades[0][4] == "USD->BTC->ETH->USD"
|
|
||||||
assert trades[0][5] == 3
|
|
||||||
|
|
||||||
assert len(orders) == 3
|
|
||||||
assert orders[0][2] == 0
|
|
||||||
assert orders[1][2] == 1
|
|
||||||
assert orders[2][2] == 2
|
|
||||||
assert orders[0][6] == "submitted"
|
|
||||||
|
|
||||||
assert len(pnls) == 1
|
|
||||||
assert pnls[0][1] == "estimated"
|
|
||||||
assert pnls[0][2] == 0.03
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
|
||||||
from arbitrade.metrics import MetricsCalculator
|
|
||||||
from arbitrade.storage.db import DuckDBStore
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_calculator_summarizes_execution_data(tmp_path) -> None:
|
|
||||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "metrics.duckdb")
|
|
||||||
store = DuckDBStore(settings)
|
|
||||||
store.migrate()
|
|
||||||
|
|
||||||
started = datetime.now(UTC)
|
|
||||||
finished = started + timedelta(seconds=30)
|
|
||||||
started_two = started + timedelta(minutes=1)
|
|
||||||
finished_two = started_two + timedelta(seconds=90)
|
|
||||||
|
|
||||||
with store.connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO trades (
|
|
||||||
trade_ref,
|
|
||||||
started_at,
|
|
||||||
finished_at,
|
|
||||||
status,
|
|
||||||
realized_pnl,
|
|
||||||
estimated_pnl,
|
|
||||||
capital_used,
|
|
||||||
cycle,
|
|
||||||
leg_count
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
"trade-1",
|
|
||||||
started,
|
|
||||||
finished,
|
|
||||||
"filled",
|
|
||||||
12.5,
|
|
||||||
10.0,
|
|
||||||
100.0,
|
|
||||||
"USD->BTC->ETH->USD",
|
|
||||||
3,
|
|
||||||
"trade-2",
|
|
||||||
started_two,
|
|
||||||
finished_two,
|
|
||||||
"filled",
|
|
||||||
-4.5,
|
|
||||||
-2.0,
|
|
||||||
200.0,
|
|
||||||
"USD->ETH->BTC->USD",
|
|
||||||
3,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO opportunities (
|
|
||||||
detected_at,
|
|
||||||
cycle,
|
|
||||||
gross_pct,
|
|
||||||
net_pct,
|
|
||||||
est_profit,
|
|
||||||
executed
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
started,
|
|
||||||
"USD->BTC->ETH->USD",
|
|
||||||
4.0,
|
|
||||||
3.0,
|
|
||||||
0.03,
|
|
||||||
True,
|
|
||||||
started_two,
|
|
||||||
"USD->ETH->BTC->USD",
|
|
||||||
2.0,
|
|
||||||
1.0,
|
|
||||||
0.01,
|
|
||||||
False,
|
|
||||||
started_two + timedelta(seconds=30),
|
|
||||||
"USD->BTC->ETH->USD",
|
|
||||||
5.0,
|
|
||||||
4.0,
|
|
||||||
0.04,
|
|
||||||
True,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO orders (
|
|
||||||
trade_ref,
|
|
||||||
order_ref,
|
|
||||||
leg_index,
|
|
||||||
pair,
|
|
||||||
side,
|
|
||||||
volume,
|
|
||||||
user_ref,
|
|
||||||
status,
|
|
||||||
filled_volume,
|
|
||||||
avg_price,
|
|
||||||
raw_response,
|
|
||||||
recorded_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
"trade-1",
|
|
||||||
"order-1",
|
|
||||||
0,
|
|
||||||
"BTC/USD",
|
|
||||||
"buy",
|
|
||||||
2.0,
|
|
||||||
101,
|
|
||||||
"closed",
|
|
||||||
2.0,
|
|
||||||
100.0,
|
|
||||||
"{}",
|
|
||||||
started,
|
|
||||||
"trade-2",
|
|
||||||
"order-2",
|
|
||||||
0,
|
|
||||||
"ETH/USD",
|
|
||||||
"sell",
|
|
||||||
4.0,
|
|
||||||
202,
|
|
||||||
"closed",
|
|
||||||
3.0,
|
|
||||||
200.0,
|
|
||||||
"{}",
|
|
||||||
started_two,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics = MetricsCalculator(store).compute()
|
|
||||||
|
|
||||||
assert metrics.realized_pnl_usd == 8.0
|
|
||||||
assert metrics.win_rate == 0.5
|
|
||||||
assert metrics.avg_trade_duration_seconds == 60.0
|
|
||||||
assert metrics.opportunities_per_minute == 2.0
|
|
||||||
assert metrics.fill_rate == 0.875
|
|
||||||
assert metrics.latency_p50_seconds == 60.0
|
|
||||||
assert metrics.latency_p95_seconds == 87.0
|
|
||||||
assert metrics.latency_p99_seconds == pytest.approx(89.4)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from arbitrade.config.settings import Settings
|
|
||||||
from arbitrade.detection.engine import OpportunityEvent
|
|
||||||
from arbitrade.storage.db import DuckDBStore
|
|
||||||
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
|
||||||
from arbitrade.storage.repositories import OpportunityRepository
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_async_opportunity_writer_persists_events(tmp_path) -> None:
|
|
||||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "test.duckdb")
|
|
||||||
store = DuckDBStore(settings)
|
|
||||||
store.migrate()
|
|
||||||
|
|
||||||
repository = OpportunityRepository(store)
|
|
||||||
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
|
|
||||||
await writer.start()
|
|
||||||
|
|
||||||
event = OpportunityEvent(
|
|
||||||
detected_at=datetime.now(UTC),
|
|
||||||
cycle="USD->BTC->ETH->USD",
|
|
||||||
updated_pair="BTC/USD",
|
|
||||||
gross_rate=1.04,
|
|
||||||
net_rate=1.03,
|
|
||||||
gross_pct=4.0,
|
|
||||||
net_pct=3.0,
|
|
||||||
est_profit=0.03,
|
|
||||||
)
|
|
||||||
|
|
||||||
await writer.enqueue(event)
|
|
||||||
await writer.stop()
|
|
||||||
|
|
||||||
with store.connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0][0] == "USD->BTC->ETH->USD"
|
|
||||||
assert rows[0][1] == 4.0
|
|
||||||
assert rows[0][2] == 3.0
|
|
||||||
assert rows[0][3] == 0.03
|
|
||||||
assert rows[0][4] is False
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user