Compare commits

...

22 Commits

Author SHA1 Message Date
zwitschi e4f5d8dfcc refactor: clean up imports and improve code formatting across multiple files
CI / lint-test-build (push) Successful in 2m21s
2026-06-09 10:02:41 +02:00
zwitschi 403daa6cf1 Refactor log aggregation periods and improve code formatting
- Removed the "3h" and "6h" periods from the log aggregation process in maintenance.py to streamline log counts.
- Enhanced code readability by adjusting line breaks and indentation in repositories.py for better clarity.
2026-06-09 09:41:23 +02:00
zwitschi dc99f1604e Refactor code for improved readability and consistency
CI / lint-test-build (push) Successful in 54s
- Cleaned up multiline statements and removed unnecessary line breaks in various files.
- Ensured consistent formatting in function definitions and calls across the codebase.
- Updated docstrings and comments for clarity where applicable.
- Removed trailing newlines in module docstrings.
- Enhanced logging statements for better clarity in maintenance tasks.
2026-06-07 21:59:09 +02:00
zwitschi f221464daa feat: enhance backtesting panel with flash messages and pairing checks
CI / lint-test-build (push) Failing after 12s
2026-06-07 21:51:09 +02:00
zwitschi 5e7732b85f feat: add flash message support to configuration panel and improve layout
CI / lint-test-build (push) Successful in 52s
2026-06-07 19:57:42 +02:00
zwitschi 77dfb08b23 feat: update package data inclusion to add dashboard templates and reorder partials
CI / lint-test-build (push) Successful in 1m6s
2026-06-07 19:35:05 +02:00
zwitschi 9acabddb7e feat: include config HTML templates in package data
CI / lint-test-build (push) Successful in 2m23s
2026-06-07 18:46:12 +02:00
zwitschi 2fbc78f7a9 feat: add logging package with DB sink and maintenance tasks
CI / lint-test-build (push) Successful in 2m24s
2026-06-07 18:31:27 +02:00
zwitschi f58634d438 feat: add storage schema SQL file to package data inclusion
CI / lint-test-build (push) Successful in 2m31s
2026-06-07 18:29:16 +02:00
zwitschi e44876c7c7 refactor: clean up imports and improve code formatting in various modules
CI / lint-test-build (push) Successful in 49s
2026-06-07 18:17:45 +02:00
zwitschi 1e4086a0fd feat: add logging routes and update health page to display system logs
CI / lint-test-build (push) Failing after 12s
2026-06-07 18:10:50 +02:00
zwitschi cf5ff2e2d8 feat: implement logging system with aggregation and archiving tasks 2026-06-07 18:06:35 +02:00
zwitschi db2e02c316 feat: add pairings management page and integrate with Kraken API for syncing
feat: create configuration templates for alerts, Kraken settings, risk limits, and runtime settings
refactor: streamline config form by including separate template files for better organization
2026-06-07 17:44:26 +02:00
zwitschi c1dda187af refactor: migrate database schema from TIMESTAMP to TIMESTAMPTZ for better timezone handling
CI / lint-test-build (push) Successful in 1m25s
2026-06-07 15:20:27 +02:00
zwitschi af0ac94a12 refactor: update tests to use async mocks and improve readability
CI / lint-test-build (push) Failing after 12s
2026-06-07 15:05:42 +02:00
zwitschi ef22e217c7 feat: update environment configuration and improve repository handling
CI / lint-test-build (push) Failing after 11s
- Added PG_PASSWORD to .env.example for database connection.
- Removed unnecessary imports and streamlined code in various modules.
- Enhanced error handling in ConfigSettingRepository and ConfigPairingRepository.
- Updated test files to remove unused imports and improve clarity.
2026-06-07 14:50:55 +02:00
zwitschi 529ff967cc Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s
- Implemented integration tests for the execution writer to ensure trade orders and PnL are persisted correctly.
- Created integration tests for the metrics calculator to summarize execution data accurately.
- Added integration tests for the opportunity writer to verify event persistence.
- Established PostgreSQL schema validation tests to ensure all expected tables, columns, and constraints exist.
- Removed outdated unit tests that relied on DuckDB and replaced them with tests using PgStore.
2026-06-07 14:37:53 +02:00
zwitschi 54feb2ecd4 fix: remove unnecessary duckdb file from .gitignore and add the database file
CI / lint-test-build (push) Successful in 2m36s
2026-06-05 10:17:16 +02:00
zwitschi df2f4f3246 fix: update type hint for _build_section_from_row to support tuple with Any
CI / lint-test-build (push) Successful in 1m6s
2026-06-04 22:30:38 +02:00
zwitschi 8cfd969dae fix: update ws_client type hint to allow None for better clarity 2026-06-04 22:29:24 +02:00
zwitschi 3f4b9a4012 refactor: streamline WebSocket connection logging and improve data handling in repositories 2026-06-04 22:28:57 +02:00
zwitschi 4c59a0e4cb feat: Implement pairing synchronization from Kraken and enhance market data feed
- Added `sync_pairings_from_kraken` function to fetch and upsert asset pairs into the config_pairings table.
- Introduced `run_pairing_sync_loop` for periodic synchronization of pairings.
- Enhanced `KrakenWsClient` to manage subscribed symbols for market data feeds.
- Created `build_detector_from_enabled_pairings` to initialize cycle detection based on enabled pairings.
- Updated FastAPI app to start market data feed and pairing synchronization tasks.
- Added new API routes for managing pairings, including listing, toggling, and syncing from Kraken.
- Improved dashboard templates to display pairing options and allow user interaction for backtesting.
- Refactored database queries to streamline fetching and updating of pairing data.
2026-06-04 22:10:06 +02:00
73 changed files with 5478 additions and 3387 deletions
+20
View File
@@ -0,0 +1,20 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
max_line_length = 120
+5
View File
@@ -1,6 +1,11 @@
APP_ENV=dev APP_ENV=dev
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_JSON=true LOG_JSON=true
ALERTS_ENABLED=true ALERTS_ENABLED=true
+1 -1
View File
@@ -36,9 +36,9 @@ build/
dist/ 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
+22 -11
View File
@@ -6,11 +6,10 @@ 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). Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
@@ -22,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
@@ -152,7 +151,11 @@ APP_HOST=0.0.0.0
APP_PORT=9090 APP_PORT=9090
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_JSON=true LOG_JSON=true
DUCKDB_PATH=./data/arbitrade.duckdb PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
FERNET_KEY= FERNET_KEY=
KRAKEN_API_KEY= KRAKEN_API_KEY=
KRAKEN_API_SECRET= KRAKEN_API_SECRET=
@@ -182,15 +185,19 @@ Health endpoints:
## Database ## Database
DuckDB used everywhere: local dev, tests, production. PostgreSQL used everywhere: local dev, tests, production.
Default database file: Default connection:
```text ```text
./data/arbitrade.duckdb PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
``` ```
Schema bootstrap runs automatically on app startup. Schema bootstrap runs automatically on app startup via `PgStore.migrate()`.
Current tables: Current tables:
@@ -220,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
@@ -342,7 +349,7 @@ Add a persistent volume in Coolify:
- Mount Path: `/app/data` - Mount Path: `/app/data`
This preserves DuckDB and other runtime artifacts across restarts/redeploys. This preserves PostgreSQL data and other runtime artifacts across restarts/redeploys.
### 5) Configure environment variables ### 5) Configure environment variables
@@ -351,7 +358,11 @@ Add runtime environment variables in Coolify (UI: Environment Variables):
- `APP_ENV=prod` - `APP_ENV=prod`
- `APP_HOST=0.0.0.0` - `APP_HOST=0.0.0.0`
- `APP_PORT=9090` - `APP_PORT=9090`
- `DUCKDB_PATH=/app/data/arbitrade.duckdb` - `PG_HOST=postgres`
`PG_PORT=5432`
`PG_DATABASE=arbitrade`
`PG_USER=arbitrade`
`PG_PASSWORD=arbitrade`
- `LOG_LEVEL=INFO` - `LOG_LEVEL=INFO`
- `LOG_JSON=true` - `LOG_JSON=true`
- `KRAKEN_API_KEY=...` - `KRAKEN_API_KEY=...`
+13 -13
View File
@@ -7,16 +7,16 @@ This guide provides two supported deployment paths for Arbitrade on Coolify:
Reference docs: Reference docs:
- Coolify Applications: https://coolify.io/docs/applications - [Coolify Applications](https://coolify.io/docs/applications)
- Coolify Build Packs: https://coolify.io/docs/applications/build-packs - [Coolify Build Packs](https://coolify.io/docs/applications/build-packs)
- Coolify Dockerfile Build Pack: https://coolify.io/docs/applications/build-packs/dockerfile - [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 Nixpacks Build Pack](https://coolify.io/docs/applications/build-packs/nixpacks)
- Coolify CI/CD (Git providers): https://coolify.io/docs/applications/ci-cd - [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 Gitea integration](https://coolify.io/docs/applications/ci-cd/gitea/integration)
- Coolify environment variables: https://coolify.io/docs/knowledge-base/environment-variables - [Coolify environment variables](https://coolify.io/docs/knowledge-base/environment-variables)
- Coolify persistent storage: https://coolify.io/docs/knowledge-base/persistent-storage - [Coolify persistent storage](https://coolify.io/docs/knowledge-base/persistent-storage)
- Coolify health checks: https://coolify.io/docs/knowledge-base/health-checks - [Coolify health checks](https://coolify.io/docs/knowledge-base/health-checks)
- Coolify Docker registry credentials: https://coolify.io/docs/knowledge-base/docker/registry - [Coolify Docker registry credentials](https://coolify.io/docs/knowledge-base/docker/registry)
## Common Runtime Configuration ## Common Runtime Configuration
@@ -32,14 +32,14 @@ Use these values in both deployment modes.
- Add a persistent volume - Add a persistent volume
- Mount path: `/app/data` - Mount path: `/app/data`
- Set DB path to: `DUCKDB_PATH=/app/data/arbitrade.duckdb` - Set PG connection: `PG_HOST=postgres`, `PG_PORT=5432`, `PG_DATABASE=arbitrade`, `PG_USER=arbitrade`, `PG_PASSWORD=arbitrade`
### Required environment variables ### Required environment variables
- `APP_ENV=prod` - `APP_ENV=prod`
- `APP_HOST=0.0.0.0` - `APP_HOST=0.0.0.0`
- `APP_PORT=9090` - `APP_PORT=9090`
- `DUCKDB_PATH=/app/data/arbitrade.duckdb` - `PG_DATABASE=arbitrade`
- `LOG_LEVEL=INFO` - `LOG_LEVEL=INFO`
- `LOG_JSON=true` - `LOG_JSON=true`
- `KRAKEN_API_KEY=<set-in-coolify-secret>` - `KRAKEN_API_KEY=<set-in-coolify-secret>`
@@ -135,7 +135,7 @@ Update flow for new releases:
- Ensure the deployed image/wheel includes package templates under `arbitrade/web/templates/*`. - 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`. - If you build from source, do not remove packaged template files under `src/arbitrade/web/templates`.
- DB resets after deploy: - DB resets after deploy:
- Confirm persistent mount exists at `/app/data` and `DUCKDB_PATH` points there. - Confirm PostgreSQL is reachable at `PG_HOST`.
- Registry pull fails: - Registry pull fails:
- Re-check Docker registry credentials in Coolify. - Re-check Docker registry credentials in Coolify.
- App starts but unavailable externally: - App starts but unavailable externally:
+18 -15
View File
@@ -8,7 +8,7 @@ Primary goals:
- Detect and execute triangular opportunities on Kraken with fee/slippage-aware math. - Detect and execute triangular opportunities on Kraken with fee/slippage-aware math.
- Keep hot-path latency low with incremental order-book updates and event-driven scoring. - Keep hot-path latency low with incremental order-book updates and event-driven scoring.
- Persist operational data in DuckDB for dev, test, and prod. - Persist operational data in PostgreSQL for all environments.
- Provide operator controls, audit trail, and alerting through a server-rendered dashboard. - Provide operator controls, audit trail, and alerting through a server-rendered dashboard.
- Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags. - Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags.
@@ -17,7 +17,7 @@ Primary goals:
- Python 3.12+ runtime. - Python 3.12+ runtime.
- Native Kraken WebSocket on the hot path. - Native Kraken WebSocket on the hot path.
- HTMX + Jinja2 UI, no SPA build step. - HTMX + Jinja2 UI, no SPA build step.
- DuckDB everywhere. - PostgreSQL everywhere.
- Self-hosted Gitea Actions CI and Gitea registry. - Self-hosted Gitea Actions CI and Gitea registry.
- Windows development support. - Windows development support.
- Secrets must stay out of the repository. - Secrets must stay out of the repository.
@@ -32,7 +32,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- Kraken REST + WebSocket provide market data and execution. - Kraken REST + WebSocket provide market data and execution.
- FastAPI serves HTML fragments, JSON endpoints, and SSE streams. - FastAPI serves HTML fragments, JSON endpoints, and SSE streams.
- DuckDB stores trades, opportunities, snapshots, audit events, and runtime state. - PostgreSQL stores trades, opportunities, snapshots, audit events, and runtime state.
- Coolify can deploy the published image using environment variables and persistent storage. - Coolify can deploy the published image using environment variables and persistent storage.
## 4. Solution Strategy ## 4. Solution Strategy
@@ -53,9 +53,9 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- `detection/` - triangular graph and incremental detector. - `detection/` - triangular graph and incremental detector.
- `risk/` - pre-trade and trade-limit guards. - `risk/` - pre-trade and trade-limit guards.
- `execution/` - multi-leg trade sequencing. - `execution/` - multi-leg trade sequencing.
- `backtesting/` - replay engine, parameter sweep, experiment scaffolds. - `backtesting/` - replay engine, parameter sweep, experiment scaffolds. See [backtesting.md](backtesting.md).
- `strategy/` - experimental strategy modules such as stat-arb. - `strategy/` - experimental strategy modules such as stat-arb.
- `storage/` - DuckDB schema and repositories. - `storage/` - PostgreSQL schema and repositories.
- `alerting/` - multi-channel notifications. - `alerting/` - multi-channel notifications.
- `runtime/` - startup recovery and graceful shutdown. - `runtime/` - startup recovery and graceful shutdown.
@@ -64,7 +64,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates. - `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates.
- `orjson` for low-alloc parsing. - `orjson` for low-alloc parsing.
- `sortedcontainers` for book state. - `sortedcontainers` for book state.
- `duckdb` for persistence and analytics. - `asyncpg` for PostgreSQL persistence.
- `pydantic` / `pydantic-settings` for typed configuration. - `pydantic` / `pydantic-settings` for typed configuration.
- `cryptography` / keyring for secret handling. - `cryptography` / keyring for secret handling.
@@ -77,7 +77,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
3. Incremental detector scores impacted cycles. 3. Incremental detector scores impacted cycles.
4. Risk manager validates the opportunity. 4. Risk manager validates the opportunity.
5. Execution sequencer places legs if approved. 5. Execution sequencer places legs if approved.
6. Trades and snapshots persist to DuckDB. 6. Trades and snapshots persist to PostgreSQL.
7. Dashboard and alerts reflect state changes. 7. Dashboard and alerts reflect state changes.
### 6.2 Dashboard Control Flow ### 6.2 Dashboard Control Flow
@@ -89,11 +89,14 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
### 6.3 Backtesting Flow ### 6.3 Backtesting Flow
1. User selects JSONL replay file and run parameters. See [backtesting.md](backtesting.md) for full design and implementation details.
2. Replay engine loads ordered book events.
3. Detector, risk, and execution logic run in simulation mode. 1. User picks currency pairs (from config/pairings page, or all enabled).
4. Report is stored in memory for recent UI display. 2. User sets starting balances (required), time range (required), min profit threshold (required).
5. Parameter sweeps split data into train/test windows, rank results, and flag overfit. 3. Fee profile defaults to "api (from Kraken)"; slippage (4.0 bps) and execution latency (20 ms) are optional with sensible defaults.
4. Job is queued via `POST /dashboard/backtesting/run`.
5. Backend loads events from `market_snapshots` table, builds triangular cycles, runs replay engine.
6. Report stored in `backtest_jobs` table, visible in recent jobs list.
## 7. Deployment View ## 7. Deployment View
@@ -112,7 +115,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- Deploy from the published image. - Deploy from the published image.
- Configure runtime via environment variables. - Configure runtime via environment variables.
- Mount persistent storage at `/app/data` for DuckDB. - Connect to PostgreSQL at configured `PG_HOST`.
## 8. Cross-Cutting Concepts ## 8. Cross-Cutting Concepts
@@ -126,7 +129,7 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
## 9. Architecture Decisions ## 9. Architecture Decisions
- Native Kraken WS instead of a generic exchange abstraction on the hot path. - Native Kraken WS instead of a generic exchange abstraction on the hot path.
- DuckDB as the single database engine. - PostgreSQL as the single database engine.
- HTMX + Jinja2 instead of SPA frontend. - HTMX + Jinja2 instead of SPA frontend.
- Backtesting reuses production detector/risk/execution logic. - Backtesting reuses production detector/risk/execution logic.
- Experimental stat-arb stays behind a feature flag. - Experimental stat-arb stays behind a feature flag.
@@ -152,5 +155,5 @@ The bot consumes Kraken market data, detects opportunities, and executes trades
- WS: WebSocket. - WS: WebSocket.
- HTMX: HTML-over-the-wire UI library. - HTMX: HTML-over-the-wire UI library.
- SSE: Server-Sent Events. - SSE: Server-Sent Events.
- DUCKDB: Embedded analytical database used for all environments. - PGSQL: PostgreSQL database used for all environments.
- Stat arb: Statistical arbitrage, currently experimental and feature-flagged. - Stat arb: Statistical arbitrage, currently experimental and feature-flagged.
+130
View File
@@ -0,0 +1,130 @@
# Backtesting Architecture
> Detailed design and implementation of the backtesting subsystem.
> See [`README.md`](README.md#63-backtesting-flow) for the high-level user flow.
## Data Flow
```txt
market_snapshots (DB) ─┐
├──→ load_replay_events_from_db() ──→ list[ReplayBookEvent]
JSONL file ─────────────┘
BacktestReplayEngine.run()
BacktestReport
BacktestJobRepository.store_report()
```
Two event sources:
- **DB mode** (default) — loads snapshots from `market_snapshots` table. Supports symbol/time filtering.
- **File mode** — reads JSONL files from disk (legacy, used by `backtest_replay.py` script).
## Core Types
### `ReplayClock`
Timekeeper for simulation. Ensures events advance monotonically. Supports `advance_ms()` to model execution latency.
### `ReplayBookEvent`
One atomic book state at a point in time. Fields: `occurred_at`, `symbol`, `bids: tuple[BookLevel]`, `asks: tuple[BookLevel]`.
### `BacktestConfig`
| Field | Default | Description |
| ------------------------ | -------- | ----------------------------------------------------- |
| `fee_rate` | `0.0` | 0.0 → API-sourced fee from `kraken_account_snapshots` |
| `min_profit_threshold` | `0.0005` | Minimum net profit to attempt trade |
| `trade_capital` | `100.0` | Capital allocated per trade |
| `quote_asset` | `"USD"` | Base currency for P&L |
| `slippage_bps` | `4.0` | Simulated slippage in basis points |
| `execution_latency_ms` | `20.0` | Simulated latency per leg |
| `max_depth_levels` | `10` | Order book depth for detection |
| `max_concurrent_trades` | `1` | Max simultaneous trades |
| `min_order_size_by_pair` | `None` | Per-pair min order size overrides |
### `BacktestReport`
| Field | Type | Description |
| -------------------------------- | -------------- | ---------------------------------- |
| `started_at` / `finished_at` | datetime | Simulation window |
| `processed_events` | int | Events consumed |
| `opportunities_seen` | int | Detected opportunities |
| `trades_executed` | int | Successful trades |
| `win_rate` | float or None | Fraction of profitable trades |
| `fill_rate` | float or None | Average fill ratio |
| `realized_pnl_usd` | float | Net P&L after slippage |
| `max_drawdown_usd` | float | Peak-to-trough equity drop |
| `miss_reasons` | dict[str, int] | Counters for skipped opportunities |
| `execution_latency_p50/95/99_ms` | float or None | Latency percentiles |
## Simulation Client
`_SimulatedRestClient` replaces the real Kraken REST client during backtesting.
- **Slippage model:** `fill_ratio = max(0.85, 1.0 - (slippage_bps / 10000.0) * 8.0)`
- **Latency model:** Clock advances by `execution_latency_ms` before each simulated fill
- Orders always fill (status = `"closed"`) at the modeled ratio
## Job Worker
`backtest_worker` is an `asyncio.Task` started in `create_app()` lifespan:
```python
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db),
name="backtest_worker",
)
```
Workflow per job:
1. Dequeue `(job_id, config_dict)` from `asyncio.Queue`
2. Update status → `"running"` in `backtest_jobs` table
3. Load events (DB or file)
4. Build currency graph → triangular cycles
5. Instantiate `BacktestReplayEngine``engine.run()`
6. Store report → update status → `"completed"` (or `"failed"` on exception)
## Sweep Pipeline
`run_parameter_search` performs grid search over backtest parameters:
1. **Split** events into train/test windows by time ratio
2. **Build grid** — cartesian product of `theta_values × trade_capital_values × pair_universes × staleness_threshold_values`
3. **For each parameter set:**
- Filter events to pair universe + apply staleness gate
- Build cycles restricted to pair universe
- Run engine on train window → `train_report`
- Run engine on test window → `test_report`
- Score = `realized_pnl + win_rate_bonus + fill_rate_bonus - max_drawdown`
- Compute generalization gap = `|train_score - test_score| / max(train_score, test_score)`
4. **Evaluate promotion:**
- `PromotionCriteria` checks: min test P&L, min win rate ≥ 0.5, min fill rate ≥ 0.9, max drawdown ≤ $25, generalization gap ≤ 0.5
- Results passing all criteria are flagged `promotion_ready`
## UI
> See `backtesting.html` → `partials/backtesting_panel.html`.
- **Shell page** loads the panel via `hx-get="/dashboard/fragment/backtesting"`
- **Run form** — starting balances, time range, profit threshold (required); fee profile, slippage, latency (advanced/collapsible)
- **Status card** — current job status + message
- **Recent jobs table** — lists last 20 jobs with status, events, trades, P&L; each row has a detail button
- **Job detail** — `GET /dashboard/backtesting/job/{id}` returns report HTML
Pairings are managed on the `/dashboard/config/pairings` page. Backtest uses DB-enabled pairings by default when no symbols are specified.
## Source Files
| File | Role |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backtesting/replay.py` | `ReplayClock`, `ReplayBookEvent`, `BacktestConfig`, `BacktestReport`, `_SimulatedRestClient`, `BacktestReplayEngine`, `load_replay_events`, `load_replay_events_from_db` |
| `backtesting/runner.py` | `run_backtest_job`, `backtest_worker`, `_build_cycles_from_events`, `_parse_balances` |
| `backtesting/sweep.py` | `SweepParameters`, `SweepResult`, `SweepArtifacts`, `PromotionCriteria`, `split_events_time_windows`, `build_parameter_grid`, `run_parameter_search`, `persist_sweep_results` |
+88
View File
@@ -0,0 +1,88 @@
# Database Layer: Schema & Repositories
> **Database engine**: PostgreSQL 15+ on `192.168.88.35`
> **Driver**: `asyncpg` (async connection pool)
> **Store class**: `PgStore` in `src/arbitrade/storage/pg_store.py`
## Connection Lifecycle
```txt
FastAPI lifespan (create_app)
└─ PgStore.start() # creates asyncpg connection pool
└─ PgStore.migrate() # reads schema_pg.sql, creates tables
└─ ... application runs ...
└─ PgStore.stop() # closes the pool
```
All repository classes accept a `PgStore` instance and acquire connections
via `async with self._store.pool.acquire() as conn:`.
## Schema
Defined in `src/arbitrade/storage/schema_pg.sql`. 15 tables:
| Table | Purpose | PK | Notes |
| ----------------------------- | -------------------------- | --------------- | ---------------------------------------- |
| `schema_migrations` | Version tracking | `version` | Single-row per version |
| `config_sections` | Config section metadata | `id` (SERIAL) | `name` UNIQUE |
| `config_settings` | Key-value config store | `key` (VARCHAR) | JSON-serialized values |
| `config_pairings` | Currency pairs to monitor | `id` (SERIAL) | `(base_asset, quote_asset)` UNIQUE |
| `config_backtesting_defaults` | Default backtest params | `id` (SERIAL) | Singleton via `ORDER BY id DESC LIMIT 1` |
| `opportunities` | Detected arb opportunities | `id` (UUID) | |
| `trades` | Executed trades | `id` (UUID) | |
| `orders` | Individual leg orders | `id` (UUID) | |
| `pnl_events` | P&L event stream | `id` (UUID) | |
| `portfolio_snapshots` | Balance snapshots | — | Append-only |
| `market_snapshots` | Raw order-book snapshots | — | Append-only |
| `audit_events` | Audit trail | `id` (UUID) | |
| `runtime_state_snapshots` | Runtime state history | — | Append-only |
| `kraken_account_snapshots` | Fee tier + account data | — | Append-only |
| `backtest_jobs` | Backtest job records | `id` (UUID) | |
JSON columns use `JSONB` for indexability. UUID primary keys use
`gen_random_uuid()` (requires `pgcrypto` extension).
## Repository Classes
All in `src/arbitrade/storage/repositories.py`. Every method is `async def`.
| Class | Key Methods | Used By |
| ------------------------------------- | ---------------------------------------------------------- | --------------------------- |
| `MarketSnapshotRepository` | `insert()` | `AsyncMarketSnapshotWriter` |
| `OpportunityRepository` | `insert()` | `AsyncOpportunityWriter` |
| `TradeRepository` | `insert()` | `AsyncExecutionWriter` |
| `OrderRepository` | `insert()` | `AsyncExecutionWriter` |
| `PnLRepository` | `insert()` | `AsyncExecutionWriter` |
| `AuditRepository` | `insert()`, `list_recent()` | API routes, lifecycle |
| `RuntimeStateRepository` | `insert()`, `latest()` | Lifecycle, API |
| `ConfigSectionRepository` | `create_section()`, `get_section()`, `list_sections()` | Config service |
| `ConfigSettingRepository` | Full CRUD + `get_latest_updated_at()` | Config service |
| `ConfigPairingRepository` | Full CRUD + `upsert_pairing()`, `list_pairings()` | Feeds, pairing sync |
| `ConfigBacktestingDefaultsRepository` | `create_defaults()`, `get_defaults()`, `update_defaults()` | Config service |
| `KrakenAccountSnapshotRepository` | `insert_snapshot()`, `latest_snapshot()` | Fee sync loop |
| `BacktestJobRepository` | Full CRUD | Backtesting UI + worker |
## Async Writers
Three background writer tasks buffer high-frequency writes:
- **`AsyncExecutionWriter`** — trades/orders/P&L queue
- **`AsyncMarketSnapshotWriter`** — order-book snapshot queue
- **`AsyncOpportunityWriter`** — opportunity event queue
Each uses an `asyncio.Queue` and drains it in a background task with
`await repo.insert(...)`.
## Integration Tests
`tests/integration/test_postgresql_schema.py` verifies:
- Connection to PostgreSQL server
- `pgcrypto` extension availability
- All 15 tables exist after migration
- Migration is idempotent
- Correct columns per table
- Primary keys and unique constraints
- Tables start empty
- Simple INSERT/SELECT round-trip
- `ON CONFLICT ... DO UPDATE` on config_pairings
+1 -1
View File
@@ -39,7 +39,7 @@ Key end-to-end latency baselines from `latency_baseline.json`:
## Optimization Note ## Optimization Note
`MetricsCalculator.compute()` was optimized to use DuckDB SQL aggregations and quantiles, reducing Python-side row scans. `MetricsCalculator.compute()` uses PostgreSQL SQL aggregations and percentiles, reducing Python-side row scans.
Measured benchmark (`scripts/benchmark_metrics_compute.py`): Measured benchmark (`scripts/benchmark_metrics_compute.py`):
+4 -1
View File
@@ -27,7 +27,10 @@ include-package-data = true
[tool.setuptools.package-data] [tool.setuptools.package-data]
arbitrade = [ arbitrade = [
"web/templates/*.html", "web/templates/*.html",
"web/templates/config/*.html",
"web/templates/dashboard/*.html",
"web/templates/partials/*.html", "web/templates/partials/*.html",
"storage/schema_pg.sql",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -54,7 +57,7 @@ pretty = true
mypy_path = "src" mypy_path = "src"
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["duckdb", "keyring", "sortedcontainers"] module = ["asyncpg", "keyring", "sortedcontainers"]
ignore_missing_imports = true ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
+1
View File
@@ -1,4 +1,5 @@
# Unpinned dev dependencies (latest available) # Unpinned dev dependencies (latest available)
asyncpg-stubs
black black
mypy mypy
pre-commit pre-commit
+1 -1
View File
@@ -1,6 +1,6 @@
# Unpinned runtime dependencies (latest available) # Unpinned runtime dependencies (latest available)
asyncpg
cryptography cryptography
duckdb
fastapi fastapi
httptools httptools
httpx httpx
+16 -20
View File
@@ -19,12 +19,10 @@ def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> flo
if db_path is not None: if db_path is not None:
try: try:
conn = duckdb.connect(db_path) conn = duckdb.connect(db_path)
row = conn.execute( row = conn.execute("""
"""
SELECT maker_fee FROM kraken_account_snapshots SELECT maker_fee FROM kraken_account_snapshots
ORDER BY snapshot_at DESC LIMIT 1 ORDER BY snapshot_at DESC LIMIT 1
""" """).fetchone()
).fetchone()
conn.close() conn.close()
if row is not None and row[0] is not None: if row is not None and row[0] is not None:
return float(row[0]) return float(row[0])
@@ -53,14 +51,13 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.") parser = argparse.ArgumentParser(description="Run backtest.")
parser.add_argument("--events", type=Path, required=True) parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0") parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--trade-capital", type=float, default=100.0) parser.add_argument("--trade-capital", type=float, default=100.0)
parser.add_argument("--fee-rate", type=float, default=None) 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)
parser.add_argument("--db-path", type=str, default=None, help="DuckDB path for fee lookup")
args = parser.parse_args() args = parser.parse_args()
cycles_by_pair, available_pairs = _build_graph() cycles_by_pair, available_pairs = _build_graph()
@@ -79,24 +76,23 @@ def main() -> int:
config=config, config=config,
started_at=events[0].occurred_at if events else datetime.now(UTC), started_at=events[0].occurred_at if events else datetime.now(UTC),
) )
report = asyncio.run( starting_balances = _parse_balances(args.starting_balances)
engine.run(events, starting_balances=_parse_balances(args.starting_balances)) r = asyncio.run(engine.run(events, starting_balances=starting_balances))
)
print("Backtest report:") print("Backtest report:")
print(f"- processed_events: {report.processed_events}") print(f"- processed_events: {r.processed_events}")
print(f"- opportunities_seen: {report.opportunities_seen}") print(f"- opportunities_seen: {r.opportunities_seen}")
print(f"- trades_executed: {report.trades_executed}") print(f"- trades_executed: {r.trades_executed}")
print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}") print(f"- win_rate: {r.win_rate if r.win_rate is not None else 'n/a'}")
print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}") print(f"- fill_rate: {r.fill_rate if r.fill_rate is not None else 'n/a'}")
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}") print(f"- realized_pnl_usd: {r.realized_pnl_usd:.4f}")
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}") print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
print(f"- miss_reasons: {dict(report.miss_reasons)}") print(f"- miss_reasons: {dict(r.miss_reasons)}")
print( print(
"- execution_latency_ms: " "- execution_latency_ms: "
f"p50={report.execution_latency_p50_ms or 0.0:.4f}, " f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
f"p95={report.execution_latency_p95_ms or 0.0:.4f}, " f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
f"p99={report.execution_latency_p99_ms or 0.0:.4f}" f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
) )
return 0 return 0
+29 -29
View File
@@ -8,19 +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
""" FROM trades
SELECT started_at, finished_at, realized_pnl WHERE finished_at IS NOT NULL
FROM trades """
WHERE finished_at IS NOT NULL sql_d = "SELECT detected_at FROM opportunities"
""" async with store.pool.acquire() as conn:
).fetchall() trade_rows = await conn.fetch(sql_s)
opportunity_rows = conn.execute("SELECT detected_at FROM opportunities").fetchall() 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 = [
@@ -30,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:
@@ -42,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, ...]] = []
@@ -88,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,
@@ -108,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,
@@ -121,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,
@@ -142,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
+123 -5
View File
@@ -4,36 +4,114 @@ 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.backtesting.runner import backtest_worker
from arbitrade.config.pairing_sync import run_pairing_sync_loop
from arbitrade.config.service import ConfigurationService 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.fee_service import run_fee_sync_loop
from arbitrade.exchange.kraken_rest import KrakenRestClient 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) kraken_client = KrakenRestClient(settings)
fee_sync_stop_event = asyncio.Event() fee_sync_stop_event = asyncio.Event()
pairing_sync_stop_event = asyncio.Event()
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = ( backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
asyncio.Queue() 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( fee_sync_task = asyncio.create_task(
run_fee_sync_loop( run_fee_sync_loop(
@@ -43,19 +121,55 @@ def create_app(settings: Settings) -> FastAPI:
), ),
name="fee_sync_loop", 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_task = asyncio.create_task(
backtest_worker(backtest_queue, db), # type: ignore backtest_worker(backtest_queue, db), # type: ignore
name="backtest_worker", name="backtest_worker",
) )
# Start market data feed from enabled pairings
await _start_feed(app)
app.state.fee_sync_task = fee_sync_task app.state.fee_sync_task = fee_sync_task
app.state.pairing_sync_task = pairing_sync_task
app.state.backtest_task = backtest_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() 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() fee_sync_task.cancel()
try: try:
await fee_sync_task await fee_sync_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
pairing_sync_task.cancel()
try:
await pairing_sync_task
except asyncio.CancelledError:
pass
await backtest_queue.put(None) # poison pill await backtest_queue.put(None) # poison pill
backtest_task.cancel() backtest_task.cancel()
try: try:
@@ -64,18 +178,22 @@ def create_app(settings: Settings) -> FastAPI:
pass pass
await kraken_client.close() 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.kraken_client = kraken_client
app.state.fee_sync_stop_event = fee_sync_stop_event 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.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)
app.state.configuration_service = ConfigurationService(settings, db, AuditRepository(db)) svc = ConfigurationService(settings, db, app.state.audit_repository)
app.state.configuration_service = svc
app.state.backtest_recent_reports = [] app.state.backtest_recent_reports = []
app.state.dashboard_controls = DashboardControlState( app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active, is_running=not settings.kill_switch_active,
+220 -1020
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -17,6 +17,7 @@ from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.market_data.order_book import OrderBook from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.pre_trade import PreTradeValidator from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.trade_limits import TradeLimitsGuard from arbitrade.risk.trade_limits import TradeLimitsGuard
from arbitrade.storage.pg_store import PgStore
@dataclass(slots=True) @dataclass(slots=True)
@@ -185,8 +186,8 @@ 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)
def load_replay_events_from_db( async def load_replay_events_from_db(
store: object, store: PgStore,
*, *,
symbols: list[str] | None = None, symbols: list[str] | None = None,
start: datetime | None = None, start: datetime | None = None,
@@ -197,32 +198,32 @@ def load_replay_events_from_db(
Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS). Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS).
Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]} Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]}
""" """
with store.connect() as conn: # type: ignore async with store.pool.acquire() as conn:
query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1" query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1"
params: list[object] = [] params: list[object] = []
if symbols: if symbols:
placeholders = ",".join("?" for _ in symbols) placeholders = ",".join(f"${i+1}" for i in range(len(symbols)))
query += f" AND symbol IN ({placeholders})" query += f" AND symbol IN ({placeholders})"
params.extend(symbols) params.extend(symbols)
if start is not None: if start is not None:
query += " AND snapshot_at >= ?"
params.append(start) params.append(start)
query += f" AND snapshot_at >= ${len(params)}"
if end is not None: if end is not None:
query += " AND snapshot_at <= ?"
params.append(end) params.append(end)
query += f" AND snapshot_at <= ${len(params)}"
query += " ORDER BY snapshot_at ASC" query += " ORDER BY snapshot_at ASC"
rows = conn.execute(query, params).fetchall() rows = await conn.fetch(query, *params)
events: list[ReplayBookEvent] = [] events: list[ReplayBookEvent] = []
for row in rows: for row in rows:
snapshot_at: datetime = row[0] snapshot_at: datetime = row["snapshot_at"]
symbol: str = row[1] symbol: str = row["symbol"]
payload_raw = row[2] payload_raw = row["payload"]
if isinstance(payload_raw, str): if isinstance(payload_raw, str):
payload = orjson.loads(payload_raw) payload = orjson.loads(payload_raw)
+8 -8
View File
@@ -15,7 +15,7 @@ from arbitrade.backtesting.replay import (
load_replay_events_from_db, load_replay_events_from_db,
) )
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import BacktestJobRepository from arbitrade.storage.repositories import BacktestJobRepository
_LOG = structlog.get_logger(__name__) _LOG = structlog.get_logger(__name__)
@@ -50,11 +50,11 @@ def _parse_balances(raw: str) -> dict[str, float]:
async def run_backtest_job( async def run_backtest_job(
job_id: str, job_id: str,
config_dict: dict[str, object] | None, config_dict: dict[str, object] | None,
store: DuckDBStore, store: PgStore,
) -> None: ) -> None:
"""Execute a single backtest job: load events from DB or file, run engine, store report.""" """Execute a single backtest job: load events from DB or file, run engine, store report."""
repo = BacktestJobRepository(store) repo = BacktestJobRepository(store)
repo.update_status(job_id, "running") await repo.update_status(job_id, "running")
_LOG.info("backtest_job_started", job_id=job_id) _LOG.info("backtest_job_started", job_id=job_id)
try: try:
@@ -79,7 +79,7 @@ async def run_backtest_job(
elif isinstance(symbols_raw, list): elif isinstance(symbols_raw, list):
symbols = [str(s).upper() for s in symbols_raw] symbols = [str(s).upper() for s in symbols_raw]
events = load_replay_events_from_db( events = await load_replay_events_from_db(
store, store,
symbols=symbols, symbols=symbols,
start=start_dt, start=start_dt,
@@ -141,18 +141,18 @@ async def run_backtest_job(
"finished_at": report.finished_at.isoformat(), "finished_at": report.finished_at.isoformat(),
} }
repo.store_report(job_id, report_dict) await repo.store_report(job_id, report_dict)
repo.update_status(job_id, "completed") await repo.update_status(job_id, "completed")
_LOG.info("backtest_job_completed", job_id=job_id, pnl=report.realized_pnl_usd) _LOG.info("backtest_job_completed", job_id=job_id, pnl=report.realized_pnl_usd)
except Exception as exc: except Exception as exc:
repo.update_status(job_id, "failed", error=str(exc)) await repo.update_status(job_id, "failed", error=str(exc))
_LOG.exception("backtest_job_failed", job_id=job_id, error=str(exc)) _LOG.exception("backtest_job_failed", job_id=job_id, error=str(exc))
async def backtest_worker( async def backtest_worker(
queue: asyncio.Queue[tuple[str, dict[str, object] | None] | None], queue: asyncio.Queue[tuple[str, dict[str, object] | None] | None],
store: DuckDBStore, store: PgStore,
) -> None: ) -> None:
"""Worker coroutine: pull jobs from queue and execute them one at a time.""" """Worker coroutine: pull jobs from queue and execute them one at a time."""
_LOG.info("backtest_worker_started") _LOG.info("backtest_worker_started")
+79
View File
@@ -0,0 +1,79 @@
"""Sync available Kraken asset pairs into the config_pairings table."""
from __future__ import annotations
import asyncio
import structlog
from arbitrade.config.service import ConfigPairing
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ConfigPairingRepository
_LOG = structlog.get_logger(__name__)
async def sync_pairings_from_kraken(
kraken_client: KrakenRestClient,
store: PgStore,
) -> dict[str, int]:
"""Fetch all asset pairs from Kraken and upsert into config_pairings.
Returns a summary dict with 'added', 'updated', 'total' counts.
"""
asset_pairs = await kraken_client.asset_pairs()
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
repo = ConfigPairingRepository(store)
added = 0
updated = 0
total = 0
# Dedupe: pair_by_direction has entries for both (base,quote) and (quote,base).
seen_symbols: set[str] = set()
for (base, quote), symbol in graph.pair_by_direction.items():
if symbol in seen_symbols:
continue
seen_symbols.add(symbol)
existing = await repo.get_pairing(base, quote)
pairing = ConfigPairing(
base_asset=base,
quote_asset=quote,
enabled=existing.enabled if existing else False,
source="kraken",
)
try:
await repo.upsert_pairing(pairing)
total += 1
if existing:
updated += 1
else:
added += 1
except Exception:
_LOG.warning("sync_pairing_failed", base=base, quote=quote)
_LOG.info(
"pairing_sync_complete",
added=added,
updated=updated,
total=total,
)
return {"added": added, "updated": updated, "total": total}
async def run_pairing_sync_loop(
kraken_client: KrakenRestClient,
store: PgStore,
stop_event: asyncio.Event,
interval_seconds: int = 86400,
) -> None:
"""Periodically sync pairings from Kraken (default daily)."""
await sync_pairings_from_kraken(kraken_client, store)
try:
while not stop_event.is_set():
await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
await sync_pairings_from_kraken(kraken_client, store)
except (TimeoutError, asyncio.CancelledError):
pass
+17 -18
View File
@@ -7,7 +7,7 @@ import orjson
from pydantic import BaseModel from pydantic import BaseModel
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
class ConfigSection(BaseModel): class ConfigSection(BaseModel):
@@ -49,16 +49,15 @@ class ConfigBacktestingDefaults(BaseModel):
class ConfigurationService: class ConfigurationService:
"""Manages application configuration from environment and database sources.""" """Manages application configuration from environment and database sources."""
def __init__(self, settings: Settings, store: DuckDBStore, audit_repo: Any) -> None: def __init__(self, settings: Settings, store: PgStore, audit_repo: Any) -> None:
self._settings = settings self._settings = settings
self._store = store self._store = store
self._audit_repo = audit_repo self._audit_repo = audit_repo
self._config_version = 0 self._config_version = 0
self._loaded_settings: dict[str, Any] = {} self._loaded_settings: dict[str, Any] = {}
self._last_updated_at: datetime | None = None self._last_updated_at: datetime | None = None
self._load_database_settings()
def _load_database_settings(self) -> None: async def load_database_settings(self) -> None:
"""Load user settings from database and merge with defaults.""" """Load user settings from database and merge with defaults."""
# Import here to avoid circular imports # Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository from arbitrade.storage.repositories import ConfigSettingRepository
@@ -66,7 +65,7 @@ class ConfigurationService:
setting_repo = ConfigSettingRepository(self._store) setting_repo = ConfigSettingRepository(self._store)
# Load all settings from database # Load all settings from database
db_settings = setting_repo.list_settings() db_settings = await setting_repo.list_settings()
# Convert to dictionary for easy access # Convert to dictionary for easy access
for setting in db_settings: for setting in db_settings:
@@ -116,7 +115,7 @@ class ConfigurationService:
"""Get the timestamp of the last configuration update.""" """Get the timestamp of the last configuration update."""
return self._last_updated_at return self._last_updated_at
def is_config_outdated(self) -> bool: async def is_config_outdated(self) -> bool:
"""Check if configuration has been updated since last load.""" """Check if configuration has been updated since last load."""
# Import here to avoid circular imports # Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository from arbitrade.storage.repositories import ConfigSettingRepository
@@ -124,7 +123,7 @@ class ConfigurationService:
setting_repo = ConfigSettingRepository(self._store) setting_repo = ConfigSettingRepository(self._store)
# Get the latest update timestamp from database # Get the latest update timestamp from database
latest_db_update = setting_repo.get_latest_updated_at() latest_db_update = await setting_repo.get_latest_updated_at()
# Compare with our last loaded timestamp # Compare with our last loaded timestamp
if latest_db_update and self._last_updated_at: if latest_db_update and self._last_updated_at:
@@ -133,15 +132,15 @@ class ConfigurationService:
return True return True
return False return False
def reload_if_changed(self) -> bool: async def reload_if_changed(self) -> bool:
"""Reload configuration if it has been updated in the database.""" """Reload configuration if it has been updated in the database."""
if self.is_config_outdated(): if await self.is_config_outdated():
self._load_database_settings() await self.load_database_settings()
self._config_version += 1 self._config_version += 1
return True return True
return False return False
def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None: async def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None:
"""Set a configuration setting value and persist to database.""" """Set a configuration setting value and persist to database."""
# Import here to avoid circular imports # Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository from arbitrade.storage.repositories import ConfigSettingRepository
@@ -183,13 +182,13 @@ class ConfigurationService:
) )
# Check if setting exists # Check if setting exists
existing_setting = setting_repo.get_setting(key) existing_setting = await setting_repo.get_setting(key)
if existing_setting: if existing_setting:
# Update existing setting # Update existing setting
updated_setting = setting_repo.update_setting(key, setting) updated_setting = await setting_repo.update_setting(key, setting)
else: else:
# Create new setting # Create new setting
updated_setting = setting_repo.create_setting(setting) updated_setting = await setting_repo.create_setting(setting)
# Update in-memory cache # Update in-memory cache
self._loaded_settings[key] = value self._loaded_settings[key] = value
@@ -211,18 +210,18 @@ class ConfigurationService:
return ConfigPairingRepository(self._store) return ConfigPairingRepository(self._store)
def list_pairings(self) -> list[ConfigPairing]: async def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings.""" """List all currency pairings."""
r = self._pairing_repo() # type: ignore[no-untyped-call] r = self._pairing_repo() # type: ignore[no-untyped-call]
p = r.list_pairings() p = await r.list_pairings()
return p # type: ignore[no-any-return] return p # type: ignore[no-any-return]
def create_pairing( async def create_pairing(
self, base_asset: str, quote_asset: str, source: str = "manual" self, base_asset: str, quote_asset: str, source: str = "manual"
) -> ConfigPairing: ) -> ConfigPairing:
"""Create a new currency pairing.""" """Create a new currency pairing."""
r = self._pairing_repo() # type: ignore[no-untyped-call] r = self._pairing_repo() # type: ignore[no-untyped-call]
e = r.get_pairing(base_asset, quote_asset) e = await r.get_pairing(base_asset, quote_asset)
if e: if e:
return e # type: ignore[no-any-return] return e # type: ignore[no-any-return]
pairing = ConfigPairing( pairing = ConfigPairing(
+8 -2
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Dashboard module for monitoring and controlling the arbitrage bot."""
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import Request
from arbitrade.storage.repositories import (
BacktestJobRepository,
)
async def _recent_backtest_reports(request: Request) -> list[dict[str, object]]:
repo = BacktestJobRepository(request.app.state.store)
jobs = await repo.list_jobs(limit=5)
reports = []
for job in jobs:
report: dict[str, object] = {
"id": str(job.id),
"status": job.status,
}
if job.created_at is not None:
report["created_at"] = job.created_at.isoformat()
if job.finished_at is not None:
report["finished_at"] = job.finished_at.isoformat()
reports.append(report)
return reports
async def _backtesting_panel_context(
request: Request,
*,
status: str = "idle",
message: str = "Configure a replay run and execute backtest.",
latest_report: dict[str, object] | None = None,
defaults: dict[str, str] | None = None,
) -> dict[str, object]:
default_values = {
"symbols": "",
"start_time": "",
"end_time": "",
"starting_balances": "USD=1000.0",
"trade_capital": "100.0",
"min_profit_threshold": "0.0005",
"fee_profile": "api",
"custom_fee_rate": "",
"slippage_bps": "4.0",
"execution_latency_ms": "20.0",
}
if defaults is not None:
default_values.update(defaults)
reports = await _recent_backtest_reports(request)
latest = latest_report or (reports[0] if reports else None)
return {
"status": status,
"message": message,
"flash_message": "",
"no_enabled_pairings": False,
"latest_report": latest,
"recent_reports": reports,
"run_endpoint": "/dashboard/backtesting/run",
"reports_endpoint": "/dashboard/api/backtesting/reports",
**default_values,
}
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -9,7 +9,7 @@ import orjson
import structlog import structlog
from arbitrade.exchange.kraken_rest import KrakenRestClient from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ( from arbitrade.storage.repositories import (
KrakenAccountSnapshot, KrakenAccountSnapshot,
KrakenAccountSnapshotRepository, KrakenAccountSnapshotRepository,
@@ -22,7 +22,7 @@ _FEE_REFRESH_INTERVAL_SECONDS = 86400 # 1 day
async def fetch_and_store_account_snapshot( async def fetch_and_store_account_snapshot(
client: KrakenRestClient, client: KrakenRestClient,
store: DuckDBStore, store: PgStore,
) -> KrakenAccountSnapshot | None: ) -> KrakenAccountSnapshot | None:
"""Query TradeVolume + TradeBalance, persist as snapshot. """Query TradeVolume + TradeBalance, persist as snapshot.
@@ -82,7 +82,7 @@ async def fetch_and_store_account_snapshot(
fee_schedule_raw=fee_schedule if fee_schedule else None, fee_schedule_raw=fee_schedule if fee_schedule else None,
) )
repo.insert_snapshot(snapshot) await repo.insert_snapshot(snapshot)
_LOG.info( _LOG.info(
"account_snapshot_stored", "account_snapshot_stored",
fee_tier=fee_tier_str, fee_tier=fee_tier_str,
@@ -97,15 +97,13 @@ async def fetch_and_store_account_snapshot(
if isinstance(balance_data, dict): if isinstance(balance_data, dict):
eb = balance_data.get("eb") eb = balance_data.get("eb")
total_value = float(eb) if eb is not None else 0.0 total_value = float(eb) if eb is not None else 0.0
with store.connect() as conn: async with store.pool.acquire() as conn:
conn.execute( await conn.execute(
"INSERT INTO portfolio_snapshots" "INSERT INTO portfolio_snapshots"
" (snapshot_at, balances, total_value_usd) VALUES (?, ?, ?)", " (snapshot_at, balances, total_value_usd) VALUES ($1, $2, $3)",
( datetime.now(UTC),
datetime.now(UTC), orjson.dumps(wallet_balances).decode("utf-8") if wallet_balances else None,
orjson.dumps(wallet_balances).decode("utf-8") if wallet_balances else None, total_value,
total_value,
),
) )
_LOG.info("portfolio_snapshot_stored", total_value_usd=total_value) _LOG.info("portfolio_snapshot_stored", total_value_usd=total_value)
except Exception: except Exception:
@@ -116,7 +114,7 @@ async def fetch_and_store_account_snapshot(
async def run_fee_sync_loop( async def run_fee_sync_loop(
client: KrakenRestClient, client: KrakenRestClient,
store: DuckDBStore, store: PgStore,
stop_event: asyncio.Event, stop_event: asyncio.Event,
) -> None: ) -> None:
"""Periodic loop: fetch account snapshot every hour. """Periodic loop: fetch account snapshot every hour.
+41 -6
View File
@@ -32,6 +32,7 @@ class KrakenWsClient:
self._alert_notifier = alert_notifier self._alert_notifier = alert_notifier
self._has_connected_once = False self._has_connected_once = False
self._was_disconnected = False self._was_disconnected = False
self._subscribed_symbols: list[str] = []
@property @property
def is_stale(self) -> bool: def is_stale(self) -> bool:
@@ -44,29 +45,63 @@ class KrakenWsClient:
async def stop(self) -> None: async def stop(self) -> None:
self._stop.set() self._stop.set()
def set_subscribed_symbols(self, symbols: list[str]) -> None:
"""Set the list of symbols to subscribe to on (re)connect."""
self._subscribed_symbols = list(symbols)
async def _subscribe(self, ws: Any) -> None:
"""Send Kraken WS v2 subscribe message for book channel."""
if not self._subscribed_symbols:
_LOG.warning("kraken_ws_no_symbols_to_subscribe")
return
depth = 10
if hasattr(self._settings, "kraken_ws_book_depth"):
depth = self._settings.kraken_ws_book_depth
msg = orjson.dumps(
{
"method": "subscribe",
"params": {
"channel": "book",
"symbol": self._subscribed_symbols,
"depth": depth,
},
}
)
await ws.send(msg)
_LOG.info(
"kraken_ws_subscribed",
symbol_count=len(self._subscribed_symbols),
symbols=self._subscribed_symbols,
)
async def connect_stream(self) -> AsyncIterator[WsMessage]: async def connect_stream(self) -> AsyncIterator[WsMessage]:
delay = 1.0 delay = 1.0
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
async with websockets.connect( url = self._settings.kraken_ws_url
self._settings.kraken_ws_url, max_size=2_000_000 async with websockets.connect(url, max_size=2_000_000) as ws:
) as ws: _LOG.info("kraken_ws_connected", url=url)
_LOG.info("kraken_ws_connected", url=self._settings.kraken_ws_url)
if self._has_connected_once and self._was_disconnected: if self._has_connected_once and self._was_disconnected:
await self._notify( await self._notify(
category="system", category="system",
severity="info", severity="info",
title="WebSocket reconnected", title="WebSocket reconnected",
message="Kraken WebSocket connection restored.", message="Kraken WebSocket connection restored.",
details={"url": self._settings.kraken_ws_url}, details={"url": url},
) )
self._has_connected_once = True self._has_connected_once = True
self._was_disconnected = False self._was_disconnected = False
delay = 1.0 delay = 1.0
await self._subscribe(ws)
async for raw in self._recv_loop(ws): async for raw in self._recv_loop(ws):
yield raw yield raw
except Exception as exc: except Exception as exc:
_LOG.warning("kraken_ws_disconnected", error=str(exc), reconnect_in=delay) log = (
"kraken_ws_disconnected_first_time"
if not self._has_connected_once
else "kraken_ws_disconnected"
)
_LOG.warning(log, error=str(exc), reconnect_in=delay)
self._was_disconnected = True self._was_disconnected = True
await self._notify( await self._notify(
category="system", category="system",
+2 -2
View File
@@ -158,7 +158,7 @@ class TriangularExecutionSequencer:
) )
except Exception as exc: except Exception as exc:
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -265,7 +265,7 @@ class TriangularExecutionSequencer:
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
+1
View File
@@ -0,0 +1 @@
"""Logging package — DB sink, maintenance tasks."""
+119
View File
@@ -0,0 +1,119 @@
"""DB sink — writes structlog events to app_logs table via background queue."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogRecord, LogRepository
_LOG = structlog.get_logger(__name__)
class DbSinkProcessor:
"""structlog processor that queues log events for DB writes.
Must be registered in the structlog processor chain. The consumer
task must be started on app init via ``start_consumer(store)``.
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=2000)
self._consumer_task: asyncio.Task[None] | None = None
def __call__(self, logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Processor — called for every structlog event. Non-blocking."""
try:
self._queue.put_nowait(dict(event_dict))
except asyncio.QueueFull:
pass # drop event if queue full, avoid backpressure
return event_dict
def start_consumer(self, store: PgStore) -> None:
"""Start background consumer task."""
if self._consumer_task is not None and not self._consumer_task.done():
return
self._consumer_task = asyncio.create_task(self._consume(store), name="log_db_sink")
async def stop_consumer(self) -> None:
"""Drain queue and cancel consumer."""
if self._consumer_task is None:
return
self._consumer_task.cancel()
try:
await self._consumer_task
except asyncio.CancelledError:
pass
self._consumer_task = None
# Flush remaining
await self._flush(store=None) # type: ignore[call-arg]
async def _consume(self, store: PgStore) -> None:
repo = LogRepository(store)
while True:
try:
event = await self._queue.get()
await self._write_one(repo, event)
except asyncio.CancelledError:
break
except Exception:
pass # swallow consumer errors, never crash
# Final flush
await self._flush(repo)
async def _write_one(self, repo: LogRepository, event: dict[str, Any]) -> None:
recorded_at = event.pop("timestamp", None)
if isinstance(recorded_at, str):
try:
recorded_at = datetime.fromisoformat(recorded_at)
except ValueError:
recorded_at = datetime.now(UTC)
elif not isinstance(recorded_at, datetime):
recorded_at = datetime.now(UTC)
level = str(event.pop("level", "info")).upper()
logger = str(event.pop("logger", "root"))
message = str(event.pop("event", event.pop("message", "")))
context = {k: v for k, v in event.items() if not k.startswith("_")} if event else None
record = LogRecord(
recorded_at=recorded_at,
level=level,
logger=logger,
message=message,
context=context if context else None,
)
try:
await repo.insert(record)
except Exception:
pass # never crash from DB write failure
async def _flush(self, repo: LogRepository | None) -> None:
drained = 0
while not self._queue.empty() and drained < 500:
try:
event = self._queue.get_nowait()
if repo is not None:
await self._write_one(repo, event)
drained += 1
except asyncio.QueueEmpty:
break
except Exception:
pass
# Module-level singleton
_db_sink = DbSinkProcessor()
def get_db_sink() -> DbSinkProcessor:
return _db_sink
def db_sink_processor(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Standalone processor function wrapping the singleton."""
return _db_sink(logger, method_name, event_dict)
+60
View File
@@ -0,0 +1,60 @@
"""Log maintenance — aggregation and archiving tasks."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
import structlog
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import LogAggregationRepository, LogArchiveRepository
_LOG = structlog.get_logger(__name__)
_AGGREGATE_INTERVAL = 3600 # 1 hour
_ARCHIVE_INTERVAL = 86400 # 1 day
_RETENTION_DAYS = 30
async def run_log_aggregation(store: PgStore) -> None:
"""Aggregate log counts for the last 2 hours across all periods."""
repo = LogAggregationRepository(store)
since = datetime.now(UTC) - timedelta(hours=2)
periods = ["1h", "1d", "1w", "1mo"]
for period in periods:
try:
await repo.aggregate_since(since, period)
except Exception:
_LOG.exception("log_aggregation_failed", period=period)
_LOG.info("log_aggregation_complete", since=since.isoformat())
async def run_log_archive(store: PgStore, retention_days: int = _RETENTION_DAYS) -> int:
"""Archive log entries older than retention_days."""
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
repo = LogArchiveRepository(store)
count = await repo.archive_before(cutoff)
if count > 0:
_LOG.info("log_archive_complete", cutoff=cutoff.isoformat(), archived=count)
return count
async def run_log_aggregation_loop(store: PgStore) -> None:
"""Periodic aggregation loop."""
while True:
try:
await run_log_aggregation(store)
except Exception:
_LOG.exception("log_aggregation_loop_error")
await asyncio.sleep(_AGGREGATE_INTERVAL)
async def run_log_archive_loop(store: PgStore) -> None:
"""Periodic archive loop."""
while True:
try:
await run_log_archive(store)
except Exception:
_LOG.exception("log_archive_loop_error")
await asyncio.sleep(_ARCHIVE_INTERVAL)
+3
View File
@@ -6,6 +6,8 @@ from typing import Any
import structlog import structlog
from arbitrade.logging.db_sink import db_sink_processor
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None: def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
level = getattr(logging, log_level.upper(), logging.INFO) level = getattr(logging, log_level.upper(), logging.INFO)
@@ -17,6 +19,7 @@ def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
timestamper, timestamper,
db_sink_processor,
] ]
if json_logs: if json_logs:
+11 -11
View File
@@ -144,7 +144,7 @@ class MarketDataFeed:
symbol=delta.symbol, symbol=delta.symbol,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -172,7 +172,7 @@ class MarketDataFeed:
for event in opportunities: for event in opportunities:
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="detector", actor="detector",
@@ -207,7 +207,7 @@ class MarketDataFeed:
net_pct=event.net_pct, net_pct=event.net_pct,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -228,7 +228,7 @@ class MarketDataFeed:
updated_pair=event.updated_pair, updated_pair=event.updated_pair,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -250,7 +250,7 @@ class MarketDataFeed:
reason=self._kill_switch.reason, reason=self._kill_switch.reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -275,7 +275,7 @@ class MarketDataFeed:
reason=self._stop_conditions_guard.halted_reason, reason=self._stop_conditions_guard.halted_reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -298,7 +298,7 @@ class MarketDataFeed:
reason=self._loss_limit_guard.halted_reason, reason=self._loss_limit_guard.halted_reason,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -329,7 +329,7 @@ class MarketDataFeed:
required_by_asset=required_balances, required_by_asset=required_balances,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -358,7 +358,7 @@ class MarketDataFeed:
exposure_by_asset=exposure_by_asset, exposure_by_asset=exposure_by_asset,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="risk_manager", actor="risk_manager",
@@ -420,7 +420,7 @@ class MarketDataFeed:
updated_pair=event.updated_pair, updated_pair=event.updated_pair,
) )
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
@@ -459,7 +459,7 @@ class MarketDataFeed:
self._trade_limits_guard.close_trade(exposure_by_asset) self._trade_limits_guard.close_trade(exposure_by_asset)
if self._audit_repository is not None: if self._audit_repository is not None:
self._audit_repository.insert( await self._audit_repository.insert(
AuditRecord( AuditRecord(
occurred_at=datetime.now(UTC), occurred_at=datetime.now(UTC),
actor="execution_engine", actor="execution_engine",
+62
View File
@@ -0,0 +1,62 @@
"""Build production MarketDataFeed components from enabled pairings."""
from __future__ import annotations
import structlog
from arbitrade.detection.engine import IncrementalCycleDetector
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ConfigPairingRepository
_LOG = structlog.get_logger(__name__)
async def build_detector_from_enabled_pairings(
store: PgStore,
*,
fee_rate: float = 0.0,
max_depth_levels: int = 10,
min_profit_threshold: float = 0.0005,
) -> IncrementalCycleDetector | None:
"""Build an IncrementalCycleDetector using only enabled pairings from DB.
Returns None if no enabled pairings exist.
"""
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings(enabled_only=True)
if not pairings:
_LOG.warning("no_enabled_pairings_found_detector_not_created")
return None
# Build CurrencyGraph from enabled pairings and discover cycles
graph = CurrencyGraph()
for p in pairings:
symbol = f"{p.base_asset}/{p.quote_asset}"
graph.add_pair(p.base_asset, p.quote_asset, symbol)
cycles = graph.triangular_cycles()
if not cycles:
_LOG.warning("no_triangular_cycles_from_enabled_pairings")
return None
cycles_by_pair = graph.index_cycles_by_pair(cycles)
_LOG.info(
"detector_built_from_enabled_pairings",
enabled_count=len(pairings),
cycle_count=len(cycles),
)
return IncrementalCycleDetector(
cycles_by_pair,
fee_rate=fee_rate,
max_depth_levels=max_depth_levels,
min_profit_threshold=min_profit_threshold,
)
async def get_enabled_pair_symbols(store: PgStore) -> list[str]:
"""Return list of enabled pair symbols (e.g. ['BTC/USD', 'ETH/BTC'])."""
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings(enabled_only=True)
return [f"{p.base_asset}/{p.quote_asset}" for p in pairings if p.enabled]
+55 -40
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -19,63 +19,66 @@ class PerformanceMetrics:
class MetricsCalculator: class MetricsCalculator:
def __init__(self, store: DuckDBStore) -> None: def __init__(self, store: PgStore) -> None:
self._store = store self._store = store
def compute(self) -> PerformanceMetrics: async def compute(self) -> PerformanceMetrics:
with self._store.connect() as conn: async with self._store.pool.acquire() as conn:
tm = conn.execute( tm = await conn.fetchrow("""
"""
SELECT SELECT
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd, COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
COUNT(*) AS total_trades, COUNT(*) AS total_trades,
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades, SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
AVG(EPOCH(finished_at) - EPOCH(started_at)) AS avg_trade_duration_seconds, AVG(EXTRACT(EPOCH FROM finished_at - started_at)) AS avg_trade_duration_seconds,
quantile_cont( PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p50_seconds,
EPOCH(finished_at) - EPOCH(started_at), PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p95_seconds,
0.50 PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p99_seconds
) AS latency_p50_seconds,
quantile_cont(
EPOCH(finished_at) - EPOCH(started_at),
0.95
) AS latency_p95_seconds,
quantile_cont(
EPOCH(finished_at) - EPOCH(started_at),
0.99
) AS latency_p99_seconds
FROM trades FROM trades
WHERE finished_at IS NOT NULL WHERE finished_at IS NOT NULL
""" """)
).fetchone()
om = conn.execute( om = await conn.fetchrow("""
"""
SELECT SELECT
COUNT(*) AS opportunity_count, COUNT(*) AS opportunity_count,
MIN(detected_at) AS first_detected_at, MIN(detected_at) AS first_detected_at,
MAX(detected_at) AS last_detected_at MAX(detected_at) AS last_detected_at
FROM opportunities FROM opportunities
""" """)
).fetchone()
fm = conn.execute( fm = await conn.fetchrow("""
"""
SELECT AVG(filled_volume / volume) AS fill_rate SELECT AVG(filled_volume / volume) AS fill_rate
FROM orders FROM orders
WHERE volume > 0 AND filled_volume IS NOT NULL WHERE volume > 0 AND filled_volume IS NOT NULL
""" """)
).fetchone()
r_pnl_usd = float(tm[0]) if tm and tm[0] is not None else 0.0 r_pnl_usd = (
tt = int(tm[1]) if tm and tm[1] is not None else 0 float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
wt = int(tm[2]) if tm and tm[2] is not None else 0 )
tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
wt = int(tm["winning_trades"]) if tm and tm["winning_trades"] is not None else 0
wr = wt / tt if tt > 0 else None wr = wt / tt if tt > 0 else None
atd = float(tm[3]) if tm and tm[3] is not None else None atd = (
float(tm["avg_trade_duration_seconds"])
if tm and tm["avg_trade_duration_seconds"] is not None
else None
)
oc = int(om[0]) if om is not None and om[0] is not None else 0 oc = (
fo = om[1] if om is not None and isinstance(om[1], datetime) else None int(om["opportunity_count"])
lo = om[2] if om is not None and isinstance(om[2], datetime) else None if om is not None and om["opportunity_count"] is not None
else 0
)
fo = (
om["first_detected_at"]
if om is not None and isinstance(om["first_detected_at"], datetime)
else None
)
lo = (
om["last_detected_at"]
if om is not None and isinstance(om["last_detected_at"], datetime)
else None
)
opportunities_per_minute: float | None opportunities_per_minute: float | None
if oc >= 2 and fo is not None and lo is not None: if oc >= 2 and fo is not None and lo is not None:
@@ -88,11 +91,23 @@ class MetricsCalculator:
else: else:
opportunities_per_minute = None opportunities_per_minute = None
fill_rate = float(fm[0]) if fm and fm[0] is not None else None fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
lp50 = float(tm[4]) if tm and tm[4] is not None else None lp50 = (
lp95 = float(tm[5]) if tm and tm[5] is not None else None float(tm["latency_p50_seconds"])
lp99 = float(tm[6]) if tm and tm[6] is not None else None if tm and tm["latency_p50_seconds"] is not None
else None
)
lp95 = (
float(tm["latency_p95_seconds"])
if tm and tm["latency_p95_seconds"] is not None
else None
)
lp99 = (
float(tm["latency_p99_seconds"])
if tm and tm["latency_p99_seconds"] is not None
else None
)
return PerformanceMetrics( return PerformanceMetrics(
realized_pnl_usd=r_pnl_usd, realized_pnl_usd=r_pnl_usd,
+26 -28
View File
@@ -8,7 +8,7 @@ from typing import Any, cast
from fastapi import FastAPI from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState from arbitrade.api.control_state import DashboardControlState
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ( from arbitrade.storage.repositories import (
AuditRecord, AuditRecord,
AuditRepository, AuditRepository,
@@ -29,8 +29,8 @@ def _controls(app: FastAPI) -> DashboardControlState:
return cast(DashboardControlState, app.state.dashboard_controls) return cast(DashboardControlState, app.state.dashboard_controls)
def _store(app: FastAPI) -> DuckDBStore: def _store(app: FastAPI) -> PgStore:
return cast(DuckDBStore, app.state.store) return cast(PgStore, app.state.store)
def _audit_repository(app: FastAPI) -> AuditRepository | None: def _audit_repository(app: FastAPI) -> AuditRepository | None:
@@ -43,38 +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,
@@ -84,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",
@@ -110,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
@@ -122,11 +120,11 @@ def persist_runtime_snapshot(app: FastAPI, *, note: str | None = None) -> Runtim
is_running=controls.is_running, is_running=controls.is_running,
kill_switch_active=controls.kill_switch.is_active, kill_switch_active=controls.kill_switch.is_active,
kill_switch_reason=controls.kill_switch.reason, kill_switch_reason=controls.kill_switch.reason,
open_trade_count=_open_trade_count(store), open_trade_count=await _open_trade_count(store),
last_known_balances=_latest_balances(store), last_known_balances=await _latest_balances(store),
note=note, note=note,
) )
repository.insert(snapshot) await repository.insert(snapshot)
return snapshot return snapshot
@@ -138,7 +136,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
restored_from_snapshot = False restored_from_snapshot = False
snapshot_at: str | None = None snapshot_at: str | None = None
latest = repo.latest() if repo is not None else None latest = await repo.latest() if repo is not None else None
if latest is not None: if latest is not None:
restored_from_snapshot = True restored_from_snapshot = True
snapshot_at = latest.snapshot_at.isoformat() snapshot_at = latest.snapshot_at.isoformat()
@@ -150,7 +148,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
ctl.kill_switch.deactivate() ctl.kill_switch.deactivate()
ctl.mark_updated() ctl.mark_updated()
open_trades = _open_trade_count(store) open_trades = await _open_trade_count(store)
restart_guard_active = False restart_guard_active = False
if open_trades > 0: if open_trades > 0:
ctl.is_running = False ctl.is_running = False
@@ -167,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",
@@ -216,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",
@@ -224,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")
-308
View File
@@ -1,308 +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 config_sections (
id INTEGER PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL,
description TEXT,
updated_at TIMESTAMP 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 TIMESTAMP DEFAULT current_timestamp,
updated_by VARCHAR
);
CREATE TABLE IF NOT EXISTS config_pairings (
id INTEGER PRIMARY KEY,
base_asset VARCHAR NOT NULL,
quote_asset VARCHAR NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
source VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp,
UNIQUE(base_asset, quote_asset)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id INTEGER PRIMARY KEY,
starting_balances JSON,
trade_capital DOUBLE,
min_profit_threshold DOUBLE,
slippage_bps INTEGER,
execution_latency_ms INTEGER,
fee_source VARCHAR DEFAULT 'api'
);
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
);
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMP NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE,
taker_fee DOUBLE,
thirty_day_volume DOUBLE,
trade_balance_raw JSON,
fee_schedule_raw JSON
);
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT uuid(),
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSON,
report JSON,
error VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp,
started_at TIMESTAMP,
finished_at TIMESTAMP
);
"""
class DuckDBStore:
SCHEMA_VERSION = 5
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 _get_table_columns(self, conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]:
try:
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
return {str(row[1]) for row in rows}
except Exception:
return set()
def _table_exists(self, conn: duckdb.DuckDBPyConnection, table_name: str) -> bool:
try:
result = conn.execute(
f"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{table_name}'"
).fetchone()
count = result[0] if result else 0
return count > 0
except Exception:
return False
def _ensure_column(
self, conn: duckdb.DuckDBPyConnection, table_name: str, column_def: str
) -> None:
"""Add a column to a table if it doesn't already exist."""
existing = self._get_table_columns(conn, table_name)
col_name = column_def.split()[0]
if col_name not in existing:
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_def}")
def migrate(self) -> None:
with self.connect() as conn:
# Run CREATE TABLE IF NOT EXISTS for all tables
conn.execute(SCHEMA_SQL)
# Ensure schema_migrations table exists and get current version
if not self._table_exists(conn, "schema_migrations"):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
)
"""
)
# Get current schema version
try:
row = conn.execute(
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
).fetchone()
current_version = row[0] if row else 0
except Exception:
current_version = 0
# Apply migrations for each version
if current_version < 1:
# Migration v1: Add missing columns to trades table
# Note: DuckDB does not support ADD COLUMN with constraints
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS trade_ref VARCHAR")
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS estimated_pnl DOUBLE")
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS capital_used DOUBLE")
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS cycle VARCHAR")
conn.execute("ALTER TABLE trades ADD COLUMN IF NOT EXISTS leg_count INTEGER")
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (1)")
_LOG.info("migration_applied", version=1)
if current_version < 2:
# Migration v2: Ensure config_backtesting_defaults table
# config_backtesting_defaults already created by SCHEMA_SQL
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (2)")
_LOG.info("migration_applied", version=2)
if current_version < 3:
# Migration v3: Add kraken_account_snapshots table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMP NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE,
taker_fee DOUBLE,
thirty_day_volume DOUBLE,
trade_balance_raw JSON,
fee_schedule_raw JSON
)
"""
)
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
_LOG.info("migration_applied", version=3)
if current_version < 4:
# Migration v4: Add fee_source to backtesting defaults
conn.execute(
"ALTER TABLE config_backtesting_defaults"
" ADD COLUMN IF NOT EXISTS fee_source VARCHAR DEFAULT 'api'"
)
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)")
_LOG.info("migration_applied", version=4)
if current_version < 5:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT uuid(),
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSON,
report JSON,
error VARCHAR,
created_at TIMESTAMP DEFAULT current_timestamp,
started_at TIMESTAMP,
finished_at TIMESTAMP
)
"""
)
conn.execute("INSERT OR IGNORE INTO schema_migrations (version) VALUES (5)")
_LOG.info("migration_applied", version=5)
# Update version to current
conn.execute(
f"INSERT OR REPLACE INTO schema_migrations (version, applied_at) "
f"VALUES ({self.SCHEMA_VERSION}, current_timestamp)"
)
+3 -3
View File
@@ -55,11 +55,11 @@ class AsyncExecutionWriter:
try: try:
if isinstance(record, TradeRecord): if isinstance(record, TradeRecord):
self._trade_repository.insert(record) await self._trade_repository.insert(record)
elif isinstance(record, OrderRecord): elif isinstance(record, OrderRecord):
self._order_repository.insert(record) await self._order_repository.insert(record)
else: else:
self._pnl_repository.insert(record) await self._pnl_repository.insert(record)
except Exception as exc: except Exception as exc:
_LOG.error("execution_write_failed", error=str(exc)) _LOG.error("execution_write_failed", error=str(exc))
finally: finally:
+1 -1
View File
@@ -49,7 +49,7 @@ class AsyncMarketSnapshotWriter:
continue continue
try: try:
self._repository.insert( await self._repository.insert(
MarketSnapshotRecord( MarketSnapshotRecord(
snapshot_at=item.snapshot_at, snapshot_at=item.snapshot_at,
symbol=item.symbol, symbol=item.symbol,
+1 -1
View File
@@ -38,7 +38,7 @@ class AsyncOpportunityWriter:
continue continue
try: try:
self._repository.insert( await self._repository.insert(
OpportunityRecord( OpportunityRecord(
detected_at=event.detected_at, detected_at=event.detected_at,
cycle=event.cycle, cycle=event.cycle,
+132
View File
@@ -0,0 +1,132 @@
"""PostgreSQL store — async connection pool wrapper around asyncpg."""
from __future__ import annotations
from pathlib import Path
import asyncpg
import structlog
from arbitrade.config.settings import Settings
_LOG = structlog.get_logger(__name__)
SCHEMA_VERSION = 1
class PgStore:
"""Async PostgreSQL connection pool for the arbitrade bot.
Wraps an ``asyncpg.Pool`` with schema migration support.
"""
def __init__(self, settings: Settings) -> None:
self._dsn: str | None = None
self._pool: asyncpg.Pool | None = None
self._settings = settings
# ── lifecycle ────────────────────────────────────────────────
async def start(self) -> None:
"""Create the connection pool."""
s = self._settings
self._pool = await asyncpg.create_pool(
host=s.pg_host,
port=s.pg_port,
database=s.pg_database,
user=s.pg_user,
password=s.pg_password,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
_LOG.info(
"pg_pool_created",
host=s.pg_host,
database=s.pg_database,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
async def stop(self) -> None:
"""Close the connection pool."""
if self._pool is not None:
await self._pool.close()
self._pool = None
_LOG.info("pg_pool_closed")
@property
def pool(self) -> asyncpg.Pool:
"""Return the underlying connection pool.
Raises ``RuntimeError`` if ``start()`` has not been called yet.
"""
if self._pool is None:
raise RuntimeError("PgStore not started — call start() first")
return self._pool
# ── schema migration ─────────────────────────────────────────
async def migrate(self) -> None:
"""Apply the PostgreSQL schema.
Reads ``schema_pg.sql`` from the same package directory and
executes it, then records the migration version.
"""
schema_path = Path(__file__).with_name("schema_pg.sql")
schema_sql = schema_path.read_text(encoding="utf-8")
async with self.pool.acquire() as conn:
# Apply the full schema (CREATE TABLE IF NOT EXISTS …)
await conn.execute(schema_sql)
# Record the current schema version
await conn.execute(
"""
INSERT INTO schema_migrations (version, applied_at)
VALUES ($1, CURRENT_TIMESTAMP)
ON CONFLICT (version) DO UPDATE SET applied_at = CURRENT_TIMESTAMP
""",
SCHEMA_VERSION,
)
_LOG.info("pg_schema_migrated", version=SCHEMA_VERSION)
# ── helpers ──────────────────────────────────────────────────
async def table_exists(self, table_name: str) -> bool:
"""Check if a table exists in the current schema."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return bool(row and row["cnt"] > 0)
async def get_table_columns(self, table_name: str) -> set[str]:
"""Return the set of column names for *table_name*."""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return {str(r["column_name"]) for r in rows}
async def ensure_column(self, table_name: str, column_def: str) -> None:
"""Add a column to *table_name* if it does not already exist.
``column_def`` should be something like ``"my_col VARCHAR"``.
"""
existing = await self.get_table_columns(table_name)
col_name = column_def.split()[0]
if col_name not in existing:
async with self.pool.acquire() as conn:
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_def}")
_LOG.info("pg_column_added", table=table_name, column=col_name)
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
-- PostgreSQL schema for arbitrade bot
-- Requires pgcrypto extension for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ========================================
-- Schema version tracking
-- ========================================
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ========================================
-- Configuration
-- ========================================
CREATE TABLE IF NOT EXISTS config_sections (
id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS config_settings (
key VARCHAR PRIMARY KEY,
section VARCHAR NOT NULL,
value_json TEXT NOT NULL,
value_type VARCHAR NOT NULL,
is_secret BOOLEAN DEFAULT FALSE,
is_runtime_reloadable BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR
);
CREATE TABLE IF NOT EXISTS config_pairings (
id SERIAL PRIMARY KEY,
base_asset VARCHAR NOT NULL,
quote_asset VARCHAR NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
source VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(base_asset, quote_asset)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id SERIAL PRIMARY KEY,
starting_balances JSONB,
trade_capital DOUBLE PRECISION,
min_profit_threshold DOUBLE PRECISION,
slippage_bps INTEGER,
execution_latency_ms INTEGER,
fee_source VARCHAR DEFAULT 'api'
);
-- ========================================
-- Detection & Execution
-- ========================================
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
detected_at TIMESTAMPTZ NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE PRECISION,
net_pct DOUBLE PRECISION,
est_profit DOUBLE PRECISION,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
status VARCHAR NOT NULL,
realized_pnl DOUBLE PRECISION,
estimated_pnl DOUBLE PRECISION,
capital_used DOUBLE PRECISION,
cycle VARCHAR,
leg_count INTEGER
);
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
order_ref VARCHAR NOT NULL,
leg_index INTEGER NOT NULL,
pair VARCHAR NOT NULL,
side VARCHAR NOT NULL,
volume DOUBLE PRECISION NOT NULL,
user_ref INTEGER,
status VARCHAR,
filled_volume DOUBLE PRECISION,
avg_price DOUBLE PRECISION,
raw_response JSONB,
recorded_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
kind VARCHAR NOT NULL,
pnl_usd DOUBLE PRECISION NOT NULL,
source VARCHAR NOT NULL
);
-- ========================================
-- Snapshots & Monitoring
-- ========================================
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
balances JSONB,
total_value_usd DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL,
payload JSONB NOT NULL,
latency_ms DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL,
payload JSONB,
correlation_id VARCHAR
);
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
is_running BOOLEAN NOT NULL,
kill_switch_active BOOLEAN NOT NULL,
kill_switch_reason VARCHAR,
open_trade_count INTEGER NOT NULL,
last_known_balances JSONB,
note VARCHAR
);
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE PRECISION,
taker_fee DOUBLE PRECISION,
thirty_day_volume DOUBLE PRECISION,
trade_balance_raw JSONB,
fee_schedule_raw JSONB
);
-- ========================================
-- Backtesting
-- ========================================
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSONB,
report JSONB,
error VARCHAR,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
-- ========================================
-- Migration: convert legacy TIMESTAMP→TIMESTAMPTZ
-- for databases created before the fix.
-- These are idempotent (no-op when already TIMESTAMPTZ).
-- ========================================
ALTER TABLE audit_events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at AT TIME ZONE 'UTC';
ALTER TABLE runtime_state_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE schema_migrations ALTER COLUMN applied_at TYPE TIMESTAMPTZ USING applied_at AT TIME ZONE 'UTC';
ALTER TABLE config_sections ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_settings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE opportunities ALTER COLUMN detected_at TYPE TIMESTAMPTZ USING detected_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
ALTER TABLE orders ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE pnl_events ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE portfolio_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE market_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE kraken_account_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
-- ========================================
-- Logging tables
-- ========================================
CREATE TABLE IF NOT EXISTS app_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB
);
CREATE INDEX IF NOT EXISTS idx_app_logs_recorded_at ON app_logs (recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs (level);
CREATE TABLE IF NOT EXISTS app_log_archives (
id UUID PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB,
archived_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_log_archives_recorded_at ON app_log_archives (recorded_at DESC);
CREATE TABLE IF NOT EXISTS app_log_aggregates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
bucket_start TIMESTAMPTZ NOT NULL,
period VARCHAR NOT NULL,
level VARCHAR NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_start, period, level)
);
CREATE INDEX IF NOT EXISTS idx_app_log_aggregates_bucket ON app_log_aggregates (bucket_start DESC, period);
+4 -3
View File
@@ -5,9 +5,10 @@
</div> </div>
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class": {% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class": "secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
"secondary"}, {"url": "/dashboard/backtesting", "label": "Backtesting", "secondary"}, {"url": "/dashboard/config/pairings", "label": "Pairings",
"class": "secondary"}, {"url": "/dashboard/health", "label": "Health", "class": "secondary"}, {"url": "/dashboard/backtesting", "label":
"class": "secondary"}, ] %} "Backtesting", "class": "secondary"}, {"url": "/dashboard/health", "label":
"Health", "class": "secondary"}, ] %}
<div class="toolbar"> <div class="toolbar">
{% for link in nav_links %} {% for link in nav_links %}
<a <a
@@ -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>
+44 -3
View File
@@ -1,8 +1,10 @@
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %} {% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
{% block header %} {% with page_title="Arbitrade Health Check", {% block header %} {% with page_title="Arbitrade Health Check",
page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %} page_subtitle="Live system state and logs." %} {% include "_header.html" %} {%
{% endblock %} {% block main_class %}shell{% endblock %} {% block content %} endwith %} {% endblock %} {% block main_class %}shell{% endblock %} {% block
<section class="card"> content %}
<section class="card" style="margin-bottom: 24px">
<h1>Arbitrade Bootstrap Complete</h1> <h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p> <p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p> <p>UTC: {{ time }}</p>
@@ -18,4 +20,43 @@ page_subtitle="Live system state." %} {% include "_header.html" %} {% endwith %}
</p> </p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre> <pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section> </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 %} {% endblock %} {% block scripts %}{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Currency Pairings",
page_subtitle="Enable/disable pairings, search, and sync from Kraken." %}
{% include "_header.html" %} {% endwith %} {% endblock %} {% block content %}
<div class="toolbar" style="margin-bottom: 16px; display: flex; gap: 8px">
<input
id="pairing-search"
type="text"
placeholder="Search pairings…"
value="{{ search or '' }}"
style="flex: 1; max-width: 300px"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="keyup changed delay:300ms"
hx-include="#pairing-enabled-filter"
name="search"
/>
<select
id="pairing-enabled-filter"
name="enabled"
hx-get="/dashboard/fragment/pairings"
hx-target="#pairings-table-container"
hx-trigger="change"
hx-include="#pairing-search"
>
<option value="all">All</option>
<option value="true" {{ 'selected' if enabled == 'true' else '' }}>
Enabled
</option>
<option value="false" {{ 'selected' if enabled == 'false' else '' }}>
Disabled
</option>
</select>
<button
class="button"
hx-post="/dashboard/api/pairings/sync"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
>
Sync from Kraken
</button>
</div>
<div id="pairings-table-container">
{% include "partials/pairings_table.html" %}
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% for p in pairings %}
<label
style="
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
"
>
<input
type="checkbox"
name="symbols"
value="{{ p.base_asset }}/{{ p.quote_asset }}"
{%
if
p.enabled
%}checked{%
endif
%}
/>
{{ p.base_asset }}/{{ p.quote_asset }}
</label>
{% endfor %} {% if not pairings %}
<span style="opacity: 0.5"
>No pairings available. Sync from Kraken in config page.</span
>
{% endif %}
@@ -45,6 +45,46 @@
<article class="card" style="margin-top: 16px"> <article class="card" style="margin-top: 16px">
<div class="label">Run Backtest</div> <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 <form
class="form-grid" class="form-grid"
hx-post="{{ run_endpoint }}" hx-post="{{ run_endpoint }}"
@@ -52,35 +92,16 @@
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<input type="hidden" name="source" value="db" /> <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"> <label class="field">
<span>Symbols (comma-separated, blank=all)</span> <span>Starting balances <span style="color: #ff4d4f">*</span></span>
<input
name="symbols"
type="text"
value="{{ symbols | default('') }}"
placeholder="BTC/USD,ETH/BTC"
/>
</label>
<label class="field">
<span>Start time (ISO datetime, optional)</span>
<input
name="start_time"
type="text"
value="{{ start_time | default('') }}"
placeholder="2025-01-01T00:00:00"
/>
</label>
<label class="field">
<span>End time (ISO datetime, optional)</span>
<input
name="end_time"
type="text"
value="{{ end_time | default('') }}"
placeholder="2025-01-02T00:00:00"
/>
</label>
<label class="field">
<span>Starting balances</span>
<input <input
name="starting_balances" name="starting_balances"
type="text" type="text"
@@ -89,17 +110,25 @@
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Trade capital</span> <span>Start time <span style="color: #ff4d4f">*</span></span>
<input <input
name="trade_capital" name="start_time"
type="number" type="text"
min="0" value="{{ start_time | default('') }}"
step="0.01" placeholder="2025-01-01T00:00:00"
value="{{ trade_capital }}"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Min profit threshold</span> <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 <input
name="min_profit_threshold" name="min_profit_threshold"
type="number" type="number"
@@ -108,51 +137,67 @@
value="{{ min_profit_threshold }}" value="{{ min_profit_threshold }}"
/> />
</label> </label>
<label class="field">
<span>Fee profile</span> <!-- Advanced -->
<select name="fee_profile"> <details style="grid-column: 1 / -1; margin-top: 8px">
{% set sel = "selected" if fee_profile == "api" else "" %} <summary style="cursor: pointer; opacity: 0.7; font-size: 0.85rem">
<option value="api" {{ sel }}>api (from Kraken)</option> Advanced options (fee profile, slippage, latency)
{% set sel = "selected" if fee_profile == "standard" else "" %} </summary>
<option value="standard" {{ sel }}>standard</option> <div
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %} class="form-grid"
<option value="maker_heavy" {{ sel }}>maker_heavy</option> style="
{% set sel = "selected" if fee_profile == "taker_heavy" else "" %} margin-top: 12px;
<option value="taker_heavy" {{ sel }}>taker_heavy</option> grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
{% set sel = "selected" if fee_profile == "custom" else "" %} "
<option value="custom" {{ sel }}>custom</option> >
</select> <label class="field">
</label> <span>Fee profile</span>
<label class="field"> <select name="fee_profile">
<span>Custom fee rate (if fee profile = custom)</span> {% set sel = "selected" if fee_profile == "api" else "" %}
<input <option value="api" {{ sel }}>api (from Kraken)</option>
name="custom_fee_rate" {% set sel = "selected" if fee_profile == "standard" else "" %}
type="number" <option value="standard" {{ sel }}>standard</option>
min="0" {% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
step="0.0001" <option value="maker_heavy" {{ sel }}>maker_heavy</option>
value="{{ custom_fee_rate }}" {% set sel = "selected" if fee_profile == "taker_heavy" else "" %}
/> <option value="taker_heavy" {{ sel }}>taker_heavy</option>
</label> {% set sel = "selected" if fee_profile == "custom" else "" %}
<label class="field"> <option value="custom" {{ sel }}>custom</option>
<span>Slippage (bps)</span> </select>
<input </label>
name="slippage_bps" <label class="field">
type="number" <span>Custom fee rate (if custom profile)</span>
min="0" <input
step="0.1" name="custom_fee_rate"
value="{{ slippage_bps }}" type="number"
/> min="0"
</label> step="0.0001"
<label class="field"> value="{{ custom_fee_rate }}"
<span>Execution latency (ms)</span> />
<input </label>
name="execution_latency_ms" <label class="field">
type="number" <span>Slippage (bps)</span>
min="0" <input
step="0.1" name="slippage_bps"
value="{{ execution_latency_ms }}" type="number"
/> min="0"
</label> 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> <button type="submit" class="button">Submit Job</button>
</form> </form>
</article> </article>
@@ -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>
+23 -600
View File
@@ -1,4 +1,23 @@
<div id="config-panel" class="panel" style="margin-top: 16px"> <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 <form
class="form-grid" class="form-grid"
hx-post="{{ config_endpoint }}" hx-post="{{ config_endpoint }}"
@@ -9,606 +28,10 @@
gap: 20px; gap: 20px;
" "
> >
<!-- Runtime --> {% include "config/runtime.html" %} {% include "config/alerts.html" %} {%
<div class="card"> include "config/kraken.html" %} {% include "config/risk.html" %}
<div class="label">Runtime</div> <div style="grid-column: 1 / -1">
<label class="field"> <button type="submit" class="button">Save Settings</button>
<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>
<!-- Alerts -->
<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="{{ email_smtp_password }}"
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>
<!-- Kraken -->
<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 }}"
placeholder="API key"
/>
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value="{{ kraken_api_secret }}"
placeholder="API secret"
/>
</label>
<label class="field">
<span>Key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
placeholder="query,trade"
/>
</label>
<label class="field">
<span>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>Max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="0"
step="0.5"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
<!-- Risk -->
<div class="card">
<div class="label">Risk Limits</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 }}"
placeholder="None"
/>
</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 }}"
placeholder="None"
/>
</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 }}"
placeholder="None"
/>
</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 }}"
placeholder="None"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="1"
step="1"
value="{{ max_consecutive_failures_value }}"
placeholder="None"
/>
</label>
<label class="field checkbox">
<input
name="kill_switch_active"
type="checkbox"
{{
kill_switch_active
}}
/>
<span>Kill switch active</span>
</label>
</div>
<!-- Strategy Stat-Arb -->
<div class="card">
<div class="label">Stat-Arb Strategy</div>
<label class="field checkbox">
<input
name="strategy_enable_stat_arb_experiment"
type="checkbox"
{%
if
strategy_stat_arb_enabled
%}checked{%
endif
%}
/>
<span>Enable stat-arb experiment</span>
</label>
{% if strategy_stat_arb_enabled %}
<label class="field">
<span>Lookback window</span>
<input
name="strategy_stat_arb_lookback_window"
type="number"
min="2"
step="1"
value="{{ strategy_stat_arb_lookback_window }}"
/>
</label>
<label class="field">
<span>Entry z-score</span>
<input
name="strategy_stat_arb_entry_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_entry_zscore }}"
/>
</label>
<label class="field">
<span>Exit z-score</span>
<input
name="strategy_stat_arb_exit_zscore"
type="number"
min="0"
step="0.1"
value="{{ strategy_stat_arb_exit_zscore }}"
/>
</label>
<label class="field">
<span>Max holding seconds</span>
<input
name="strategy_stat_arb_max_holding_seconds"
type="number"
min="1"
step="1"
value="{{ strategy_stat_arb_max_holding_seconds }}"
/>
</label>
{% endif %}
</div>
<!-- Submit -->
<div
class="card"
style="display: flex; align-items: center; justify-content: center"
>
<button
type="submit"
class="button"
style="padding: 14px 32px; font-size: 1.1rem"
>
Save configuration
</button>
</div> </div>
</form> </form>
</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>
@@ -0,0 +1,50 @@
<table
class="pairings-table"
style="width: 100%; border-collapse: collapse; font-size: 0.85rem"
>
<thead>
<tr
style="
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: left;
"
>
<th style="padding: 6px 8px">Base</th>
<th style="padding: 6px 8px">Quote</th>
<th style="padding: 6px 8px">Source</th>
<th style="padding: 6px 8px; text-align: center">Enabled</th>
</tr>
</thead>
<tbody>
{% for p in pairings %}
<tr style="border-bottom: 1px solid rgba(255, 255, 255, 0.05)">
<td style="padding: 6px 8px">{{ p.base_asset }}</td>
<td style="padding: 6px 8px">{{ p.quote_asset }}</td>
<td style="padding: 6px 8px; opacity: 0.6">{{ p.source }}</td>
<td style="padding: 6px 8px; text-align: center">
<input
type="checkbox"
hx-post="/dashboard/api/pairings/toggle"
hx-vals='{"base_asset": "{{ p.base_asset }}", "quote_asset": "{{ p.quote_asset }}"}'
hx-trigger="change"
hx-target="#pairings-table-container"
hx-swap="innerHTML"
hx-include="#pairing-search"
{%
if
p.enabled
%}checked{%
endif
%}
/>
</td>
</tr>
{% endfor %} {% if not pairings %}
<tr>
<td colspan="4" style="padding: 20px; text-align: center; opacity: 0.5">
No pairings found. Click "Sync from Kraken" to fetch available pairs.
</td>
</tr>
{% endif %}
</tbody>
</table>
+1
View File
@@ -0,0 +1 @@
"""End-to-end tests — require full app startup with PostgreSQL."""
+1
View File
@@ -0,0 +1 @@
"""Integration tests for PostgreSQL schema and connectivity."""
+27
View File
@@ -0,0 +1,27 @@
"""pytest configuration for integration tests.
Integration tests require a live PostgreSQL server at the configured host.
They are skipped automatically if the server is unreachable.
"""
from __future__ import annotations
import pathlib
import pytest
def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool:
"""Skip integration tests unless --integration is passed."""
if "integration" in str(collection_path) and not config.getoption("--integration", False):
return True
return False
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--integration",
action="store_true",
default=False,
help="Run integration tests (requires PostgreSQL)",
)
@@ -0,0 +1,51 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_audit_repository_inserts_and_lists_recent() -> None:
async with _pg() as store:
repository = AuditRepository(store)
await repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = await repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
@@ -0,0 +1,103 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
pytestmark = pytest.mark.integration
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl() -> None:
async with _pg() as store:
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
async with store.pool.acquire() as conn:
trades = await conn.fetch(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
)
orders = await conn.fetch(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
)
pnls = await conn.fetch("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events")
assert len(trades) == 1
assert trades[0]["status"] == "filled"
assert trades[0]["estimated_pnl"] == 0.03
assert trades[0]["capital_used"] == 1.0
assert trades[0]["cycle"] == "USD->BTC->ETH->USD"
assert trades[0]["leg_count"] == 3
assert len(orders) == 3
assert orders[0]["leg_index"] == 0
assert orders[1]["leg_index"] == 1
assert orders[2]["leg_index"] == 2
assert orders[0]["status"] == "submitted"
assert len(pnls) == 1
assert pnls[0]["kind"] == "estimated"
assert pnls[0]["pnl_usd"] == 0.03
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_metrics_calculator_summarizes_execution_data() -> None:
async with _pg() as store:
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
async with store.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO trades (
trade_ref, started_at, finished_at, status,
realized_pnl, estimated_pnl, capital_used, cycle, leg_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9),
($10, $11, $12, $13, $14, $15, $16, $17, $18)
""",
"trade-1",
started,
finished,
"filled",
12.5,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
"trade-2",
started_two,
finished_two,
"filled",
-4.5,
-2.0,
200.0,
"USD->ETH->BTC->USD",
3,
)
await conn.execute(
"""
INSERT INTO opportunities (detected_at, cycle, gross_pct, net_pct, est_profit, executed)
VALUES ($1, $2, $3, $4, $5, $6),
($7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18)
""",
started,
"USD->BTC->ETH->USD",
4.0,
3.0,
0.03,
True,
started_two,
"USD->ETH->BTC->USD",
2.0,
1.0,
0.01,
False,
started_two + timedelta(seconds=30),
"USD->BTC->ETH->USD",
5.0,
4.0,
0.04,
True,
)
await conn.execute(
"""
INSERT INTO orders (
trade_ref, order_ref, leg_index, pair, side, volume,
user_ref, status, filled_volume, avg_price, raw_response, recorded_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
""",
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
101,
"closed",
2.0,
100.0,
"{}",
started,
"trade-2",
"order-2",
0,
"ETH/USD",
"sell",
4.0,
202,
"closed",
3.0,
200.0,
"{}",
started_two,
)
metrics = await MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
@@ -0,0 +1,61 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import OpportunityRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events() -> None:
async with _pg() as store:
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
)
assert len(rows) == 1
assert rows[0]["cycle"] == "USD->BTC->ETH->USD"
assert rows[0]["gross_pct"] == 4.0
assert rows[0]["net_pct"] == 3.0
assert rows[0]["est_profit"] == 0.03
assert rows[0]["executed"] is False
+424
View File
@@ -0,0 +1,424 @@
"""Integration tests: verify PostgreSQL schema and connection.
These tests connect to the PostgreSQL server at 192.168.88.35 and
validate that all expected tables, columns, and constraints exist.
They are skipped if the server is unreachable.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import pytest
import pytest_asyncio
from arbitrade.config.settings import get_settings
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
# ── expected schema ──────────────────────────────────────────────────────────
EXPECTED_TABLES: dict[str, list[str]] = {
"schema_migrations": ["version", "applied_at"],
"config_sections": ["id", "name", "description", "updated_at"],
"config_settings": [
"key",
"section",
"value_json",
"value_type",
"is_secret",
"is_runtime_reloadable",
"updated_at",
"updated_by",
],
"config_pairings": [
"id",
"base_asset",
"quote_asset",
"enabled",
"source",
"created_at",
"updated_at",
],
"config_backtesting_defaults": [
"id",
"starting_balances",
"trade_capital",
"min_profit_threshold",
"slippage_bps",
"execution_latency_ms",
"fee_source",
],
"opportunities": [
"id",
"detected_at",
"cycle",
"gross_pct",
"net_pct",
"est_profit",
"executed",
],
"trades": [
"id",
"trade_ref",
"started_at",
"finished_at",
"status",
"realized_pnl",
"estimated_pnl",
"capital_used",
"cycle",
"leg_count",
],
"orders": [
"id",
"trade_ref",
"order_ref",
"leg_index",
"pair",
"side",
"volume",
"user_ref",
"status",
"filled_volume",
"avg_price",
"raw_response",
"recorded_at",
],
"pnl_events": [
"id",
"trade_ref",
"recorded_at",
"kind",
"pnl_usd",
"source",
],
"portfolio_snapshots": ["snapshot_at", "balances", "total_value_usd"],
"market_snapshots": ["snapshot_at", "symbol", "source", "payload", "latency_ms"],
"audit_events": [
"id",
"occurred_at",
"actor",
"event_type",
"decision",
"payload",
"correlation_id",
],
"runtime_state_snapshots": [
"snapshot_at",
"is_running",
"kill_switch_active",
"kill_switch_reason",
"open_trade_count",
"last_known_balances",
"note",
],
"kraken_account_snapshots": [
"snapshot_at",
"fee_tier",
"maker_fee",
"taker_fee",
"thirty_day_volume",
"trade_balance_raw",
"fee_schedule_raw",
],
"backtest_jobs": [
"id",
"status",
"events_path",
"config",
"report",
"error",
"created_at",
"started_at",
"finished_at",
],
}
# Tables that should have a primary key
TABLES_WITH_PRIMARY_KEY: dict[str, str | list[str]] = {
"schema_migrations": "version",
"config_sections": "id",
"config_settings": "key",
"config_pairings": "id",
"config_backtesting_defaults": "id",
"opportunities": "id",
"trades": "id",
"orders": "id",
"pnl_events": "id",
"audit_events": "id",
"backtest_jobs": "id",
}
# Tables with a UNIQUE constraint beyond the primary key
TABLES_WITH_UNIQUE_CONSTRAINTS: dict[str, list[str]] = {
"config_sections": ["name"],
"config_pairings": ["base_asset, quote_asset"],
}
# ── fixtures ────────────────────────────────────────────────────────────────
@asynccontextmanager
async def _pg_lifecycle() -> AsyncIterator[PgStore]:
"""Connect, yield store, then disconnect."""
settings = get_settings()
store = PgStore(settings)
try:
await store.start()
yield store
finally:
await store.stop()
@pytest_asyncio.fixture(name="pg")
async def pg_fixture() -> AsyncIterator[PgStore]:
async with _pg_lifecycle() as store:
yield store
# ── helpers ─────────────────────────────────────────────────────────────────
async def _get_actual_tables(store: PgStore) -> dict[str, list[str]]:
"""Return {table_name: [column_name, ...]} for the public schema."""
actual: dict[str, list[str]] = {}
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT table_name, column_name FROM information_schema.columns "
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position"
)
for row in rows:
tbl: str = row["table_name"]
col: str = row["column_name"]
actual.setdefault(tbl, []).append(col)
return actual
async def _table_row_count(store: PgStore, table: str) -> int:
async with store.pool.acquire() as conn:
row = await conn.fetchrow(f"SELECT COUNT(*) AS cnt FROM {table}")
return int(row["cnt"]) if row else 0
# ── tests ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pg_connect(pg: PgStore) -> None:
"""Can connect to PostgreSQL and ping the server."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT 1 AS val")
assert val == 1
@pytest.mark.asyncio
async def test_pgcrypto_extension(pg: PgStore) -> None:
"""The pgcrypto extension is available (gen_random_uuid)."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT gen_random_uuid()")
assert val is not None
# The result should be a UUID object
assert len(str(val)) == 36 # UUID string length
@pytest.mark.asyncio
async def test_schema_migration_applies(pg: PgStore) -> None:
"""Migrate creates all expected tables."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual, (
f"Table '{table}' missing after migration. " f"Found tables: {sorted(actual)}"
)
@pytest.mark.asyncio
async def test_migration_is_idempotent(pg: PgStore) -> None:
"""Running migrate twice does not raise."""
await pg.migrate()
await pg.migrate() # second call should be a no-op
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual
@pytest.mark.asyncio
async def test_table_columns(pg: PgStore) -> None:
"""Every expected table has the correct columns."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table, expected_cols in EXPECTED_TABLES.items():
actual_cols = actual.get(table, [])
for col in expected_cols:
assert col in actual_cols, (
f"Column '{col}' missing from table '{table}'. " f"Actual columns: {actual_cols}"
)
@pytest.mark.asyncio
async def test_primary_keys(pg: PgStore) -> None:
"""Tables that should have primary keys do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_pk in TABLES_WITH_PRIMARY_KEY.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'PRIMARY KEY' "
"ORDER BY kcu.ordinal_position",
table,
)
pk_columns = [r["column_name"] for r in rows]
expected_list = [expected_pk] if isinstance(expected_pk, str) else expected_pk
for col in expected_list:
assert col in pk_columns, (
f"Table '{table}' should have PK column '{col}'. "
f"Actual PK columns: {pk_columns}"
)
@pytest.mark.asyncio
async def test_unique_constraints(pg: PgStore) -> None:
"""Tables that should have UNIQUE constraints do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_ucs in TABLES_WITH_UNIQUE_CONSTRAINTS.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'UNIQUE'",
table,
)
uc_columns = {r["column_name"] for r in rows}
for expected_cols in expected_ucs:
cols = [c.strip() for c in expected_cols.split(",")]
for col in cols:
assert col in uc_columns, (
f"Table '{table}' should have UNIQUE column '{col}'. "
f"Actual UNIQUE columns: {uc_columns}"
)
@pytest.mark.asyncio
async def test_table_row_count_is_zero(pg: PgStore) -> None:
"""All tables start empty after migration."""
await pg.migrate()
for table in EXPECTED_TABLES:
count = await _table_row_count(pg, table)
assert count == 0, (
f"Table '{table}' should be empty after migration, " f"but has {count} rows"
)
@pytest.mark.asyncio
async def test_schema_migration_version_recorded(pg: PgStore) -> None:
"""schema_migrations has the expected version after migrate."""
from arbitrade.storage.pg_store import SCHEMA_VERSION
await pg.migrate()
async with pg.pool.acquire() as conn:
row = await conn.fetchrow("SELECT MAX(version) AS v FROM schema_migrations")
assert row is not None
assert row["v"] == SCHEMA_VERSION, (
f"Expected schema version {SCHEMA_VERSION}, " f"got {row['v']}"
)
@pytest.mark.asyncio
async def test_create_and_query_row(pg: PgStore) -> None:
"""Can INSERT a row and SELECT it back (round-trip for a simple table)."""
await pg.migrate()
async with pg.pool.acquire() as conn:
# ConfigSections round-trip
await conn.execute(
"INSERT INTO config_sections (name, description) VALUES ($1, $2)",
"test_section",
"A test section for integration test",
)
row = await conn.fetchrow(
"SELECT name, description FROM config_sections WHERE name = $1",
"test_section",
)
assert row is not None
assert row["name"] == "test_section"
assert row["description"] == "A test section for integration test"
# Clean up
await conn.execute(
"DELETE FROM config_sections WHERE name = $1",
"test_section",
)
@pytest.mark.asyncio
async def test_config_pairings_upsert(pg: PgStore) -> None:
"""ON CONFLICT ... DO UPDATE works on config_pairings (unique constraint)."""
await pg.migrate()
from arbitrade.config.service import ConfigPairing
from arbitrade.storage.repositories import ConfigPairingRepository
repo = ConfigPairingRepository(pg)
# Insert
p1 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=True, source="kraken")
)
assert p1.id is not None
assert p1.base_asset == "XBT"
assert p1.enabled is True
# Upsert (update)
p2 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=False, source="manual")
)
assert p2.id == p1.id # same row
assert p2.enabled is False
assert p2.source == "manual"
# Clean up
deleted = await repo.delete_pairing("XBT", "USD")
assert deleted is True
@pytest.mark.asyncio
async def test_audit_list_recent(pg: PgStore) -> None:
"""AuditRepository.list_recent returns records in desc order."""
await pg.migrate()
from datetime import UTC, datetime
from arbitrade.storage.repositories import AuditRecord, AuditRepository
repo = AuditRepository(pg)
now = datetime.now(UTC)
# Insert a few records
for i in range(3):
await repo.insert(
AuditRecord(
occurred_at=now,
actor="test",
event_type="integration_test",
decision=f"decision_{i}",
payload={"index": i},
correlation_id=f"corr_{i}",
)
)
recent = await repo.list_recent(limit=5)
assert len(recent) >= 3
assert recent[0].decision in ("decision_2", "decision_1", "decision_0")
# Verify payload serialization worked
first = recent[0]
if first.payload:
assert "index" in first.payload
-34
View File
@@ -1,34 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from arbitrade.config.settings import Settings
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
def test_audit_repository_inserts_and_lists_recent(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "audit.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = AuditRepository(store)
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
+10 -7
View File
@@ -1,13 +1,16 @@
"""End-to-end test for configuration management system.""" """End-to-end test for configuration management system."""
from unittest.mock import MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from arbitrade.config.service import ConfigurationService from arbitrade.config.service import ConfigurationService
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.storage.repositories import AuditRepository from arbitrade.storage.repositories import AuditRepository
def test_end_to_end_config_workflow(): @pytest.mark.asyncio
async def test_end_to_end_config_workflow():
"""Test complete configuration workflow.""" """Test complete configuration workflow."""
# Create mocks # Create mocks
settings = Mock(spec=Settings) settings = Mock(spec=Settings)
@@ -36,13 +39,13 @@ def test_end_to_end_config_workflow():
# Mock the setting creation # Mock the setting creation
mock_created_setting = Mock() mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00" mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting.return_value = mock_created_setting mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting.return_value = None mock_repo_instance.get_setting = AsyncMock(return_value=None)
mock_repo_instance.get_latest_updated_at.return_value = None mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
mock_repo_instance.list_settings.return_value = [] mock_repo_instance.list_settings = AsyncMock(return_value=[])
# Set a setting # Set a setting
service.set_setting("test_key", "test_value", "test_user") await service.set_setting("test_key", "test_value", "test_user")
# Verify setting was retrieved # Verify setting was retrieved
result = service.get_setting("test_key", "default") result = service.get_setting("test_key", "default")
+121 -210
View File
@@ -1,6 +1,6 @@
"""Unit tests for configuration repositories.""" """Unit tests for configuration repositories."""
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@@ -8,7 +8,6 @@ from arbitrade.config.service import (
ConfigPairing, ConfigPairing,
ConfigSetting, ConfigSetting,
) )
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import ( from arbitrade.storage.repositories import (
ConfigBacktestingDefaultsRepository, ConfigBacktestingDefaultsRepository,
ConfigPairingRepository, ConfigPairingRepository,
@@ -18,202 +17,148 @@ from arbitrade.storage.repositories import (
@pytest.fixture @pytest.fixture
def mock_store(): def mock_store():
"""Create a mock database store.""" """Create a mock database store with async pool."""
store = Mock(spec=DuckDBStore) 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 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): def test_config_setting_repository_initialization(mock_store):
"""Test ConfigSettingRepository initialization.""" """Test ConfigSettingRepository initialization."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
assert repo._store == mock_store assert repo._store == mock_store
def test_config_setting_repository_create_setting(mock_store): @pytest.mark.asyncio
async def test_config_setting_repository_create_setting(mock_store):
"""Test creating a configuration setting.""" """Test creating a configuration setting."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
# Mock database connection setting = ConfigSetting(
with patch.object(mock_store, "connect") as mock_connect: key="test_key",
mock_cursor = Mock() section="test_section",
mock_cursor.execute.return_value = mock_cursor value_json="test_value",
mock_connect.return_value.__enter__.return_value = mock_cursor value_type="str",
is_secret=False,
is_runtime_reloadable=False,
updated_by="test_user",
)
# Mock the return value result = await repo.create_setting(setting)
mock_cursor.fetchone.return_value = [
"test_key",
"test_section",
"test_value",
"str",
False,
False,
"2023-01-01T00:00:00",
"test_user",
]
# Create setting assert result.key == "test_key"
setting = ConfigSetting( assert result.section == "test_section"
key="test_key", assert result.value_json == "test_value"
section="test_section", assert result.value_type == "str"
value_json="test_value", assert result.updated_by == "test_user"
value_type="str",
is_secret=False,
is_runtime_reloadable=False,
updated_by="test_user",
)
result = repo.create_setting(setting)
# Verify database call
mock_cursor.execute.assert_called_once()
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"
def test_config_setting_repository_get_setting(mock_store): @pytest.mark.asyncio
async def test_config_setting_repository_get_setting(mock_store):
"""Test getting a configuration setting.""" """Test getting a configuration setting."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
# Mock database connection result = await repo.get_setting("test_key")
with patch.object(mock_store, "connect") as mock_connect:
mock_cursor = Mock()
mock_cursor.execute.return_value = mock_cursor
mock_connect.return_value.__enter__.return_value = mock_cursor
# Mock the return value assert result is not None
mock_cursor.fetchone.return_value = [ assert result.key == "test_key"
"test_key", assert result.section == "test_section"
"test_section", assert result.value_json == "test_value"
"test_value", assert result.value_type == "str"
"str", assert result.updated_by == "test_user"
False,
False,
"2023-01-01T00:00:00",
"test_user",
]
# Get setting
result = repo.get_setting("test_key")
# Verify database call
mock_cursor.execute.assert_called_once()
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"
def test_config_setting_repository_update_setting(mock_store): @pytest.mark.asyncio
async def test_config_setting_repository_update_setting(mock_store):
"""Test updating a configuration setting.""" """Test updating a configuration setting."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(SETTING_ROW))
# Mock database connection setting = ConfigSetting(
with patch.object(mock_store, "connect") as mock_connect: key="test_key",
mock_cursor = Mock() section="test_section",
mock_cursor.execute.return_value = mock_cursor value_json="updated_value",
mock_connect.return_value.__enter__.return_value = mock_cursor value_type="str",
is_secret=False,
is_runtime_reloadable=False,
updated_by="test_user",
)
# Mock the return value result = await repo.update_setting("test_key", setting)
mock_cursor.fetchone.return_value = [
"test_key",
"test_section",
"updated_value",
"str",
False,
False,
"2023-01-01T00:00:00",
"test_user",
]
# Update setting assert result.key == "test_key"
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 = repo.update_setting("test_key", setting)
# Verify database call
mock_cursor.execute.assert_called_once()
assert result.key == "test_key"
assert result.section == "test_section"
assert result.value_json == "updated_value"
assert result.value_type == "str"
assert result.updated_by == "test_user"
def test_config_setting_repository_list_settings(mock_store): @pytest.mark.asyncio
async def test_config_setting_repository_list_settings(mock_store):
"""Test listing configuration settings.""" """Test listing configuration settings."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
# Mock database connection row1 = _make_row({**SETTING_ROW, "key": "test_key1", "value_json": "test_value1"})
with patch.object(mock_store, "connect") as mock_connect: row2 = _make_row({**SETTING_ROW, "key": "test_key2", "value_json": "test_value2"})
mock_cursor = Mock() conn.fetch = AsyncMock(return_value=[row1, row2])
mock_cursor.execute.return_value = mock_cursor
mock_connect.return_value.__enter__.return_value = mock_cursor
# Mock the return value result = await repo.list_settings()
mock_cursor.fetchall.return_value = [
[
"test_key1",
"test_section",
"test_value1",
"str",
False,
False,
"2023-01-01T00:00:00",
"test_user",
],
[
"test_key2",
"test_section",
"test_value2",
"str",
False,
False,
"2023-01-01T00:00:00",
"test_user",
],
]
# List settings assert len(result) == 2
result = repo.list_settings() assert result[0].key == "test_key1"
assert result[1].key == "test_key2"
# Verify database call
mock_cursor.execute.assert_called_once()
assert len(result) == 2
assert result[0].key == "test_key1"
assert result[1].key == "test_key2"
def test_config_setting_repository_get_latest_updated_at(mock_store): @pytest.mark.asyncio
async def test_config_setting_repository_get_latest_updated_at(mock_store):
"""Test getting latest updated timestamp.""" """Test getting latest updated timestamp."""
repo = ConfigSettingRepository(mock_store) repo = ConfigSettingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
# Mock database connection row = _make_row({"latest_updated_at": "2023-01-01T00:00:00"})
with patch.object(mock_store, "connect") as mock_connect: conn.fetchrow = AsyncMock(return_value=row)
mock_cursor = Mock()
mock_cursor.execute.return_value = mock_cursor
mock_connect.return_value.__enter__.return_value = mock_cursor
# Mock the return value result = await repo.get_latest_updated_at()
mock_cursor.fetchone.return_value = ["2023-01-01T00:00:00"]
# Get latest updated at assert result is not None
result = repo.get_latest_updated_at()
# Verify database call
mock_cursor.execute.assert_called_once()
assert result is not None
def test_config_pairing_repository_initialization(mock_store): def test_config_pairing_repository_initialization(mock_store):
@@ -222,70 +167,36 @@ def test_config_pairing_repository_initialization(mock_store):
assert repo._store == mock_store assert repo._store == mock_store
def test_config_pairing_repository_create_pairing(mock_store): @pytest.mark.asyncio
async def test_config_pairing_repository_create_pairing(mock_store):
"""Test creating a currency pairing.""" """Test creating a currency pairing."""
repo = ConfigPairingRepository(mock_store) repo = ConfigPairingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
# Mock database connection pairing = ConfigPairing(base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken")
with patch.object(mock_store, "connect") as mock_connect:
mock_cursor = Mock()
mock_cursor.execute.return_value = mock_cursor
mock_connect.return_value.__enter__.return_value = mock_cursor
# Mock the return value result = await repo.create_pairing(pairing)
mock_cursor.fetchone.return_value = [
1,
"BTC",
"USD",
True,
"Kraken",
"2023-01-01T00:00:00",
"2023-01-01T00:00:00",
]
# Create pairing assert result.base_asset == "BTC"
pairing = ConfigPairing(base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken") assert result.quote_asset == "USD"
assert result.enabled is True
result = repo.create_pairing(pairing) assert result.source == "Kraken"
# Verify database call
mock_cursor.execute.assert_called_once()
assert result.base_asset == "BTC"
assert result.quote_asset == "USD"
assert result.enabled is True
assert result.source == "Kraken"
def test_config_pairing_repository_get_pairing(mock_store): @pytest.mark.asyncio
async def test_config_pairing_repository_get_pairing(mock_store):
"""Test getting a currency pairing.""" """Test getting a currency pairing."""
repo = ConfigPairingRepository(mock_store) repo = ConfigPairingRepository(mock_store)
conn = await mock_store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=_make_row(PAIRING_ROW))
# Mock database connection result = await repo.get_pairing("BTC", "USD")
with patch.object(mock_store, "connect") as mock_connect:
mock_cursor = Mock()
mock_cursor.execute.return_value = mock_cursor
mock_connect.return_value.__enter__.return_value = mock_cursor
# Mock the return value assert result.base_asset == "BTC"
mock_cursor.fetchone.return_value = [ assert result.quote_asset == "USD"
1, assert result.enabled is True
"BTC", assert result.source == "Kraken"
"USD",
True,
"Kraken",
"2023-01-01T00:00:00",
"2023-01-01T00:00:00",
]
# Get pairing
result = repo.get_pairing("BTC", "USD")
# Verify database call
mock_cursor.execute.assert_called_once()
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): def test_config_backtesting_defaults_repository_initialization(mock_store):
+39 -71
View File
@@ -1,6 +1,6 @@
"""Unit tests for configuration management system.""" """Unit tests for configuration management system."""
from unittest.mock import MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
@@ -19,15 +19,9 @@ def mock_settings():
@pytest.fixture @pytest.fixture
def mock_store(): def mock_store():
"""Create a mock database store with context manager.""" """Create a mock database store (sync — repos are patched)."""
store = Mock() store = MagicMock()
cursor = Mock() store.pool = MagicMock()
cursor.fetchone.return_value = None
cursor.fetchall.return_value = []
cursor.execute.return_value = cursor
cntx = MagicMock()
cntx.__enter__.return_value = cursor
store.connect.return_value = cntx
return store return store
@@ -40,10 +34,8 @@ def mock_audit_repo():
def test_configuration_service_initialization(mock_settings, mock_store, mock_audit_repo): def test_configuration_service_initialization(mock_settings, mock_store, mock_audit_repo):
"""Test that ConfigurationService initializes correctly.""" """Test that ConfigurationService initializes correctly."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Verify attributes are set
assert service._settings == mock_settings assert service._settings == mock_settings
assert service._store == mock_store assert service._store == mock_store
assert service._audit_repo == mock_audit_repo assert service._audit_repo == mock_audit_repo
@@ -53,132 +45,108 @@ def test_configuration_service_initialization(mock_settings, mock_store, mock_au
def test_configuration_service_get_setting(mock_settings, mock_store, mock_audit_repo): def test_configuration_service_get_setting(mock_settings, mock_store, mock_audit_repo):
"""Test getting configuration settings.""" """Test getting configuration settings."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Set up mock loaded settings
service._loaded_settings = {"test_key": "test_value"} service._loaded_settings = {"test_key": "test_value"}
# Test getting existing setting assert service.get_setting("test_key", "default") == "test_value"
result = service.get_setting("test_key", "default") assert service.get_setting("non_existing", "default") == "default"
assert result == "test_value"
# Test getting non-existing setting with default
result = service.get_setting("non_existing", "default")
assert result == "default"
def test_configuration_service_set_setting(mock_settings, mock_store, mock_audit_repo): @pytest.mark.asyncio
async def test_configuration_service_set_setting(mock_settings, mock_store, mock_audit_repo):
"""Test setting configuration settings.""" """Test setting configuration settings."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Mock the repository
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class: with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock() mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance mock_repo_class.return_value = mock_repo_instance
# Mock the setting creation
mock_created_setting = Mock() mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00" mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting.return_value = mock_created_setting mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting.return_value = None # force create path mock_repo_instance.get_setting = AsyncMock(return_value=None)
# Set a setting await service.set_setting("test_key", "test_value", "test_user")
service.set_setting("test_key", "test_value", "test_user")
# Verify repository was called mock_repo_instance.create_setting.assert_awaited_once()
mock_repo_instance.create_setting.assert_called_once()
def test_configuration_service_hot_reload_detection(mock_settings, mock_store, mock_audit_repo): @pytest.mark.asyncio
async def test_configuration_service_hot_reload_detection(
mock_settings, mock_store, mock_audit_repo
):
"""Test hot-reload detection functionality.""" """Test hot-reload detection functionality."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Initially should not be outdated
assert service.is_config_outdated() is False
# Test with mock repository that returns a timestamp
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class: with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock() mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance mock_repo_class.return_value = mock_repo_instance
# Mock the latest updated at timestamp mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
assert await service.is_config_outdated() is False
from datetime import datetime from datetime import datetime
mock_repo_instance.get_latest_updated_at.return_value = datetime.now() mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
assert await service.is_config_outdated() is True
# Should detect as outdated when timestamp exists
assert service.is_config_outdated() is True
def test_configuration_service_reload_if_changed(mock_settings, mock_store, mock_audit_repo): @pytest.mark.asyncio
async def test_configuration_service_reload_if_changed(mock_settings, mock_store, mock_audit_repo):
"""Test hot-reload functionality.""" """Test hot-reload functionality."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Mock the repository
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class: with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock() mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance mock_repo_class.return_value = mock_repo_instance
# Mock the latest updated at timestamp to return None initially mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=None)
mock_repo_instance.get_latest_updated_at.return_value = None mock_repo_instance.list_settings = AsyncMock(return_value=[])
mock_repo_instance.list_settings.return_value = []
# Mock the latest updated at timestamp to return a value
from datetime import datetime from datetime import datetime
mock_repo_instance.get_latest_updated_at.return_value = datetime.now() mock_repo_instance.get_latest_updated_at = AsyncMock(return_value=datetime.now())
# Should reload when outdated result = await service.reload_if_changed()
result = service.reload_if_changed()
assert result is True assert result is True
assert service.get_config_version() == 1 assert service.get_config_version() == 1
def test_configuration_service_get_config_version(mock_settings, mock_store, mock_audit_repo): @pytest.mark.asyncio
async def test_configuration_service_get_config_version(mock_settings, mock_store, mock_audit_repo):
"""Test getting configuration version.""" """Test getting configuration version."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Should start at version 0
assert service.get_config_version() == 0 assert service.get_config_version() == 0
# After setting a value, version should increment
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class: with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock() mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance mock_repo_class.return_value = mock_repo_instance
mock_created_setting = Mock() mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00" mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting.return_value = mock_created_setting mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting.return_value = None mock_repo_instance.get_setting = AsyncMock(return_value=None)
service.set_setting("test_key", "test_value", "test_user") await service.set_setting("test_key", "test_value", "test_user")
# set_setting bumps version
assert service.get_config_version() == 1 assert service.get_config_version() == 1
def test_configuration_service_get_last_updated_at(mock_settings, mock_store, mock_audit_repo): @pytest.mark.asyncio
async def test_configuration_service_get_last_updated_at(
mock_settings, mock_store, mock_audit_repo
):
"""Test getting last updated timestamp.""" """Test getting last updated timestamp."""
# Create service instance
service = ConfigurationService(mock_settings, mock_store, mock_audit_repo) service = ConfigurationService(mock_settings, mock_store, mock_audit_repo)
# Should start with None
assert service.get_last_updated_at() is None assert service.get_last_updated_at() is None
# After setting a value, should have timestamp
with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class: with patch("arbitrade.storage.repositories.ConfigSettingRepository") as mock_repo_class:
mock_repo_instance = Mock() mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance mock_repo_class.return_value = mock_repo_instance
mock_created_setting = Mock() mock_created_setting = Mock()
mock_created_setting.updated_at = "2023-01-01T00:00:00" mock_created_setting.updated_at = "2023-01-01T00:00:00"
mock_repo_instance.create_setting.return_value = mock_created_setting mock_repo_instance.create_setting = AsyncMock(return_value=mock_created_setting)
mock_repo_instance.get_setting.return_value = None mock_repo_instance.get_setting = AsyncMock(return_value=None)
service.set_setting("test_key", "test_value", "test_user") await service.set_setting("test_key", "test_value", "test_user")
# set_setting updates _last_updated_at from mock
assert service.get_last_updated_at() is not None assert service.get_last_updated_at() is not None
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "exec.duckdb")
store = DuckDBStore(settings)
store.migrate()
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
with store.connect() as conn:
trades = conn.execute(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
).fetchall()
orders = conn.execute(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
).fetchall()
pnls = conn.execute("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
assert len(trades) == 1
assert trades[0][1] == "filled"
assert trades[0][2] == 0.03
assert trades[0][3] == 1.0
assert trades[0][4] == "USD->BTC->ETH->USD"
assert trades[0][5] == 3
assert len(orders) == 3
assert orders[0][2] == 0
assert orders[1][2] == 1
assert orders[2][2] == 2
assert orders[0][6] == "submitted"
assert len(pnls) == 1
assert pnls[0][1] == "estimated"
assert pnls[0][2] == 0.03
-144
View File
@@ -1,144 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.db import DuckDBStore
def test_metrics_calculator_summarizes_execution_data(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "metrics.duckdb")
store = DuckDBStore(settings)
store.migrate()
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
with store.connect() as conn:
conn.execute(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
started,
finished,
"filled",
12.5,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
"trade-2",
started_two,
finished_two,
"filled",
-4.5,
-2.0,
200.0,
"USD->ETH->BTC->USD",
3,
],
)
conn.execute(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
""",
[
started,
"USD->BTC->ETH->USD",
4.0,
3.0,
0.03,
True,
started_two,
"USD->ETH->BTC->USD",
2.0,
1.0,
0.01,
False,
started_two + timedelta(seconds=30),
"USD->BTC->ETH->USD",
5.0,
4.0,
0.04,
True,
],
)
conn.execute(
"""
INSERT INTO orders (
trade_ref,
order_ref,
leg_index,
pair,
side,
volume,
user_ref,
status,
filled_volume,
avg_price,
raw_response,
recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
101,
"closed",
2.0,
100.0,
"{}",
started,
"trade-2",
"order-2",
0,
"ETH/USD",
"sell",
4.0,
202,
"closed",
3.0,
200.0,
"{}",
started_two,
],
)
metrics = MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import OpportunityRepository
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "test.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
with store.connect() as conn:
rows = conn.execute(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
).fetchall()
assert len(rows) == 1
assert rows[0][0] == "USD->BTC->ETH->USD"
assert rows[0][1] == 4.0
assert rows[0][2] == 3.0
assert rows[0][3] == 0.03
assert rows[0][4] is False
+62 -54
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@@ -31,38 +32,67 @@ class _FakeStartupReconciler:
self.called = True self.called = True
@pytest.mark.asyncio def _mock_pg_store():
async def test_persist_runtime_snapshot_writes_record(tmp_path) -> None: """Create a PgStore-alike with an async pool returning an AsyncMock conn."""
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "runtime.duckdb")) store = MagicMock()
conn = AsyncMock()
conn.fetchrow = AsyncMock()
conn.fetch = AsyncMock(return_value=[])
conn.execute = AsyncMock(return_value=conn)
pool_cm = AsyncMock()
pool_cm.__aenter__.return_value = conn
store.pool = MagicMock()
store.pool.acquire.return_value = pool_cm
return store
@pytest.fixture
def app():
"""Create a test app with a mocked PgStore and audit repository."""
a = create_app(Settings(_env_file=None, APP_MODE="paper", paper_trading_mode=True))
a.state.store = _mock_pg_store()
a.state.runtime_state_repository.insert = AsyncMock()
a.state.runtime_state_repository.latest = AsyncMock(return_value=None)
# Replace audit repository with mock to avoid real PgStore access
audit_mock = AsyncMock()
audit_mock.insert = AsyncMock()
a.state.audit_repository = audit_mock
return a
@pytest.mark.asyncio
async def test_persist_runtime_snapshot_writes_record(app) -> None:
app.state.dashboard_controls.is_running = True app.state.dashboard_controls.is_running = True
app.state.dashboard_controls.kill_switch.deactivate() app.state.dashboard_controls.kill_switch.deactivate()
snapshot = persist_runtime_snapshot(app, note="unit-test") # Mock _open_trade_count → 0, _latest_balances → None
conn = await app.state.store.pool.acquire().__aenter__()
conn.fetchrow = AsyncMock(return_value=MagicMock(**{"__getitem__": lambda s, k: 0}))
snapshot = await persist_runtime_snapshot(app, note="unit-test")
assert snapshot is not None assert snapshot is not None
assert snapshot.note == "unit-test" assert snapshot.note == "unit-test"
latest = app.state.runtime_state_repository.latest() app.state.runtime_state_repository.latest = AsyncMock(return_value=snapshot)
latest = await app.state.runtime_state_repository.latest()
assert latest is not None assert latest is not None
assert latest.note == "unit-test" assert latest.note == "unit-test"
assert latest.is_running is True assert latest.is_running is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_applies_snapshot(tmp_path) -> None: async def test_restore_runtime_state_applies_snapshot(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "restore.duckdb")) seed = RuntimeStateRecord(
app.state.runtime_state_repository.insert( snapshot_at=datetime.now(UTC),
RuntimeStateRecord( is_running=False,
snapshot_at=datetime.now(UTC), kill_switch_active=True,
is_running=False, kill_switch_reason="manual-stop",
kill_switch_active=True, open_trade_count=0,
kill_switch_reason="manual-stop", last_known_balances={"USD": 100.0},
open_trade_count=0, note="seed",
last_known_balances={"USD": 100.0},
note="seed",
)
) )
app.state.runtime_state_repository.latest = AsyncMock(return_value=seed)
report = await restore_runtime_state(app) report = await restore_runtime_state(app)
@@ -73,36 +103,12 @@ async def test_restore_runtime_state_applies_snapshot(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_enables_restart_guard_for_open_trades(tmp_path) -> None: async def test_restore_runtime_state_enables_restart_guard_for_open_trades(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "open-trades.duckdb")) # Simulate 1 open trade
conn = await app.state.store.pool.acquire().__aenter__()
with app.state.store.connect() as conn: row = MagicMock()
conn.execute( row.__getitem__.return_value = 1
""" conn.fetchrow = AsyncMock(return_value=row)
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"open-trade-1",
datetime.now(UTC),
None,
"open",
None,
1.0,
100.0,
"USD->BTC->ETH->USD",
3,
],
)
report = await restore_runtime_state(app) report = await restore_runtime_state(app)
@@ -114,24 +120,26 @@ async def test_restore_runtime_state_enables_restart_guard_for_open_trades(tmp_p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_graceful_shutdown_drains_workers_and_persists_snapshot(tmp_path) -> None: async def test_graceful_shutdown_drains_workers_and_persists_snapshot(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "shutdown.duckdb"))
worker = _FakeWorker() worker = _FakeWorker()
app.state.background_workers = [worker] app.state.background_workers = [worker]
app.state.dashboard_controls.is_running = True app.state.dashboard_controls.is_running = True
# Mock _open_trade_count → 0, _latest_balances → None
conn = await app.state.store.pool.acquire().__aenter__()
row = MagicMock()
row.__getitem__.return_value = 0
conn.fetchrow = AsyncMock(return_value=row)
await graceful_shutdown(app) await graceful_shutdown(app)
assert worker.stopped is True assert worker.stopped is True
assert app.state.dashboard_controls.is_running is False assert app.state.dashboard_controls.is_running is False
latest = app.state.runtime_state_repository.latest() app.state.runtime_state_repository.insert.assert_called()
assert latest is not None
assert latest.note == "graceful_shutdown"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restore_runtime_state_calls_startup_reconciler(tmp_path) -> None: async def test_restore_runtime_state_calls_startup_reconciler(app) -> None:
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "reconciler.duckdb"))
reconciler = _FakeStartupReconciler() reconciler = _FakeStartupReconciler()
app.state.startup_reconciler = reconciler app.state.startup_reconciler = reconciler