Compare commits

...

76 Commits

Author SHA1 Message Date
zwitschi e4f5d8dfcc refactor: clean up imports and improve code formatting across multiple files
CI / lint-test-build (push) Successful in 2m21s
2026-06-09 10:02:41 +02:00
zwitschi 403daa6cf1 Refactor log aggregation periods and improve code formatting
- Removed the "3h" and "6h" periods from the log aggregation process in maintenance.py to streamline log counts.
- Enhanced code readability by adjusting line breaks and indentation in repositories.py for better clarity.
2026-06-09 09:41:23 +02:00
zwitschi dc99f1604e Refactor code for improved readability and consistency
CI / lint-test-build (push) Successful in 54s
- Cleaned up multiline statements and removed unnecessary line breaks in various files.
- Ensured consistent formatting in function definitions and calls across the codebase.
- Updated docstrings and comments for clarity where applicable.
- Removed trailing newlines in module docstrings.
- Enhanced logging statements for better clarity in maintenance tasks.
2026-06-07 21:59:09 +02:00
zwitschi f221464daa feat: enhance backtesting panel with flash messages and pairing checks
CI / lint-test-build (push) Failing after 12s
2026-06-07 21:51:09 +02:00
zwitschi 5e7732b85f feat: add flash message support to configuration panel and improve layout
CI / lint-test-build (push) Successful in 52s
2026-06-07 19:57:42 +02:00
zwitschi 77dfb08b23 feat: update package data inclusion to add dashboard templates and reorder partials
CI / lint-test-build (push) Successful in 1m6s
2026-06-07 19:35:05 +02:00
zwitschi 9acabddb7e feat: include config HTML templates in package data
CI / lint-test-build (push) Successful in 2m23s
2026-06-07 18:46:12 +02:00
zwitschi 2fbc78f7a9 feat: add logging package with DB sink and maintenance tasks
CI / lint-test-build (push) Successful in 2m24s
2026-06-07 18:31:27 +02:00
zwitschi f58634d438 feat: add storage schema SQL file to package data inclusion
CI / lint-test-build (push) Successful in 2m31s
2026-06-07 18:29:16 +02:00
zwitschi e44876c7c7 refactor: clean up imports and improve code formatting in various modules
CI / lint-test-build (push) Successful in 49s
2026-06-07 18:17:45 +02:00
zwitschi 1e4086a0fd feat: add logging routes and update health page to display system logs
CI / lint-test-build (push) Failing after 12s
2026-06-07 18:10:50 +02:00
zwitschi cf5ff2e2d8 feat: implement logging system with aggregation and archiving tasks 2026-06-07 18:06:35 +02:00
zwitschi db2e02c316 feat: add pairings management page and integrate with Kraken API for syncing
feat: create configuration templates for alerts, Kraken settings, risk limits, and runtime settings
refactor: streamline config form by including separate template files for better organization
2026-06-07 17:44:26 +02:00
zwitschi c1dda187af refactor: migrate database schema from TIMESTAMP to TIMESTAMPTZ for better timezone handling
CI / lint-test-build (push) Successful in 1m25s
2026-06-07 15:20:27 +02:00
zwitschi af0ac94a12 refactor: update tests to use async mocks and improve readability
CI / lint-test-build (push) Failing after 12s
2026-06-07 15:05:42 +02:00
zwitschi ef22e217c7 feat: update environment configuration and improve repository handling
CI / lint-test-build (push) Failing after 11s
- Added PG_PASSWORD to .env.example for database connection.
- Removed unnecessary imports and streamlined code in various modules.
- Enhanced error handling in ConfigSettingRepository and ConfigPairingRepository.
- Updated test files to remove unused imports and improve clarity.
2026-06-07 14:50:55 +02:00
zwitschi 529ff967cc Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s
- Implemented integration tests for the execution writer to ensure trade orders and PnL are persisted correctly.
- Created integration tests for the metrics calculator to summarize execution data accurately.
- Added integration tests for the opportunity writer to verify event persistence.
- Established PostgreSQL schema validation tests to ensure all expected tables, columns, and constraints exist.
- Removed outdated unit tests that relied on DuckDB and replaced them with tests using PgStore.
2026-06-07 14:37:53 +02:00
zwitschi 54feb2ecd4 fix: remove unnecessary duckdb file from .gitignore and add the database file
CI / lint-test-build (push) Successful in 2m36s
2026-06-05 10:17:16 +02:00
zwitschi df2f4f3246 fix: update type hint for _build_section_from_row to support tuple with Any
CI / lint-test-build (push) Successful in 1m6s
2026-06-04 22:30:38 +02:00
zwitschi 8cfd969dae fix: update ws_client type hint to allow None for better clarity 2026-06-04 22:29:24 +02:00
zwitschi 3f4b9a4012 refactor: streamline WebSocket connection logging and improve data handling in repositories 2026-06-04 22:28:57 +02:00
zwitschi 4c59a0e4cb feat: Implement pairing synchronization from Kraken and enhance market data feed
- Added `sync_pairings_from_kraken` function to fetch and upsert asset pairs into the config_pairings table.
- Introduced `run_pairing_sync_loop` for periodic synchronization of pairings.
- Enhanced `KrakenWsClient` to manage subscribed symbols for market data feeds.
- Created `build_detector_from_enabled_pairings` to initialize cycle detection based on enabled pairings.
- Updated FastAPI app to start market data feed and pairing synchronization tasks.
- Added new API routes for managing pairings, including listing, toggling, and syncing from Kraken.
- Improved dashboard templates to display pairing options and allow user interaction for backtesting.
- Refactored database queries to streamline fetching and updating of pairing data.
2026-06-04 22:10:06 +02:00
zwitschi 92b0b49535 refactor: remove unnecessary COPY command for web directory in Dockerfile
CI / lint-test-build (push) Successful in 2m37s
2026-06-04 20:56:03 +02:00
zwitschi 44da9220d6 refactor: improve code formatting and readability in test files
CI / lint-test-build (push) Failing after 1m13s
2026-06-04 20:42:58 +02:00
zwitschi df59f5ad7c feat: enhance mock database store with context manager support and improve cursor behavior
CI / lint-test-build (push) Failing after 52s
2026-06-04 20:28:06 +02:00
zwitschi 170f59eb89 refactor: improve SQL query formatting and enhance readability across multiple files
CI / lint-test-build (push) Failing after 56s
2026-06-04 20:16:58 +02:00
zwitschi 8ceca2a7e4 feat: improve SQL query formatting and add type hints for better clarity
CI / lint-test-build (push) Failing after 53s
2026-06-04 19:53:32 +02:00
zwitschi c8e3daeb57 Refactor code for improved readability and consistency
CI / lint-test-build (push) Failing after 12s
- Consolidated multiline string formatting into single-line for SQL queries in multiple files.
- Adjusted argument formatting in function calls for better alignment and readability.
- Removed unnecessary line breaks and improved spacing in various sections of the codebase.
- Updated test cases to maintain consistency in formatting and improve clarity.
2026-06-04 19:04:30 +02:00
zwitschi 7d18bdf316 feat: add 'dist' directory to .dockerignore and .gitignore for build artifact exclusion 2026-06-04 19:04:18 +02:00
zwitschi 83f2064fa9 feat: remove obsolete wheel distribution file 2026-06-04 19:03:40 +02:00
zwitschi 7728f9a8cd feat: enhance backtesting functionality with database integration and UI updates
CI / lint-test-build (push) Failing after 1m20s
2026-06-04 18:39:17 +02:00
zwitschi a83d231d06 feat: refactor health endpoint handling and improve job ID display in backtesting 2026-06-04 17:59:41 +02:00
zwitschi 1c2558cfb3 feat: refactor fee management by removing deprecated pair fee handling and updating dashboard to display equity 2026-06-04 17:48:41 +02:00
zwitschi a0366f06ff feat: add health check endpoint and refactor templates for consistent header usage 2026-06-04 17:13:40 +02:00
zwitschi 86d1046862 feat: update documentation structure and add deployment guide 2026-06-04 15:55:07 +02:00
zwitschi 6acd6bbbc9 feat: implement backtesting job management with database integration and UI updates 2026-06-03 21:34:19 +02:00
zwitschi ff71fc5feb feat: enhance fee management with API integration and audit trail support 2026-06-03 19:27:32 +02:00
zwitschi 587c9afc3b feat: implement fee synchronization and dashboard updates for Kraken account fees 2026-06-03 18:59:39 +02:00
zwitschi 5f2f968721 feat: update .gitignore and .dockerignore to include build artifacts 2026-06-03 18:48:11 +02:00
zwitschi 87dd655f08 feat: add pair fees configuration panel and related functionality
- Introduced a new HTML template for configuring pair fees, allowing users to add, edit, and delete fees for trading pairs.
- Implemented a responsive fee table displaying existing fees with options for editing and deleting.
- Added a form for adding new fees, including fields for base asset, quote asset, market type, and fee rates.
- Removed outdated templates related to backtesting, dashboard, health, and various partials to streamline the codebase.
- Ensured the new fee configuration panel integrates seamlessly with existing endpoints and uses htmx for dynamic updates.
2026-06-03 18:43:36 +02:00
zwitschi ccca9ef62a feat: add dashboard configuration management endpoints and services for pairing and fee management 2026-06-03 18:30:31 +02:00
zwitschi 57df3a4361 remove build dir 2026-06-03 18:30:08 +02:00
zwitschi 00bd2d664d feat: implement comprehensive configuration management system with web interface and database support
CI / lint-test-build (push) Failing after 1m18s
2026-06-02 17:23:58 +02:00
zwitschi 815284289e feat: add configuration management UI for fees, pairings, and application settings 2026-06-02 16:07:30 +02:00
zwitschi 107595826a refactor: update ConfigurationService to avoid circular imports and streamline repository usage 2026-06-02 15:33:14 +02:00
zwitschi 6b5973a0bb feat: implement configuration management with database support 2026-06-02 15:11:56 +02:00
zwitschi 1b21f2443a fix: remove package template smoke check from CI workflow
CI / lint-test-build (push) Successful in 1m4s
2026-06-02 14:30:43 +02:00
zwitschi 1df4b11aef Add HTML templates for dashboard, metrics, overview, and backtesting
CI / lint-test-build (push) Failing after 1m7s
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities.
- Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity.
- Updated the Jinja2 template resolution logic to support different deployment environments.
- Added a health check template to display the service status.
- Included a test suite to verify the template resolution logic.
- Updated `pyproject.toml` to include new HTML templates in the package data.
2026-06-02 14:16:42 +02:00
zwitschi 38e1d64437 feat: add backtesting functionality with UI and API endpoints
CI / lint-test-build (push) Successful in 2m31s
- Introduced backtesting page and fragment in the dashboard for running backtests and viewing recent reports.
- Implemented backtest run logic with configuration options including event path, starting balances, trade capital, and fee profiles.
- Added recent backtest reports storage and retrieval.
- Created a new strategy module for statistical arbitrage experiments with validation on configuration parameters.
- Updated settings to include parameters for the statistical arbitrage strategy.
- Enhanced dashboard controls to support the new strategy mode.
- Added unit tests for backtesting functionality and strategy validation.
- Updated templates for backtesting UI integration.
2026-06-02 09:28:22 +02:00
zwitschi f612c8533a feat: Add backtesting parameter sweep support and related functionality 2026-06-02 08:44:10 +02:00
zwitschi 8ef8dc801d fix: Update CI workflow to publish Docker image and document Coolify deployment process
CI / lint-test-build (push) Successful in 2m36s
2026-06-01 17:48:13 +02:00
zwitschi df55953d31 fix: Update application port from 8000 to 9090 in configuration and deployment files
CI / lint-test-build (push) Failing after 43s
2026-06-01 17:23:59 +02:00
zwitschi 5051f2de83 fix: Update Dockerfile and docker-compose.yml for dependency installation and image reference
CI / lint-test-build (push) Successful in 2m35s
2026-06-01 17:13:30 +02:00
zwitschi 2845721797 refactor: Simplify strategy mode selection logic in controls template
CI / lint-test-build (push) Successful in 54s
2026-06-01 16:56:43 +02:00
zwitschi 9e2f08d40a fix: Update pip-audit command to specify requirements file for dependency audit
CI / lint-test-build (push) Failing after 43s
2026-06-01 16:50:02 +02:00
zwitschi 778b41910a chore: Update project dependencies and restructure requirements files
CI / lint-test-build (push) Failing after 29s
2026-06-01 16:30:55 +02:00
zwitschi 065dcbda20 style: Comment out Black code formatting step in CI workflow
CI / lint-test-build (push) Failing after 24s
2026-06-01 15:46:22 +02:00
zwitschi c3c524724e fix: Correctly assign alerts_last_channel_results in dashboard controls
CI / lint-test-build (push) Failing after 13s
2026-06-01 15:45:21 +02:00
zwitschi 6c7dd0a715 refactor: Simplify variable names and improve readability in metrics and lifecycle modules
CI / lint-test-build (push) Failing after 11s
2026-06-01 15:44:17 +02:00
zwitschi 0d1f6961d6 style: Clean up code formatting for improved readability
CI / lint-test-build (push) Failing after 14s
2026-06-01 15:18:01 +02:00
zwitschi d742577484 feat: Add dashboard controls for tradable pairs and strategy configuration 2026-06-01 15:13:05 +02:00
zwitschi 7c86e838fa feat: Add deterministic replay backtesting engine and related documentation
CI / lint-test-build (push) Failing after 14s
2026-06-01 14:57:42 +02:00
zwitschi cc11082ea7 feat: Implement latency profiling and guardrails for performance monitoring
CI / lint-test-build (push) Failing after 19s
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
- Introduced latency baseline and threshold artifacts for CI enforcement.
- Enhanced CI workflow with latency guardrail checks.
- Updated documentation to include latency profiling commands and performance metrics.
- Added unit tests for latency guardrail evaluation.
2026-06-01 14:47:52 +02:00
zwitschi c17f41aaf8 feat: add audit events and runtime state snapshots to database
- Introduced new tables for audit events and runtime state snapshots in the database schema.
- Created data classes for AuditRecord and RuntimeStateRecord to represent the new entities.
- Implemented AuditRepository and RuntimeStateRepository for inserting and retrieving records.
- Enhanced the dashboard to include an audit trail section, displaying recent audit events.
- Added tests for the new audit repository and runtime lifecycle functionalities.
- Updated settings validation to ensure proper configuration for alerting features.
- Integrated alert notifications across various components, including execution sequencer and loss limits.
2026-06-01 14:18:12 +02:00
zwitschi b413c66ca4 feat: Refactor dashboard template to extend base layout and improve structure 2026-06-01 12:44:36 +02:00
zwitschi bbc806bcef refactor: Consolidate dashboard response handling into a single function 2026-06-01 12:39:51 +02:00
zwitschi 24f2b2ed88 feat: Add dashboard charts with interactivity and basic authentication support 2026-06-01 12:28:02 +02:00
zwitschi cde181f343 feat: Enhance dashboard with live overview panel and control features 2026-06-01 12:20:28 +02:00
zwitschi 0c232b7aee feat: Add performance metrics dashboard and metrics calculator 2026-06-01 12:06:04 +02:00
zwitschi 93f4f62d42 feat: Implement idempotency and recovery mechanisms for order execution
- Add IdempotencyKeyFactory for generating unique user references based on execution legs.
- Introduce OrderReconciler to reconcile order statuses with historical data.
- Implement PartialFillRecovery to handle partial fills by canceling orders and placing hedges.
- Create TriangularExecutionSequencer for executing triangular arbitrage strategies.
- Enhance storage with new tables for trades, orders, and PnL events.
- Develop AsyncExecutionWriter for asynchronous writing of execution records to the database.
- Add unit tests for execution persistence, sequencer behavior, fill monitoring, and idempotency checks.
- Update KrakenRestClient to ensure proper payloads for order placement and querying.
2026-06-01 11:59:13 +02:00
zwitschi 240a591a64 Add risk management features: implement KillSwitch and StopConditionsGuard; update settings and tests 2026-06-01 11:22:17 +02:00
zwitschi 45e219d103 Add risk management features: implement loss limits, trade limits, and pre-trade validation; update settings and tests 2026-06-01 11:16:37 +02:00
zwitschi 9d8a8a8a45 Add benchmark detection script and related tests; refactor settings and update project scripts 2026-06-01 11:01:53 +02:00
zwitschi a89886186f Add opportunity detection and storage functionality with async processing
- Introduced OpportunityEvent class for structured opportunity data.
- Enhanced IncrementalCycleDetector to generate opportunities based on updated pairs.
- Implemented AsyncOpportunityWriter for persisting opportunities to the database.
- Updated MarketDataFeed to handle opportunity detection and execution in both paper and live trading modes.
- Added unit tests for opportunity detection and persistence.
2026-06-01 10:59:09 +02:00
zwitschi 652b20274a Add IncrementalCycleDetector and related classes for cycle scoring
- Implement IncrementalCycleDetector for scoring based on updated market data.
- Introduce CycleScore class to encapsulate cycle scoring details.
- Update CurrencyGraph and MarketDataFeed to integrate cycle detection.
- Add unit tests for IncrementalCycleDetector functionality.
2026-06-01 10:36:35 +02:00
zwitschi 7d3071463e Implement Kraken integration with REST and WebSocket clients, add market data handling, and enhance settings configuration 2026-06-01 10:30:58 +02:00
151 changed files with 17721 additions and 245 deletions
+2
View File
@@ -5,7 +5,9 @@ __pycache__
.pytest_cache
.mypy_cache
.ruff_cache
build
data
dist
logs
*.pyc
.env
+20
View File
@@ -0,0 +1,20 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
max_line_length = 120
+52
View File
@@ -1,9 +1,61 @@
APP_ENV=dev
APP_HOST=0.0.0.0
APP_PORT=8000
PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
LOG_LEVEL=INFO
LOG_JSON=true
ALERTS_ENABLED=true
ALERT_MIN_SEVERITY=warning
ALERT_DEDUP_SECONDS=30
ALERT_ON_TRADE_EVENTS=true
ALERT_ON_ERROR_EVENTS=true
ALERT_ON_THRESHOLD_EVENTS=true
ALERT_ON_SYSTEM_EVENTS=true
TELEGRAM_ALERTS_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
DISCORD_ALERTS_ENABLED=false
DISCORD_WEBHOOK_URL=
EMAIL_ALERTS_ENABLED=false
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_ALERT_FROM=
EMAIL_ALERT_TO=
EMAIL_SMTP_USE_TLS=true
DUCKDB_PATH=./data/arbitrade.duckdb
FERNET_KEY=
KRAKEN_API_KEY=
KRAKEN_API_SECRET=
KRAKEN_API_KEY_PERMISSIONS=query,trade
KRAKEN_REST_URL=https://api.kraken.com
KRAKEN_WS_URL=wss://ws.kraken.com/v2
KRAKEN_PRIVATE_RATE_LIMIT_SECONDS=1.0
KRAKEN_HTTP_TIMEOUT_SECONDS=10.0
KRAKEN_RETRY_ATTEMPTS=3
KRAKEN_RETRY_BASE_DELAY_SECONDS=0.25
WS_HEARTBEAT_TIMEOUT_SECONDS=20.0
WS_MAX_STALENESS_SECONDS=5.0
PAPER_TRADING_MODE=true
TRADE_CAPITAL_USD=100.0
MAX_TRADE_CAPITAL_USD=100.0
MAX_CONCURRENT_TRADES=
MAX_EXPOSURE_PER_ASSET_USD=
QUOTE_BALANCE_ASSET=USD
MIN_ORDER_SIZE_USD=
KILL_SWITCH_ACTIVE=false
DAILY_LOSS_LIMIT_USD=5.0
CUMULATIVE_LOSS_LIMIT_USD=10.0
MAX_SOURCE_LATENCY_MS=
MAX_APPLY_LATENCY_MS=
MAX_CONSECUTIVE_FAILURES=
STRATEGY_ENABLE_STAT_ARB_EXPERIMENT=false
STRATEGY_STAT_ARB_LOOKBACK_WINDOW=120
STRATEGY_STAT_ARB_ENTRY_ZSCORE=2.0
STRATEGY_STAT_ARB_EXIT_ZSCORE=0.5
STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS=900.0
+17 -3
View File
@@ -23,19 +23,33 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install pip-audit
- name: Ruff
run: ruff check .
- name: Black
run: black --check .
# - name: Black
# run: black --check .
- name: MyPy
run: mypy src
- name: Dependency audit
run: pip-audit -r requirements/latest-runtime.in
- name: Secret scan (worktree + git history)
run: python scripts/security_scan.py
- name: Tests
run: pytest -q
- name: Latency guardrails
run: |
python scripts/check_latency_regression.py \
--baseline ops/performance/latency_baseline.json \
--thresholds ops/performance/latency_thresholds.json \
--iterations 600
- name: Login to Gitea registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
@@ -50,4 +64,4 @@ jobs:
with:
context: .
push: true
tags: git.allucanget.biz/${{ secrets.REGISTRY_NAMESPACE }}/arbitrade:${{ github.sha }}
tags: git.allucanget.biz/allucanget/arbitrade:latest
+6 -1
View File
@@ -31,11 +31,16 @@ Thumbs.db
!.env.example
secrets/
# Local build artifacts
build/
dist/
# Local database / runtime data
data/*.duckdb
data/*.duckdb.wal
data/*.duckdb.tmp
data/arbitrade.duckdb
logs/
ops/performance/latest_profile.json
# Node assets if used for frontend tooling
node_modules/
+72
View File
@@ -0,0 +1,72 @@
# Changelog
## [Unreleased] - 2026-06-01
### Added
- Added stop-condition risk controls for abnormal source/apply latency and repeated execution failures.
- Added a new stop-conditions guard and integration in market feed processing.
- Added multi-channel alerting infrastructure with Telegram, Discord webhook, and SMTP channel clients.
- Added alert configuration settings for severity threshold, category routing, and dedup cooldown.
- Added dashboard alert status surfacing with configured channels and last-send delivery outcome.
- Added append-only `audit_events` schema plus repository support for insert/query of recent audit records.
- Added dashboard audit fragment and protected API endpoint for recent audit entries.
- Added runtime lifecycle manager with startup recovery and graceful shutdown orchestration.
- Added `runtime_state_snapshots` persistence for control flags, open trade count, and last known balances.
- Added CI security gates for dependency auditing (`pip-audit --strict`) and a repository/worktree secret scan script.
- Added strict settings validators for auth pairing, Kraken credential pairing, alert severity bounds, and key-scope policy.
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
- Added backtesting parameter sweep support (`scripts/backtest_sweep.py`) for theta, trade-capital, pair-universe, and staleness-threshold grid search.
- Added persisted sweep artifacts with ranked in-sample/out-of-sample results and promotion-ready candidate reporting.
- Added out-of-sample overfit guards via train/test time-window split and generalization-gap checks.
- Added dashboard controls for tradable pair universe selection and strategy mode/parameter configuration.
- Added feature-flagged statistical arbitrage experiment scaffold (`src/arbitrade/strategy/stat_arb.py`) with mean-reversion signal lifecycle.
- Added strategy feature-flag settings for statistical arbitrage experiment activation and z-score/holding-window controls.
- Added unit coverage for statistical arbitrage experiment behavior and new strategy settings validation rules.
- Added dedicated backtesting dashboard page (`/dashboard/backtesting`) with run controls for replay path, fee profile, balances, slippage, and execution latency.
- Added backtesting run/report endpoints (`/dashboard/backtesting/run`, `/dashboard/api/backtesting/reports`) and recent-report history surfaced in UI.
### Changed
- Live execution path now auto-activates the kill switch when configured stop conditions are breached.
- Added configuration env keys for stop-condition thresholds.
- WebSocket client now emits system alerts for disconnect/reconnect and heartbeat staleness timeout events.
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
- Added backtesting usage and replay format documentation to README.
- Dashboard controls now surface tradable pairs and strategy config snapshot values.
- CI now publishes `git.allucanget.biz/allucanget/arbitrade:latest`, and README now documents Coolify image deployment with runtime environment variables managed in Coolify.
- Dashboard strategy-mode validation now allows `stat_arb_experiment` only when feature flag is enabled.
- README now documents deferred cross-exchange architecture requirements, risk assumptions, and promotion milestones for strategy expansion.
### Removed
- None.
### Fixed
- Added/expanded unit coverage for risk limits and kill-switch enforcement, including stop-condition scenarios.
- Added partial-fill recovery logic that cancels open orders when possible and hedges residual exposure on timeout or failure.
- Added deterministic order idempotency via Kraken userref plus reconciliation helpers for Kraken order history responses.
- Added execution journaling for trades, orders, and estimated P&L, plus a DuckDB startup fallback when the default file path is unavailable.
- Added a mocked execution integration test that drives the triangular sequencer through the execution journal and DuckDB persistence.
- Added a DuckDB-backed performance metrics calculator for realized P&L, win rate, trade duration, opportunities/min, fill rate, and latency percentiles.
- Added dashboard page plus HTMX metrics fragment and SSE metrics stream.
- Added dashboard live overview panel for status, balances, open trades, realized P&L, and opportunity feed with HTMX refresh and SSE updates.
- Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms.
- Added Alpine.js interactivity and a Chart.js opportunity trend panel to the dashboard.
- Added optional HTTP Basic authentication for dashboard routes, fragments, streams, and control endpoints.
- Added alert wiring for dashboard control actions, execution success/failure, and threshold breaches in risk guards.
- Added unit/integration tests covering alert notifier behavior and alert emission paths.
- Added critical system alert emission when live opportunity executor raises an unhandled exception.
- Added WebSocket and market-feed tests for system-event alerting paths.
- Added notifier status snapshot tracking and protected alert-status API endpoint for operational visibility.
- Added audit event writes for dashboard controls and detector/risk/execution decision points.
- Added tests for audit repository and dashboard audit route coverage.
- Added startup restart safety guard that halts execution when open trades are detected after restart.
- Added lifecycle tests for snapshot persistence, worker draining, recovery restore, and startup reconciliation hook.
- Added unit coverage for security-related settings validation paths.
- Added latency guardrail unit coverage and documented measured metrics aggregation speedup (`1.14x`).
- Added deterministic replay tests that exercise the backtesting path without depending on live services.
+5 -3
View File
@@ -7,12 +7,14 @@ WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip
COPY requirements /app/requirements
RUN pip install --no-cache-dir -r requirements/latest-runtime.in
COPY pyproject.toml README.md /app/
COPY src /app/src
COPY web /app/web
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir --no-deps .
EXPOSE 8000
EXPOSE 9090
CMD ["python", "-m", "arbitrade.main"]
+197 -66
View File
@@ -6,12 +6,12 @@ Current stack:
- Python 3.12+
- FastAPI + HTMX/Jinja2
- DuckDB for dev/test/prod
- PostgreSQL for all environments (via asyncpg)
- Native Kraken WebSocket planned for market-data hot path
- Gitea Actions + Gitea container registry
Project plan lives in [PLAN.md](PLAN.md).
Task checklist lives in [.github/instructions/TODO.md](.github/instructions/TODO.md).
Coolify deployment runbooks live in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
## Current Status
@@ -21,7 +21,7 @@ Bootstrap complete for foundation layer:
- typed settings and env loading
- structured logging
- encrypted secret helpers
- DuckDB connection + base schema
- PostgreSQL connection + full schema migration
- FastAPI app with health endpoint
- Gitea Actions CI scaffold
- Docker / docker-compose scaffold
@@ -34,6 +34,50 @@ Not implemented yet:
- trade execution
- dashboard beyond health/bootstrap page
## Configuration Management
The arbitrage trading bot now includes a complete configuration management system that allows users to configure trading behavior, currency pairings, fees, and other application settings through a web interface. All user-configurable settings are persisted in the database while system variables remain in environment variables as per the settings split plan.
Key features include:
- Web-based configuration interface at `/dashboard/config/`
- Runtime hot-reloading of configuration changes
- Complete CRUD operations for all configuration entities
- Input validation and error handling
- Audit logging for all configuration changes
- Backtesting parameter configuration
- Fee configuration by pairing and market type
## Templates
Full page templates (`src/arbitrade/web/templates/`):
| Template | Route | Purpose |
| ------------------ | ------------------------ | ------------------------------------------------------- |
| `base.html` | — (root layout) | Dark theme, `.shell` container, HTMX, CSS variables |
| `dashboard.html` | `/`, `/dashboard` | Main dashboard: metrics, overview, controls, charts |
| `config.html` | `/dashboard/config` | Full configuration: fees, runtime, alerts, Kraken, risk |
| `audit.html` | `/dashboard/audit` | Audit trail with auto-refresh via HTMX |
| `backtesting.html` | `/dashboard/backtesting` | Backtesting panel with replay/sweep forms |
| `health.html` | `/health` | System health check |
Dashboard partials (`src/arbitrade/web/templates/partials/`):
| Partial | In page | Content |
| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------- |
| `metrics.html` | Dashboard | 6 KPI cards: P&L, win rate, avg duration, trade count, success %, profit factor |
| `overview.html` | Dashboard | Status, balances, fee tier, open trades list, opportunity feed |
| `controls.html` | Dashboard | Runtime status, kill switch, config snapshot, alerting status, execution controls (Start/Stop/Kill) |
| `charts.html` | Dashboard | Opportunity trend chart (Chart.js, Alpine toggle) |
| `config.html` | Config page | Config form: Runtime, Alerts, Kraken, Risk, Strategy sections |
| `config_fees.html` | Config page | Pair fee table + add/edit form |
| `backtesting_panel.html` | Backtesting page | Run status, replay/sweep forms, recent runs |
| `audit.html` | Audit page | Audit trail table: time, actor, event, decision, payload |
Legacy templates (`src/arbitrade/web/templates/dashboard/`):
- `config_settings.html`, `config_pairs.html`, `config_fees.html` — superseded by config page; retained for reference
## Prerequisites
- Python 3.12+
@@ -87,6 +131,12 @@ Install app + dev dependencies:
uv pip install -e .[dev]
```
Dependency source of truth:
- Runtime dependencies live in `requirements/latest-runtime.in`.
- Dev dependencies live in `requirements/latest-dev.in`.
- `pyproject.toml` reads both files dynamically during package install.
Create local env file:
```powershell
@@ -98,18 +148,25 @@ Minimum `.env` values:
```env
APP_ENV=dev
APP_HOST=0.0.0.0
APP_PORT=8000
APP_PORT=9090
LOG_LEVEL=INFO
LOG_JSON=true
DUCKDB_PATH=./data/arbitrade.duckdb
PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
FERNET_KEY=
KRAKEN_API_KEY=
KRAKEN_API_SECRET=
KRAKEN_API_KEY_PERMISSIONS=query,trade
```
Notes:
- Leave Kraken creds empty until Kraken integration lands.
- If Kraken creds are set, both key and secret are required.
- `KRAKEN_API_KEY_PERMISSIONS` must include `query,trade` and must not include withdrawal scope.
- `FERNET_KEY` optional. If empty, keyring-backed key generation used by secret helper.
- On Windows, app falls back to default `asyncio` loop. On non-Windows, `uvloop` installs automatically.
@@ -123,20 +180,24 @@ python -m arbitrade.main
Health endpoints:
- HTML: `http://localhost:8000/`
- JSON: `http://localhost:8000/health`
- HTML: `http://localhost:9090/`
- JSON: `http://localhost:9090/health`
## Database
DuckDB used everywhere: local dev, tests, production.
PostgreSQL used everywhere: local dev, tests, production.
Default database file:
Default connection:
```text
./data/arbitrade.duckdb
PG_HOST=192.168.88.35
PG_PORT=5432
PG_DATABASE=arbitrade
PG_USER=arbitrade
PG_PASSWORD=arbitrade
```
Schema bootstrap runs automatically on app startup.
Schema bootstrap runs automatically on app startup via `PgStore.migrate()`.
Current tables:
@@ -145,6 +206,30 @@ Current tables:
- `trades`
- `portfolio_snapshots`
Audit trail table:
- `audit_events` (append-only operational decision log)
Audit retention and compaction guidance:
- Keep at least 30 days of `audit_events` in active DB for incident triage.
- Archive older rows to a timestamped export file before deletion.
- Example monthly archive workflow:
```sql
COPY (
SELECT *
FROM audit_events
WHERE occurred_at < NOW() - INTERVAL 30 DAY
) TO 'data/audit_events_archive_YYYYMM.parquet' (FORMAT PARQUET);
DELETE FROM audit_events
WHERE occurred_at < NOW() - INTERVAL 30 DAY;
```
- Back up archive files and the PostgreSQL database together.
- For production, run archive + backup as scheduled maintenance (cron/task scheduler).
## Quality Checks
Run tests:
@@ -171,6 +256,30 @@ Run mypy:
mypy src
```
Run dependency vulnerability audit:
```powershell
pip-audit -r requirements/latest-runtime.in
```
Run secret scan (worktree + git history):
```powershell
python scripts/security_scan.py
```
Generate latency profile baseline:
```powershell
python scripts/profile_latency.py --iterations 600 --output ops/performance/latency_baseline.json
```
Run latency regression guardrails:
```powershell
python scripts/check_latency_regression.py --baseline ops/performance/latency_baseline.json --thresholds ops/performance/latency_thresholds.json --iterations 600
```
Install pre-commit hooks:
```powershell
@@ -191,6 +300,11 @@ Build locally:
docker build -t arbitrade:local .
```
Container dependency install flow:
- Docker installs runtime dependencies from `requirements/latest-runtime.in`.
- Docker then installs the package with `--no-deps` so dependency resolution is driven by requirements files.
Run with compose:
```powershell
@@ -201,8 +315,72 @@ Compose mounts local `data/` folder into container at `/app/data`.
Important:
- [docker-compose.yml](docker-compose.yml) still uses registry image placeholder format.
- Replace `git.allucanget.biz/OWNER/arbitrade:latest` with actual namespace image, likely `git.allucanget.biz/allucanget/arbitrade:latest`.
- [docker-compose.yml](docker-compose.yml) uses `git.allucanget.biz/allucanget/arbitrade:latest` as the default image reference.
## Coolify Deployment (Prebuilt Image)
Use this when deploying from the image published by CI instead of building from Git inside Coolify.
### 1) Create application in Coolify
- In Coolify, create a new `Application` using `Docker Image` / `Public Image` / `Private Registry Image`.
- Image: `git.allucanget.biz/allucanget/arbitrade:latest`
- Registry: `git.allucanget.biz`
- If registry auth is required, configure the same registry credentials in Coolify.
### 2) Configure build and start behavior
Set these in Coolify application settings:
- Build Command: leave empty.
- Install Command: leave empty.
- Start Command: leave empty unless you explicitly want to override the image default.
- Port: `9090` (coolify uses `8000` internally)
### 3) Configure health check and networking
- Health Check Path: `/health`
- Exposed Port: `9090`
- Use Coolify-generated domain or attach your own domain.
### 4) Configure persistent storage
Add a persistent volume in Coolify:
- Mount Path: `/app/data`
This preserves PostgreSQL data and other runtime artifacts across restarts/redeploys.
### 5) Configure environment variables
Add runtime environment variables in Coolify (UI: Environment Variables):
- `APP_ENV=prod`
- `APP_HOST=0.0.0.0`
- `APP_PORT=9090`
- `PG_HOST=postgres`
`PG_PORT=5432`
`PG_DATABASE=arbitrade`
`PG_USER=arbitrade`
`PG_PASSWORD=arbitrade`
- `LOG_LEVEL=INFO`
- `LOG_JSON=true`
- `KRAKEN_API_KEY=...`
- `KRAKEN_API_SECRET=...`
- `KRAKEN_API_KEY_PERMISSIONS=query,trade`
Recommended:
- Configure `FERNET_KEY` in Coolify secrets (do not commit it).
- Keep all exchange keys/secrets in Coolify secret variables only.
Coolify should own runtime configuration through environment variables. CI only publishes the image.
### 6) Deploy and verify
- Trigger deploy in Coolify after CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
- Verify app boot logs show startup completed.
- Verify `GET /health` returns success on deployed URL.
## Gitea CI / Registry Setup
@@ -214,13 +392,6 @@ Required Gitea Actions secrets:
- `REGISTRY_USERNAME`
- `REGISTRY_TOKEN`
- `REGISTRY_NAMESPACE`
Expected namespace now likely:
```text
allucanget
```
Example registry login:
@@ -231,54 +402,14 @@ docker login git.allucanget.biz
Example pushed image tag shape:
```text
git.allucanget.biz/allucanget/arbitrade:<tag>
git.allucanget.biz/allucanget/arbitrade:latest
```
## Project Layout
## Architecture Docs
```text
arbitrade/
├── .gitea/workflows/ci.yml
├── .github/instructions/TODO.md
├── PLAN.md
├── pyproject.toml
├── src/arbitrade/
│ ├── api/
│ ├── config/
│ ├── storage/
│ ├── logging_setup.py
│ └── main.py
├── tests/
└── web/templates/
```
Implementation detail moved into docs:
## Next Work
- [architecture overview](docs/architecture/README.md) - system context, building blocks, runtime, deployment, quality goals, risks.
- [current implementation snapshot](docs/architecture/current-implementation.md) - codebase state, active routes, backtesting, strategy flags, deployment flow.
Next planned implementation slice:
- Kraken REST client skeleton
- native Kraken WebSocket client
- in-memory order book cache
- latency instrumentation
## Troubleshooting
PowerShell blocks activation script:
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
```
Then activate again:
```powershell
.\.venv\Scripts\Activate.ps1
```
If app import fails, confirm editable install ran:
```powershell
uv pip install -e .[dev]
```
If DuckDB file missing, start app once or create `data/` directory manually.
For navigation from README, use the docs above instead of this file for deep architecture detail.
+2 -2
View File
@@ -1,13 +1,13 @@
services:
arbitrade:
image: git.allucanget.biz/OWNER/arbitrade:latest
image: git.allucanget.biz/allucanget/arbitrade:latest
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
ports:
- "8000:8000"
- "9090:9090"
volumes:
- ./data:/app/data
restart: unless-stopped
+142
View File
@@ -0,0 +1,142 @@
# Deployment Guide (Coolify)
This guide provides two supported deployment paths for Arbitrade on Coolify:
- Build directly from Git repository in Coolify.
- Deploy prebuilt container image: `git.allucanget.biz/allucanget/arbitrade:latest`.
Reference docs:
- [Coolify Applications](https://coolify.io/docs/applications)
- [Coolify Build Packs](https://coolify.io/docs/applications/build-packs)
- [Coolify Dockerfile Build Pack](https://coolify.io/docs/applications/build-packs/dockerfile)
- [Coolify Nixpacks Build Pack](https://coolify.io/docs/applications/build-packs/nixpacks)
- [Coolify CI/CD (Git providers)](https://coolify.io/docs/applications/ci-cd)
- [Coolify Gitea integration](https://coolify.io/docs/applications/ci-cd/gitea/integration)
- [Coolify environment variables](https://coolify.io/docs/knowledge-base/environment-variables)
- [Coolify persistent storage](https://coolify.io/docs/knowledge-base/persistent-storage)
- [Coolify health checks](https://coolify.io/docs/knowledge-base/health-checks)
- [Coolify Docker registry credentials](https://coolify.io/docs/knowledge-base/docker/registry)
## Common Runtime Configuration
Use these values in both deployment modes.
### Port and health
- Container port: `9090`
- Health check path: `/health`
- Protocol: HTTP
### Persistent storage
- Add a persistent volume
- Mount path: `/app/data`
- Set PG connection: `PG_HOST=postgres`, `PG_PORT=5432`, `PG_DATABASE=arbitrade`, `PG_USER=arbitrade`, `PG_PASSWORD=arbitrade`
### Required environment variables
- `APP_ENV=prod`
- `APP_HOST=0.0.0.0`
- `APP_PORT=9090`
- `PG_DATABASE=arbitrade`
- `LOG_LEVEL=INFO`
- `LOG_JSON=true`
- `KRAKEN_API_KEY=<set-in-coolify-secret>`
- `KRAKEN_API_SECRET=<set-in-coolify-secret>`
- `KRAKEN_API_KEY_PERMISSIONS=query,trade`
- `FERNET_KEY=<set-in-coolify-secret>`
Notes:
- Store secrets in Coolify secret variables, not in Git.
- Keep Kraken key scope minimal (query + trade, no withdrawal).
## Option A: Build in Coolify from Git Repository
Recommended when you want Coolify to build from source and optionally auto-deploy on commits.
1. Open your Coolify project and select Create New Resource.
2. Choose deployment source:
- Public repo: use `Public repository` and provide HTTPS URL.
- Private Gitea repo: use deploy key flow from the Gitea guide.
3. Set repository URL for this project:
- `https://git.allucanget.biz/allucanget/arbitrade.git` (public)
- or SSH URL if private deploy key is used.
4. Choose build pack:
- Prefer `Dockerfile` for this repo, because Docker build behavior is explicitly defined.
- Use `Nixpacks` only if you intentionally want auto-detected build logic.
5. Configure branch and base directory:
- Branch: your deploy branch (for example `main`)
- Base directory: `/`
6. Configure network:
- Exposed port: `9090`
- Domain: set your Coolify domain/custom domain
7. Configure environment variables and secrets from the Common Runtime Configuration section.
8. Add persistent storage mount `/app/data`.
9. Configure health check:
- Path: `/health`
- Ensure container includes `curl` or `wget` if using UI-defined checks.
10. Click Deploy and verify:
- Deployment logs complete successfully.
- `GET /health` returns success.
Optional (Git webhook auto-deploy with Gitea):
1. In Coolify resource, open `Webhooks` and copy Manual Git Webhook URL.
2. Set webhook secret in Coolify.
3. In Gitea repo settings, add webhook URL + same secret and enable Push events.
4. Push a commit and confirm Coolify triggers deploy.
## Option B: Deploy Prebuilt Image from Container Registry
Recommended when CI publishes the image and Coolify only runs it.
Image:
- `git.allucanget.biz/allucanget/arbitrade:latest`
1. Ensure CI publishes the image before first deployment.
2. In Coolify, select Create New Resource.
3. Choose Application deployment based on Docker Image.
4. Set image reference:
- Registry: `git.allucanget.biz`
- Image: `git.allucanget.biz/allucanget/arbitrade:latest`
5. Configure registry credentials in Coolify if your registry requires auth.
6. Leave build/install/start commands empty unless you need overrides.
7. Set network and health:
- Exposed port: `9090`
- Health check path: `/health`
8. Add environment variables and secrets from the Common Runtime Configuration section.
9. Add persistent storage mount `/app/data`.
10. Deploy and verify:
- Logs show container start success.
- `GET /health` returns success.
Update flow for new releases:
- Push code and let CI publish a new `latest` image.
- Trigger redeploy in Coolify for this resource.
## Quick Troubleshooting
- `No available server` from proxy:
- Check health check path/port and app bind (`APP_HOST=0.0.0.0`, `APP_PORT=9090`).
- Verify health check is passing in Coolify.
- `TemplateNotFound: dashboard.html` at runtime:
- Ensure the deployed image/wheel includes package templates under `arbitrade/web/templates/*`.
- If you build from source, do not remove packaged template files under `src/arbitrade/web/templates`.
- DB resets after deploy:
- Confirm PostgreSQL is reachable at `PG_HOST`.
- Registry pull fails:
- Re-check Docker registry credentials in Coolify.
- App starts but unavailable externally:
- Confirm exposed port is `9090` and domain is attached to this resource.
+159
View File
@@ -0,0 +1,159 @@
# Arbitrade Architecture Overview (arc42)
## 1. Introduction and Goals
Arbitrade is a Python 3.12+ cryptocurrency arbitrage bot for Kraken, focused on triangular arbitrage on a single exchange. The system is designed for low-latency detection, configurable risk control, replayable backtesting, and operator-visible dashboard control.
Primary goals:
- Detect and execute triangular opportunities on Kraken with fee/slippage-aware math.
- Keep hot-path latency low with incremental order-book updates and event-driven scoring.
- Persist operational data in PostgreSQL for all environments.
- Provide operator controls, audit trail, and alerting through a server-rendered dashboard.
- Support backtesting, parameter sweeps, and deferred experimental strategy work behind feature flags.
## 2. Constraints
- Python 3.12+ runtime.
- Native Kraken WebSocket on the hot path.
- HTMX + Jinja2 UI, no SPA build step.
- PostgreSQL everywhere.
- Self-hosted Gitea Actions CI and Gitea registry.
- Windows development support.
- Secrets must stay out of the repository.
## 3. Context and Scope
### 3.1 Business Context
The bot consumes Kraken market data, detects opportunities, and executes trades or paper-trades depending on configuration. Operators monitor and control the system through a dashboard and alerting channels.
### 3.2 Technical Context
- Kraken REST + WebSocket provide market data and execution.
- FastAPI serves HTML fragments, JSON endpoints, and SSE streams.
- PostgreSQL stores trades, opportunities, snapshots, audit events, and runtime state.
- Coolify can deploy the published image using environment variables and persistent storage.
## 4. Solution Strategy
- Use an incremental currency graph to re-score only cycles touched by a changed pair.
- Use top-of-book plus depth-aware pricing and configurable fee/slippage buffers.
- Use a single-process asyncio model with uvloop where available.
- Keep strategy, risk, execution, and backtesting logic reusable across paper and replay modes.
- Expose configuration through the dashboard and environment variables.
## 5. Building Block View
### 5.1 Runtime Blocks
- `api/` - FastAPI app, routes, dashboard fragments, backtesting endpoints.
- `exchange/` - Kraken REST and WebSocket integration.
- `market_data/` - live order-book state and ingestion.
- `detection/` - triangular graph and incremental detector.
- `risk/` - pre-trade and trade-limit guards.
- `execution/` - multi-leg trade sequencing.
- `backtesting/` - replay engine, parameter sweep, experiment scaffolds. See [backtesting.md](backtesting.md).
- `strategy/` - experimental strategy modules such as stat-arb.
- `storage/` - PostgreSQL schema and repositories.
- `alerting/` - multi-channel notifications.
- `runtime/` - startup recovery and graceful shutdown.
### 5.2 Important Dependencies
- `fastapi`, `uvicorn`, `jinja2`, `htmx`-driven templates.
- `orjson` for low-alloc parsing.
- `sortedcontainers` for book state.
- `asyncpg` for PostgreSQL persistence.
- `pydantic` / `pydantic-settings` for typed configuration.
- `cryptography` / keyring for secret handling.
## 6. Runtime View
### 6.1 Live Trading Flow
1. Kraken WS delivers book updates.
2. Order book updates in memory.
3. Incremental detector scores impacted cycles.
4. Risk manager validates the opportunity.
5. Execution sequencer places legs if approved.
6. Trades and snapshots persist to PostgreSQL.
7. Dashboard and alerts reflect state changes.
### 6.2 Dashboard Control Flow
- `/dashboard/control/*` mutates runtime state.
- `/dashboard/fragment/*` renders HTMX partials.
- `/dashboard/stream/*` provides SSE live updates.
- `/dashboard/backtesting` provides a dedicated replay control page.
### 6.3 Backtesting Flow
See [backtesting.md](backtesting.md) for full design and implementation details.
1. User picks currency pairs (from config/pairings page, or all enabled).
2. User sets starting balances (required), time range (required), min profit threshold (required).
3. Fee profile defaults to "api (from Kraken)"; slippage (4.0 bps) and execution latency (20 ms) are optional with sensible defaults.
4. Job is queued via `POST /dashboard/backtesting/run`.
5. Backend loads events from `market_snapshots` table, builds triangular cycles, runs replay engine.
6. Report stored in `backtest_jobs` table, visible in recent jobs list.
## 7. Deployment View
### 7.1 Local Development
- `uv venv` for environment creation.
- `uv pip install -e .[dev]` for editable install.
- `docker compose up --build` for local container workflow.
### 7.2 CI/CD
- Gitea Actions runs lint, tests, security checks, latency guards, and image publish.
- CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
### 7.3 Coolify
- Deploy from the published image.
- Configure runtime via environment variables.
- Connect to PostgreSQL at configured `PG_HOST`.
## 8. Cross-Cutting Concepts
- Staleness checks prevent stale book execution.
- Kill switch halts execution immediately.
- Audit trail records dashboard and runtime decisions.
- Alerting spans Telegram, Discord, and email.
- Feature flags gate experimental strategy code.
- Config is environment-driven and validated at startup.
## 9. Architecture Decisions
- Native Kraken WS instead of a generic exchange abstraction on the hot path.
- PostgreSQL as the single database engine.
- HTMX + Jinja2 instead of SPA frontend.
- Backtesting reuses production detector/risk/execution logic.
- Experimental stat-arb stays behind a feature flag.
- Published image is the deployment artifact; Coolify owns runtime env vars.
## 10. Quality Requirements
- Low latency on book-update-to-decision path.
- Safe startup and restart behavior.
- Strong operator visibility.
- Reproducible backtests and sweeps.
- Secrets protection and strict validation.
## 11. Risks and Technical Debt
- Exchange API schema changes.
- Spread decay and execution slippage.
- Cross-venue strategy complexity if/when enabled.
- UI and backtesting paths can drift if not kept aligned with production logic.
## 12. Glossary
- WS: WebSocket.
- HTMX: HTML-over-the-wire UI library.
- SSE: Server-Sent Events.
- PGSQL: PostgreSQL database used for all environments.
- Stat arb: Statistical arbitrage, currently experimental and feature-flagged.
+130
View File
@@ -0,0 +1,130 @@
# Backtesting Architecture
> Detailed design and implementation of the backtesting subsystem.
> See [`README.md`](README.md#63-backtesting-flow) for the high-level user flow.
## Data Flow
```txt
market_snapshots (DB) ─┐
├──→ load_replay_events_from_db() ──→ list[ReplayBookEvent]
JSONL file ─────────────┘
BacktestReplayEngine.run()
BacktestReport
BacktestJobRepository.store_report()
```
Two event sources:
- **DB mode** (default) — loads snapshots from `market_snapshots` table. Supports symbol/time filtering.
- **File mode** — reads JSONL files from disk (legacy, used by `backtest_replay.py` script).
## Core Types
### `ReplayClock`
Timekeeper for simulation. Ensures events advance monotonically. Supports `advance_ms()` to model execution latency.
### `ReplayBookEvent`
One atomic book state at a point in time. Fields: `occurred_at`, `symbol`, `bids: tuple[BookLevel]`, `asks: tuple[BookLevel]`.
### `BacktestConfig`
| Field | Default | Description |
| ------------------------ | -------- | ----------------------------------------------------- |
| `fee_rate` | `0.0` | 0.0 → API-sourced fee from `kraken_account_snapshots` |
| `min_profit_threshold` | `0.0005` | Minimum net profit to attempt trade |
| `trade_capital` | `100.0` | Capital allocated per trade |
| `quote_asset` | `"USD"` | Base currency for P&L |
| `slippage_bps` | `4.0` | Simulated slippage in basis points |
| `execution_latency_ms` | `20.0` | Simulated latency per leg |
| `max_depth_levels` | `10` | Order book depth for detection |
| `max_concurrent_trades` | `1` | Max simultaneous trades |
| `min_order_size_by_pair` | `None` | Per-pair min order size overrides |
### `BacktestReport`
| Field | Type | Description |
| -------------------------------- | -------------- | ---------------------------------- |
| `started_at` / `finished_at` | datetime | Simulation window |
| `processed_events` | int | Events consumed |
| `opportunities_seen` | int | Detected opportunities |
| `trades_executed` | int | Successful trades |
| `win_rate` | float or None | Fraction of profitable trades |
| `fill_rate` | float or None | Average fill ratio |
| `realized_pnl_usd` | float | Net P&L after slippage |
| `max_drawdown_usd` | float | Peak-to-trough equity drop |
| `miss_reasons` | dict[str, int] | Counters for skipped opportunities |
| `execution_latency_p50/95/99_ms` | float or None | Latency percentiles |
## Simulation Client
`_SimulatedRestClient` replaces the real Kraken REST client during backtesting.
- **Slippage model:** `fill_ratio = max(0.85, 1.0 - (slippage_bps / 10000.0) * 8.0)`
- **Latency model:** Clock advances by `execution_latency_ms` before each simulated fill
- Orders always fill (status = `"closed"`) at the modeled ratio
## Job Worker
`backtest_worker` is an `asyncio.Task` started in `create_app()` lifespan:
```python
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db),
name="backtest_worker",
)
```
Workflow per job:
1. Dequeue `(job_id, config_dict)` from `asyncio.Queue`
2. Update status → `"running"` in `backtest_jobs` table
3. Load events (DB or file)
4. Build currency graph → triangular cycles
5. Instantiate `BacktestReplayEngine``engine.run()`
6. Store report → update status → `"completed"` (or `"failed"` on exception)
## Sweep Pipeline
`run_parameter_search` performs grid search over backtest parameters:
1. **Split** events into train/test windows by time ratio
2. **Build grid** — cartesian product of `theta_values × trade_capital_values × pair_universes × staleness_threshold_values`
3. **For each parameter set:**
- Filter events to pair universe + apply staleness gate
- Build cycles restricted to pair universe
- Run engine on train window → `train_report`
- Run engine on test window → `test_report`
- Score = `realized_pnl + win_rate_bonus + fill_rate_bonus - max_drawdown`
- Compute generalization gap = `|train_score - test_score| / max(train_score, test_score)`
4. **Evaluate promotion:**
- `PromotionCriteria` checks: min test P&L, min win rate ≥ 0.5, min fill rate ≥ 0.9, max drawdown ≤ $25, generalization gap ≤ 0.5
- Results passing all criteria are flagged `promotion_ready`
## UI
> See `backtesting.html` → `partials/backtesting_panel.html`.
- **Shell page** loads the panel via `hx-get="/dashboard/fragment/backtesting"`
- **Run form** — starting balances, time range, profit threshold (required); fee profile, slippage, latency (advanced/collapsible)
- **Status card** — current job status + message
- **Recent jobs table** — lists last 20 jobs with status, events, trades, P&L; each row has a detail button
- **Job detail** — `GET /dashboard/backtesting/job/{id}` returns report HTML
Pairings are managed on the `/dashboard/config/pairings` page. Backtest uses DB-enabled pairings by default when no symbols are specified.
## Source Files
| File | Role |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backtesting/replay.py` | `ReplayClock`, `ReplayBookEvent`, `BacktestConfig`, `BacktestReport`, `_SimulatedRestClient`, `BacktestReplayEngine`, `load_replay_events`, `load_replay_events_from_db` |
| `backtesting/runner.py` | `run_backtest_job`, `backtest_worker`, `_build_cycles_from_events`, `_parse_balances` |
| `backtesting/sweep.py` | `SweepParameters`, `SweepResult`, `SweepArtifacts`, `PromotionCriteria`, `split_events_time_windows`, `build_parameter_grid`, `run_parameter_search`, `persist_sweep_results` |
@@ -0,0 +1,61 @@
# Current Implementation Snapshot
This document summarizes the code that exists now, not the original plan.
## Runtime
- FastAPI app starts from [src/arbitrade/api/app.py](../../src/arbitrade/api/app.py).
- Settings come from `pydantic-settings` in [src/arbitrade/config/settings.py](../../src/arbitrade/config/settings.py).
- DuckDB is initialized and migrated on startup.
- Runtime recovery persists and restores control state and snapshots.
## Configuration Management
- Complete configuration management system implemented with database-backed user settings.
- Configuration service in [src/arbitrade/config/service.py](../../src/arbitrade/config/service.py) handles loading and applying settings.
- Repository classes in [src/arbitrade/storage/repositories.py](../../src/arbitrade/storage/repositories.py) provide database access.
- Web UI for configuration at `/dashboard/config/` with CRUD operations for:
- Currency pairings
- Fee configurations
- Application settings
- Backtesting parameters
- Hot-reloading capabilities for runtime configuration changes.
- Input validation and error handling for all configuration forms.
- Audit logging for all configuration modifications.
## Market Data and Detection
- Kraken market data is handled by native WS and thin REST code.
- Incremental triangular detector is implemented in [src/arbitrade/detection/engine.py](../../src/arbitrade/detection/engine.py).
- Currency graph and cycle indexing live in [src/arbitrade/detection/graph.py](../../src/arbitrade/detection/graph.py).
## Execution and Risk
- Multi-leg execution sequencer exists for triangular cycles.
- Pre-trade validation and trade-limit guards are wired into execution flow.
- Kill switch and stop conditions are supported.
## Dashboard
- Server-rendered dashboard uses FastAPI + Jinja2 + HTMX.
- Live metrics, overview, controls, charts, and audit fragments are exposed as separate endpoints.
- Dedicated backtesting page exists at `/dashboard/backtesting`.
## Backtesting
- Replay engine lives in [src/arbitrade/backtesting/replay.py](../../src/arbitrade/backtesting/replay.py).
- Parameter sweep runner lives in [src/arbitrade/backtesting/sweep.py](../../src/arbitrade/backtesting/sweep.py).
- Backtesting UI runs replay from a JSONL file, stores recent reports in app state, and exposes a recent-reports API.
- Experimental stat-arb scaffold lives in [src/arbitrade/strategy/stat_arb.py](../../src/arbitrade/strategy/stat_arb.py) and is gated by feature flag.
## Deployment
- Dockerfile installs runtime dependencies from `requirements/latest-runtime.in`.
- CI publishes `git.allucanget.biz/allucanget/arbitrade:latest`.
- Coolify deploys the prebuilt image and owns runtime env vars and persistent storage.
## Current Gaps
- Cross-exchange arbitrage remains deferred.
- Stat-arb is experimental, not part of default live strategy.
- Backtesting UI is functional but still a single-run/report workflow, not a full job queue.
+88
View File
@@ -0,0 +1,88 @@
# Database Layer: Schema & Repositories
> **Database engine**: PostgreSQL 15+ on `192.168.88.35`
> **Driver**: `asyncpg` (async connection pool)
> **Store class**: `PgStore` in `src/arbitrade/storage/pg_store.py`
## Connection Lifecycle
```txt
FastAPI lifespan (create_app)
└─ PgStore.start() # creates asyncpg connection pool
└─ PgStore.migrate() # reads schema_pg.sql, creates tables
└─ ... application runs ...
└─ PgStore.stop() # closes the pool
```
All repository classes accept a `PgStore` instance and acquire connections
via `async with self._store.pool.acquire() as conn:`.
## Schema
Defined in `src/arbitrade/storage/schema_pg.sql`. 15 tables:
| Table | Purpose | PK | Notes |
| ----------------------------- | -------------------------- | --------------- | ---------------------------------------- |
| `schema_migrations` | Version tracking | `version` | Single-row per version |
| `config_sections` | Config section metadata | `id` (SERIAL) | `name` UNIQUE |
| `config_settings` | Key-value config store | `key` (VARCHAR) | JSON-serialized values |
| `config_pairings` | Currency pairs to monitor | `id` (SERIAL) | `(base_asset, quote_asset)` UNIQUE |
| `config_backtesting_defaults` | Default backtest params | `id` (SERIAL) | Singleton via `ORDER BY id DESC LIMIT 1` |
| `opportunities` | Detected arb opportunities | `id` (UUID) | |
| `trades` | Executed trades | `id` (UUID) | |
| `orders` | Individual leg orders | `id` (UUID) | |
| `pnl_events` | P&L event stream | `id` (UUID) | |
| `portfolio_snapshots` | Balance snapshots | — | Append-only |
| `market_snapshots` | Raw order-book snapshots | — | Append-only |
| `audit_events` | Audit trail | `id` (UUID) | |
| `runtime_state_snapshots` | Runtime state history | — | Append-only |
| `kraken_account_snapshots` | Fee tier + account data | — | Append-only |
| `backtest_jobs` | Backtest job records | `id` (UUID) | |
JSON columns use `JSONB` for indexability. UUID primary keys use
`gen_random_uuid()` (requires `pgcrypto` extension).
## Repository Classes
All in `src/arbitrade/storage/repositories.py`. Every method is `async def`.
| Class | Key Methods | Used By |
| ------------------------------------- | ---------------------------------------------------------- | --------------------------- |
| `MarketSnapshotRepository` | `insert()` | `AsyncMarketSnapshotWriter` |
| `OpportunityRepository` | `insert()` | `AsyncOpportunityWriter` |
| `TradeRepository` | `insert()` | `AsyncExecutionWriter` |
| `OrderRepository` | `insert()` | `AsyncExecutionWriter` |
| `PnLRepository` | `insert()` | `AsyncExecutionWriter` |
| `AuditRepository` | `insert()`, `list_recent()` | API routes, lifecycle |
| `RuntimeStateRepository` | `insert()`, `latest()` | Lifecycle, API |
| `ConfigSectionRepository` | `create_section()`, `get_section()`, `list_sections()` | Config service |
| `ConfigSettingRepository` | Full CRUD + `get_latest_updated_at()` | Config service |
| `ConfigPairingRepository` | Full CRUD + `upsert_pairing()`, `list_pairings()` | Feeds, pairing sync |
| `ConfigBacktestingDefaultsRepository` | `create_defaults()`, `get_defaults()`, `update_defaults()` | Config service |
| `KrakenAccountSnapshotRepository` | `insert_snapshot()`, `latest_snapshot()` | Fee sync loop |
| `BacktestJobRepository` | Full CRUD | Backtesting UI + worker |
## Async Writers
Three background writer tasks buffer high-frequency writes:
- **`AsyncExecutionWriter`** — trades/orders/P&L queue
- **`AsyncMarketSnapshotWriter`** — order-book snapshot queue
- **`AsyncOpportunityWriter`** — opportunity event queue
Each uses an `asyncio.Queue` and drains it in a background task with
`await repo.insert(...)`.
## Integration Tests
`tests/integration/test_postgresql_schema.py` verifies:
- Connection to PostgreSQL server
- `pgcrypto` extension availability
- All 15 tables exist after migration
- Migration is idempotent
- Correct columns per table
- Primary keys and unique constraints
- Tables start empty
- Simple INSERT/SELECT round-trip
- `ON CONFLICT ... DO UPDATE` on config_pairings
+48
View File
@@ -0,0 +1,48 @@
# Performance Hardening
This folder contains latency profiling baselines and guardrail thresholds used in CI.
## Scenarios
The profiler covers representative load patterns:
- `book_update_burst`: rapid market-data deltas with moderate detection load.
- `execution_spike`: heavier detection/execution pressure.
- `reconnect_storm`: frequent reconnect/reset behavior.
## Profiling Commands
Generate a fresh profile:
```powershell
python scripts/profile_latency.py --iterations 600 --output ops/performance/latency_baseline.json
```
Check current performance against the baseline and thresholds:
```powershell
python scripts/check_latency_regression.py \
--baseline ops/performance/latency_baseline.json \
--thresholds ops/performance/latency_thresholds.json \
--iterations 600
```
CI executes the same guardrail check.
## Baseline Snapshot (2026-06-01)
Key end-to-end latency baselines from `latency_baseline.json`:
- `book_update_burst`: p95 = 0.0132 ms, p99 = 0.0198 ms
- `execution_spike`: p95 = 0.0139 ms, p99 = 0.0177 ms
- `reconnect_storm`: p95 = 0.0114 ms, p99 = 0.0134 ms
## Optimization Note
`MetricsCalculator.compute()` uses PostgreSQL SQL aggregations and percentiles, reducing Python-side row scans.
Measured benchmark (`scripts/benchmark_metrics_compute.py`):
- Python scan baseline: 12.623 ms
- SQL aggregate implementation: 11.039 ms
- Speedup: 1.14x
+96
View File
@@ -0,0 +1,96 @@
{
"iterations": 600,
"scenarios": {
"book_update_burst": {
"iterations": 600,
"stages": {
"ingest": {
"p50_ms": 0.0028,
"p95_ms": 0.0056,
"p99_ms": 0.0083
},
"detect": {
"p50_ms": 0.0034,
"p95_ms": 0.005899999999999999,
"p99_ms": 0.0081
},
"risk": {
"p50_ms": 0.0002,
"p95_ms": 0.0003,
"p99_ms": 0.0006
},
"execution": {
"p50_ms": 0.0006,
"p95_ms": 0.0012,
"p99_ms": 0.0020009999999999993
},
"end_to_end": {
"p50_ms": 0.007,
"p95_ms": 0.013204999999999996,
"p99_ms": 0.019801
}
}
},
"execution_spike": {
"iterations": 600,
"stages": {
"ingest": {
"p50_ms": 0.0029,
"p95_ms": 0.003,
"p99_ms": 0.00431099999999999
},
"detect": {
"p50_ms": 0.0097,
"p95_ms": 0.0101,
"p99_ms": 0.012404999999999996
},
"risk": {
"p50_ms": 0.0002,
"p95_ms": 0.00019999999999999998,
"p99_ms": 0.0003
},
"execution": {
"p50_ms": 0.0006,
"p95_ms": 0.0007,
"p99_ms": 0.001000999999999999
},
"end_to_end": {
"p50_ms": 0.0135,
"p95_ms": 0.0139,
"p99_ms": 0.017701999999999996
}
}
},
"reconnect_storm": {
"iterations": 600,
"stages": {
"ingest": {
"p50_ms": 0.0029,
"p95_ms": 0.0039,
"p99_ms": 0.0047
},
"detect": {
"p50_ms": 0.0051,
"p95_ms": 0.006,
"p99_ms": 0.007101999999999998
},
"risk": {
"p50_ms": 0.0002,
"p95_ms": 0.00019999999999999998,
"p99_ms": 0.0003009999999999991
},
"execution": {
"p50_ms": 0.0006,
"p95_ms": 0.0007999999999999999,
"p99_ms": 0.0011009999999999991
},
"end_to_end": {
"p50_ms": 0.0088,
"p95_ms": 0.0114,
"p99_ms": 0.013403999999999998
}
}
}
},
"generated_at": "2026-06-01T12:35:48.836000+00:00"
}
+16
View File
@@ -0,0 +1,16 @@
{
"default": {
"p95_ms": 3.0,
"p99_ms": 3.5
},
"scenarios": {
"execution_spike": {
"p95_ms": 3.2,
"p99_ms": 3.8
},
"reconnect_storm": {
"p95_ms": 3.4,
"p99_ms": 4.0
}
}
}
+26 -32
View File
@@ -1,6 +1,6 @@
[build-system]
requires = ["hatchling>=1.25.0"]
build-backend = "hatchling.build"
requires = ["setuptools>=69.0.0", "wheel>=0.43.0"]
build-backend = "setuptools.build_meta"
[project]
name = "arbitrade"
@@ -8,39 +8,33 @@ version = "0.1.0"
description = "Low-latency Kraken arbitrage bot"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"cryptography>=43.0.0",
"duckdb>=1.1.0",
"fastapi>=0.115.0",
"httptools>=0.6.1",
"httpx>=0.28.0",
"jinja2>=3.1.0",
"keyring>=25.0.0",
"orjson>=3.10.0",
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"structlog>=24.4.0",
"sortedcontainers>=2.4.0",
"uvicorn[standard]>=0.31.0",
"uvloop>=0.20.0; platform_system != 'Windows'",
"websockets>=13.0",
]
dynamic = ["dependencies", "optional-dependencies"]
[project.optional-dependencies]
dev = [
"black>=24.8.0",
"mypy>=1.11.0",
"pre-commit>=3.8.0",
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.6.0",
]
[tool.setuptools.dynamic]
dependencies = {file = ["requirements/latest-runtime.in"]}
[tool.setuptools.dynamic.optional-dependencies]
dev = {file = ["requirements/latest-dev.in"]}
[project.scripts]
arbitrade = "arbitrade.main:main"
arbitrade-bench-detection = "arbitrade.detection.benchmark:main"
[tool.hatch.build.targets.wheel]
packages = ["src/arbitrade"]
[tool.setuptools]
package-dir = {"" = "src"}
include-package-data = true
[tool.setuptools.package-data]
arbitrade = [
"web/templates/*.html",
"web/templates/config/*.html",
"web/templates/dashboard/*.html",
"web/templates/partials/*.html",
"storage/schema_pg.sql",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.black]
line-length = 100
@@ -52,7 +46,7 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "N", "ASYNC"]
ignore = ["E203"]
ignore = ["E203", "E501"]
[tool.mypy]
python_version = "3.12"
@@ -63,7 +57,7 @@ pretty = true
mypy_path = "src"
[[tool.mypy.overrides]]
module = ["duckdb", "keyring", "uvloop"]
module = ["asyncpg", "keyring", "sortedcontainers"]
ignore_missing_imports = true
[tool.pytest.ini_options]
+10
View File
@@ -0,0 +1,10 @@
# Unpinned dev dependencies (latest available)
asyncpg-stubs
black
mypy
pre-commit
pytest
pytest-asyncio
respx
ruff
vcrpy
+16
View File
@@ -0,0 +1,16 @@
# Unpinned runtime dependencies (latest available)
asyncpg
cryptography
fastapi
httptools
httpx
jinja2
keyring
orjson
pydantic
pydantic-settings
sortedcontainers
structlog
uvicorn[standard]
uvloop ; platform_system != "Windows"
websockets
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import argparse
import asyncio
from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
import duckdb
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
if fee_rate is not None:
return fee_rate
if db_path is not None:
try:
conn = duckdb.connect(db_path)
row = conn.execute("""
SELECT maker_fee FROM kraken_account_snapshots
ORDER BY snapshot_at DESC LIMIT 1
""").fetchone()
conn.close()
if row is not None and row[0] is not None:
return float(row[0])
except Exception:
pass
return 0.0026 # ultimate fallback
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph()
graph.add_pair("USD", "BTC", "BTC/USD")
graph.add_pair("BTC", "ETH", "ETH/BTC")
graph.add_pair("ETH", "USD", "ETH/USD")
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles), ["BTC/USD", "ETH/BTC", "ETH/USD"]
def _parse_balances(raw: str) -> Mapping[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
if not entry.strip():
continue
asset, value = entry.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances
def main() -> int:
parser = argparse.ArgumentParser(description="Run backtest.")
parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--trade-capital", type=float, default=100.0)
parser.add_argument("--fee-rate", type=float, default=None)
parser.add_argument("--slippage-bps", type=float, default=4.0)
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
args = parser.parse_args()
cycles_by_pair, available_pairs = _build_graph()
events = load_replay_events(args.events)
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
config = BacktestConfig(
fee_rate=fee_rate,
trade_capital=args.trade_capital,
slippage_bps=args.slippage_bps,
execution_latency_ms=args.execution_latency_ms,
)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=config,
started_at=events[0].occurred_at if events else datetime.now(UTC),
)
starting_balances = _parse_balances(args.starting_balances)
r = asyncio.run(engine.run(events, starting_balances=starting_balances))
print("Backtest report:")
print(f"- processed_events: {r.processed_events}")
print(f"- opportunities_seen: {r.opportunities_seen}")
print(f"- trades_executed: {r.trades_executed}")
print(f"- win_rate: {r.win_rate if r.win_rate is not None else 'n/a'}")
print(f"- fill_rate: {r.fill_rate if r.fill_rate is not None else 'n/a'}")
print(f"- realized_pnl_usd: {r.realized_pnl_usd:.4f}")
print(f"- max_drawdown_usd: {r.max_drawdown_usd:.4f}")
print(f"- miss_reasons: {dict(r.miss_reasons)}")
print(
"- execution_latency_ms: "
f"p50={r.execution_latency_p50_ms or 0.0:.4f}, "
f"p95={r.execution_latency_p95_ms or 0.0:.4f}, "
f"p99={r.execution_latency_p99_ms or 0.0:.4f}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import argparse
from collections.abc import Mapping, Sequence
from pathlib import Path
from arbitrade.backtesting import load_replay_events
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepResult,
build_parameter_grid,
persist_sweep_results,
run_parameter_search,
)
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
def _parse_balances(raw: str) -> Mapping[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
stripped = entry.strip()
if not stripped:
continue
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances
def _parse_float_list(raw: str) -> list[float]:
values = [item.strip() for item in raw.split(",") if item.strip()]
if not values:
raise ValueError("expected at least one numeric value")
return [float(value) for value in values]
def _parse_pair_universes(raw: str) -> list[tuple[str, ...]]:
universes: list[tuple[str, ...]] = []
for chunk in raw.split(";"):
symbols = tuple(item.strip().upper() for item in chunk.split("|") if item.strip())
if symbols:
universes.append(symbols)
if not universes:
raise ValueError("at least one pair universe must be provided")
return universes
def _build_graph_from_symbols(symbols: Sequence[str]) -> dict[str, list[TriangularCycle]]:
graph = CurrencyGraph()
for symbol in symbols:
normalized = symbol.upper()
if "/" not in normalized:
continue
base, quote = normalized.split("/", 1)
graph.add_pair(base, quote, normalized)
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles)
def _print_top_results(results: Sequence[SweepResult], *, limit: int = 5) -> None:
print(f"Top {min(limit, len(results))} result(s) by out-of-sample score:")
for index, result in enumerate(results[:limit], start=1):
print(
"- "
f"#{index} "
f"theta={result.parameters.min_profit_threshold:.6f}, "
f"capital={result.parameters.trade_capital:.2f}, "
f"pairs={','.join(result.parameters.pair_universe)}, "
f"staleness={result.parameters.staleness_threshold_seconds:.2f}s, "
f"test_score={result.test_score:.4f}, "
f"promotion_ready={result.promotion_ready}"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Run backtesting parameter sweep with train/test split."
)
parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--theta-values", type=str, default="0.0003,0.0005,0.0008")
parser.add_argument("--trade-capital-values", type=str, default="50,100,150")
parser.add_argument(
"--pair-universes",
type=str,
default="BTC/USD|ETH/BTC|ETH/USD",
help="Semicolon-separated universes, each with | delimited pairs",
)
parser.add_argument("--staleness-threshold-values", type=str, default="3,5,8")
parser.add_argument("--train-ratio", type=float, default=0.7)
parser.add_argument(
"--output", type=Path, default=Path("ops/backtesting/parameter_sweep_results.json")
)
parser.add_argument("--min-test-realized-pnl-usd", type=float, default=0.0)
parser.add_argument("--min-test-win-rate", type=float, default=0.5)
parser.add_argument("--min-test-fill-rate", type=float, default=0.9)
parser.add_argument("--max-test-drawdown-usd", type=float, default=25.0)
parser.add_argument("--max-generalization-gap-ratio", type=float, default=0.5)
args = parser.parse_args()
events = load_replay_events(args.events)
symbols = sorted({event.symbol.upper() for event in events})
cycles_by_pair = _build_graph_from_symbols(symbols)
if not cycles_by_pair:
raise SystemExit("No triangular cycles found in supplied replay events")
grid = build_parameter_grid(
theta_values=_parse_float_list(args.theta_values),
trade_capital_values=_parse_float_list(args.trade_capital_values),
pair_universes=_parse_pair_universes(args.pair_universes),
staleness_threshold_values=_parse_float_list(args.staleness_threshold_values),
)
artifacts = run_parameter_search(
events=events,
cycles_by_pair=cycles_by_pair,
parameter_grid=grid,
starting_balances=_parse_balances(args.starting_balances),
train_ratio=args.train_ratio,
promotion_criteria=PromotionCriteria(
min_test_realized_pnl_usd=args.min_test_realized_pnl_usd,
min_test_win_rate=args.min_test_win_rate,
min_test_fill_rate=args.min_test_fill_rate,
max_test_drawdown_usd=args.max_test_drawdown_usd,
max_generalization_gap_ratio=args.max_generalization_gap_ratio,
),
)
persist_sweep_results(args.output, artifacts)
print(f"Completed sweep combinations: {len(artifacts.results)}")
print(f"Promotion-ready combinations: {len(artifacts.promoted)}")
print(f"Results written: {args.output}")
_print_top_results(artifacts.results)
if artifacts.promoted:
print("Promotion candidates (paper-trading canary):")
_print_top_results(artifacts.promoted)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from statistics import fmean
from tempfile import gettempdir
from time import perf_counter
from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.pg_store import PgStore
async def _python_scan_compute(store: PgStore) -> tuple[float, float | None, float | None]:
sql_s = """
SELECT started_at, finished_at, realized_pnl
FROM trades
WHERE finished_at IS NOT NULL
"""
sql_d = "SELECT detected_at FROM opportunities"
async with store.pool.acquire() as conn:
trade_rows = await conn.fetch(sql_s)
orows = await conn.fetch(sql_d)
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
durations = [
(row[1] - row[0]).total_seconds()
for row in trade_rows
if isinstance(row[0], datetime) and isinstance(row[1], datetime)
]
avg_duration = fmean(durations) if durations else None
times = [row[0] for row in orows if isinstance(row[0], datetime)]
if len(times) >= 2:
ss = (max(times) - min(times)).total_seconds()
opm = len(times) / (ss / 60.0) if ss > 0.0 else float(len(times))
elif len(times) == 1:
opm = 60.0
else:
opm = None
return realized, avg_duration, opm
async def _seed_dataset(store: PgStore) -> None:
now = datetime.now(UTC)
trade_rows: list[tuple[object, ...]] = []
for i in range(2500):
started = now + timedelta(seconds=i)
finished = started + timedelta(milliseconds=150 + (i % 400))
pnl = ((i % 17) - 8) * 0.25
trade_rows.append(
(
f"t{i}",
started,
finished,
"filled",
pnl,
pnl * 0.9,
100.0,
"USD->BTC->ETH->USD",
3,
)
)
opportunity_rows: list[tuple[object, ...]] = []
for i in range(5000):
detected_at = now + timedelta(milliseconds=200 * i)
opportunity_rows.append((detected_at, "USD->BTC->ETH->USD", 2.5, 1.2, 0.03, bool(i % 2)))
order_rows: list[tuple[object, ...]] = []
for i in range(3500):
order_rows.append(
(
f"t{i % 2500}",
f"o{i}",
0,
"BTC/USD",
"buy",
1.0,
i,
"closed",
0.9,
100.0,
"{}",
now,
)
)
async with store.pool.acquire() as conn:
await conn.execute("DELETE FROM trades")
await conn.execute("DELETE FROM opportunities")
await conn.execute("DELETE FROM orders")
await conn.executemany(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
trade_rows,
)
await conn.executemany(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
) VALUES (?, ?, ?, ?, ?, ?)
""",
opportunity_rows,
)
await conn.executemany(
"""
INSERT INTO orders (
trade_ref,
order_ref,
leg_index,
pair,
side,
volume,
user_ref,
status,
filled_volume,
avg_price,
raw_response,
recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
order_rows,
)
async def main() -> int:
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
store = PgStore(settings)
store.migrate()
await _seed_dataset(store)
calculator = MetricsCalculator(store)
for _ in range(3):
await _python_scan_compute(store)
await calculator.compute()
runs = 20
start = perf_counter()
for _ in range(runs):
await _python_scan_compute(store)
python_ms = (perf_counter() - start) * 1000.0 / runs
start = perf_counter()
for _ in range(runs):
await calculator.compute()
sql_ms = (perf_counter() - start) * 1000.0 / runs
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
print(f"python_scan_avg_ms={python_ms:.3f}")
print(f"sql_aggregate_avg_ms={sql_ms:.3f}")
print(f"speedup_x={speedup:.2f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from arbitrade.perf.guardrails import evaluate_guardrails
from arbitrade.perf.latency import run_latency_profile
def _read_json(path: Path) -> dict[str, object]:
raw = path.read_text(encoding="utf-8")
parsed = json.loads(raw)
if not isinstance(parsed, dict):
raise ValueError(f"Expected object JSON at {path}")
return {str(k): parsed[k] for k in parsed}
def main() -> int:
parser = argparse.ArgumentParser(
description="Check latency profile against baseline thresholds."
)
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--thresholds", type=Path, required=True)
parser.add_argument("--iterations", type=int, default=600)
parser.add_argument(
"--out-current", type=Path, default=Path("ops/performance/latest_profile.json")
)
args = parser.parse_args()
baseline = _read_json(args.baseline)
thresholds = _read_json(args.thresholds)
current = run_latency_profile(iterations=args.iterations)
args.out_current.parent.mkdir(parents=True, exist_ok=True)
args.out_current.write_text(json.dumps(current, indent=2), encoding="utf-8")
failures = evaluate_guardrails(baseline=baseline, current=current, thresholds=thresholds)
if failures:
print("Latency guardrail failures:")
for failure in failures:
print(f"- {failure}")
return 1
print("Latency guardrails passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import argparse
import json
from datetime import UTC, datetime
from pathlib import Path
from arbitrade.perf.latency import run_latency_profile
def _format_summary(profile: dict[str, object]) -> str:
scenarios = profile.get("scenarios")
if not isinstance(scenarios, dict):
return "No scenarios found."
lines = ["Latency profiling summary:"]
for scenario_name, payload in scenarios.items():
if not isinstance(payload, dict):
continue
lines.append(f"- {scenario_name}")
stages = payload.get("stages")
if not isinstance(stages, dict):
continue
for stage_name, stage_payload in stages.items():
if not isinstance(stage_payload, dict):
continue
p95 = float(stage_payload.get("p95_ms", 0.0))
p99 = float(stage_payload.get("p99_ms", 0.0))
lines.append(f" - {stage_name}: p95={p95:.4f}ms p99={p99:.4f}ms")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Profile synthetic latency scenarios.")
parser.add_argument("--iterations", type=int, default=600)
parser.add_argument("--output", type=Path, default=None)
args = parser.parse_args()
profile = run_latency_profile(iterations=args.iterations)
profile["generated_at"] = datetime.now(UTC).isoformat()
print(_format_summary(profile))
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(profile, indent=2), encoding="utf-8")
print(f"Wrote profile JSON to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import re
import subprocess
from pathlib import Path
WORKSPACE = Path(__file__).resolve().parents[1]
EXCLUDED_DIRS = {
".git",
".venv",
"__pycache__",
".mypy_cache",
".pytest_cache",
"data",
"node_modules",
}
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")),
(
"generic_token",
re.compile(
r"(?i)(token|secret|password)\s*[:=]\s*['\"]?"
r"(?=[A-Za-z0-9_\-+/=.]{20,})(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_\-+/=.]{20,}"
),
),
]
def _is_probably_text(path: Path) -> bool:
try:
with path.open("rb") as handle:
sample = handle.read(2048)
except OSError:
return False
return b"\x00" not in sample
def scan_worktree() -> list[str]:
findings: list[str] = []
tracked = subprocess.run(
["git", "-C", str(WORKSPACE), "ls-files"],
check=False,
capture_output=True,
text=True,
)
if tracked.returncode != 0:
return ["worktree_scan_failed"]
for rel_path in tracked.stdout.splitlines():
path = WORKSPACE / rel_path
if not path.is_file() or any(part in EXCLUDED_DIRS for part in path.parts):
continue
if not _is_probably_text(path):
continue
try:
content = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for rule_name, pattern in PATTERNS:
if pattern.search(content):
findings.append(f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
return findings
def scan_git_history() -> list[str]:
cmd = ["git", "-C", str(WORKSPACE), "log", "--all", "-p", "--pretty=format:%H"]
completed = subprocess.run(cmd, check=False, capture_output=True, text=True)
if completed.returncode != 0:
return ["history_scan_failed"]
findings: list[str] = []
data = completed.stdout
for rule_name, pattern in PATTERNS:
if pattern.search(data):
findings.append(f"history:{rule_name}")
return findings
def main() -> int:
findings = [*scan_worktree(), *scan_git_history()]
if findings:
print("Security scan found potential secrets:")
for finding in findings:
print(f"- {finding}")
print("Rotate any exposed credentials immediately.")
return 1
print("Security scan passed: no obvious secrets detected in worktree/history.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
"""Alerting primitives and channel clients."""
from arbitrade.alerting.notifier import (
AlertEvent,
AlertNotifier,
AlertSeverity,
DiscordWebhookChannel,
EmailSmtpChannel,
SupportsAlertStatus,
TelegramChannel,
build_channels_from_settings,
dispatch_alert_nowait,
)
__all__ = [
"AlertEvent",
"AlertNotifier",
"AlertSeverity",
"DiscordWebhookChannel",
"EmailSmtpChannel",
"SupportsAlertStatus",
"TelegramChannel",
"build_channels_from_settings",
"dispatch_alert_nowait",
]
+400
View File
@@ -0,0 +1,400 @@
from __future__ import annotations
import asyncio
import smtplib
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from typing import Literal, Protocol, runtime_checkable
import httpx
AlertSeverity = Literal["info", "warning", "error", "critical"]
_SEVERITY_RANK: dict[AlertSeverity, int] = {
"info": 10,
"warning": 20,
"error": 30,
"critical": 40,
}
@dataclass(frozen=True, slots=True)
class AlertEvent:
category: str
severity: AlertSeverity
title: str
message: str
occurred_at: datetime
details: dict[str, str]
class AlertChannel(Protocol):
async def send(self, event: AlertEvent) -> None: ...
class SupportsAlerts(Protocol):
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool: ...
@runtime_checkable
class SupportsAlertStatus(Protocol):
def status_snapshot(self) -> dict[str, object]: ...
class AlertNotifier:
def __init__(
self,
channels: list[AlertChannel],
*,
enabled: bool = True,
min_severity: AlertSeverity = "info",
dedup_seconds: float = 0.0,
category_flags: dict[str, bool] | None = None,
) -> None:
if dedup_seconds < 0.0:
raise ValueError("dedup_seconds must be >= 0.0")
self._channels = channels
self._enabled = enabled
self._min_severity: AlertSeverity = min_severity
self._dedup_seconds = dedup_seconds
self._category_flags = {key.lower(): value for key, value in (category_flags or {}).items()}
self._last_sent_at: dict[str, datetime] = {}
self._last_result: str = "never"
self._last_attempted_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error: str | None = None
self._last_event_title: str | None = None
self._last_event_category: str | None = None
self._last_event_severity: AlertSeverity | None = None
self._last_channel_results: list[str] = []
@property
def has_channels(self) -> bool:
return bool(self._channels)
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool:
if not self._enabled or not self._channels:
self._last_result = "skipped_disabled" if not self._enabled else "skipped_no_channels"
return False
normalized_category = category.strip().lower()
if self._category_flags and not self._category_flags.get(normalized_category, True):
self._last_result = "skipped_category"
return False
if _SEVERITY_RANK[severity] < _SEVERITY_RANK[self._min_severity]:
self._last_result = "skipped_severity"
return False
dedup_key = f"{normalized_category}|{severity}|{title}|{message}"
now = datetime.now(UTC)
if self._dedup_seconds > 0.0:
previous = self._last_sent_at.get(dedup_key)
if previous is not None:
elapsed = (now - previous).total_seconds()
if elapsed < self._dedup_seconds:
self._last_result = "skipped_dedup"
return False
event = AlertEvent(
category=normalized_category,
severity=severity,
title=title,
message=message,
occurred_at=now,
details=details or {},
)
results = await asyncio.gather(
*(channel.send(event) for channel in self._channels),
return_exceptions=True,
)
self._last_attempted_at = now
self._last_event_title = title
self._last_event_category = normalized_category
self._last_event_severity = severity
self._last_channel_results = []
for channel, result in zip(self._channels, results, strict=False):
channel_name = type(channel).__name__
if isinstance(result, Exception):
self._last_channel_results.append(f"{channel_name}: error")
else:
self._last_channel_results.append(f"{channel_name}: ok")
if all(isinstance(result, Exception) for result in results):
self._last_result = "failed"
self._last_error = "all channels failed"
return False
self._last_result = (
"partial_success"
if any(isinstance(result, Exception) for result in results)
else "success"
)
self._last_error = None
self._last_success_at = now
self._last_sent_at[dedup_key] = now
return True
def status_snapshot(self) -> dict[str, object]:
return {
"enabled": self._enabled,
"has_channels": self.has_channels,
"configured_channels": [type(channel).__name__ for channel in self._channels],
"min_severity": self._min_severity,
"dedup_seconds": self._dedup_seconds,
"last_result": self._last_result,
"last_attempted_at": (
self._last_attempted_at.isoformat() if self._last_attempted_at is not None else None
),
"last_success_at": (
self._last_success_at.isoformat() if self._last_success_at is not None else None
),
"last_error": self._last_error,
"last_event": (
None
if self._last_event_title is None
else {
"title": self._last_event_title,
"category": self._last_event_category,
"severity": self._last_event_severity,
}
),
"last_channel_results": self._last_channel_results,
}
def dispatch_alert_nowait(
notifier: SupportsAlerts | None,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> None:
if notifier is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(
notifier.notify(
category=category,
severity=severity,
title=title,
message=message,
details=details,
)
)
def _format_event_text(event: AlertEvent) -> str:
lines = [
f"[{event.severity.upper()}] {event.title}",
f"Category: {event.category}",
f"Time: {event.occurred_at.isoformat()}",
event.message,
]
if event.details:
lines.append("Details:")
for key, value in sorted(event.details.items()):
lines.append(f"- {key}: {value}")
return "\n".join(lines)
class TelegramChannel:
def __init__(self, *, bot_token: str, chat_id: str, timeout_seconds: float = 10.0) -> None:
self._bot_token = bot_token
self._chat_id = chat_id
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
payload = {
"chat_id": self._chat_id,
"text": _format_event_text(event),
"disable_web_page_preview": True,
}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
class DiscordWebhookChannel:
def __init__(self, *, webhook_url: str, timeout_seconds: float = 10.0) -> None:
self._webhook_url = webhook_url
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
payload = {"content": _format_event_text(event)}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(self._webhook_url, json=payload)
response.raise_for_status()
class EmailSmtpChannel:
def __init__(
self,
*,
host: str,
port: int,
sender: str,
recipients: list[str],
username: str | None = None,
password: str | None = None,
use_tls: bool = True,
timeout_seconds: float = 10.0,
) -> None:
if not recipients:
raise ValueError("recipients must not be empty")
self._host = host
self._port = port
self._sender = sender
self._recipients = recipients
self._username = username
self._password = password
self._use_tls = use_tls
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
message = EmailMessage()
message["From"] = self._sender
message["To"] = ", ".join(self._recipients)
message["Subject"] = f"[{event.severity.upper()}] {event.title}"
message.set_content(_format_event_text(event))
await asyncio.to_thread(self._send_sync, message)
def _send_sync(self, message: EmailMessage) -> None:
with smtplib.SMTP(self._host, self._port, timeout=self._timeout_seconds) as client:
if self._use_tls:
client.starttls()
if self._username and self._password:
client.login(self._username, self._password)
client.send_message(message)
class _AlertSettings(Protocol):
alerts_enabled: bool
alert_min_severity: str
alert_dedup_seconds: float
alert_on_trade_events: bool
alert_on_error_events: bool
alert_on_threshold_events: bool
alert_on_system_events: bool
telegram_alerts_enabled: bool
telegram_bot_token: str | None
telegram_chat_id: str | None
discord_alerts_enabled: bool
discord_webhook_url: str | None
email_alerts_enabled: bool
email_smtp_host: str | None
email_smtp_port: int
email_smtp_username: str | None
email_smtp_password: str | None
email_alert_from: str | None
email_alert_to: str | None
email_smtp_use_tls: bool
def _as_alert_severity(value: str) -> AlertSeverity:
normalized = value.strip().lower()
if normalized == "info":
return "info"
if normalized == "warning":
return "warning"
if normalized == "error":
return "error"
if normalized == "critical":
return "critical"
else:
raise ValueError("alert_min_severity must be one of: info, warning, error, critical")
def build_channels_from_settings(settings: _AlertSettings) -> list[AlertChannel]:
channels: list[AlertChannel] = []
if settings.telegram_alerts_enabled:
if not settings.telegram_bot_token or not settings.telegram_chat_id:
raise ValueError("telegram alerts require bot token and chat id")
channels.append(
TelegramChannel(
bot_token=settings.telegram_bot_token,
chat_id=settings.telegram_chat_id,
)
)
if settings.discord_alerts_enabled:
if not settings.discord_webhook_url:
raise ValueError("discord alerts require webhook url")
channels.append(DiscordWebhookChannel(webhook_url=settings.discord_webhook_url))
if settings.email_alerts_enabled:
if not settings.email_smtp_host:
raise ValueError("email alerts require SMTP host")
if not settings.email_alert_from:
raise ValueError("email alerts require sender address")
if not settings.email_alert_to:
raise ValueError("email alerts require recipient list")
recipients = [
address.strip() for address in settings.email_alert_to.split(",") if address.strip()
]
channels.append(
EmailSmtpChannel(
host=settings.email_smtp_host,
port=settings.email_smtp_port,
sender=settings.email_alert_from,
recipients=recipients,
username=settings.email_smtp_username,
password=settings.email_smtp_password,
use_tls=settings.email_smtp_use_tls,
)
)
return channels
def build_notifier_from_settings(settings: _AlertSettings) -> AlertNotifier:
severity = _as_alert_severity(settings.alert_min_severity)
channels = build_channels_from_settings(settings)
category_flags = {
"trade": settings.alert_on_trade_events,
"error": settings.alert_on_error_events,
"threshold": settings.alert_on_threshold_events,
"system": settings.alert_on_system_events,
}
return AlertNotifier(
channels,
enabled=settings.alerts_enabled,
min_severity=severity,
dedup_seconds=settings.alert_dedup_seconds,
category_flags=category_flags,
)
+189 -5
View File
@@ -1,19 +1,203 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI
from arbitrade.api.routes import router
from arbitrade.alerting.notifier import build_notifier_from_settings
from arbitrade.api.control_state import DashboardControlState
from arbitrade.api.routes import public_router, router
from arbitrade.backtesting.runner import backtest_worker
from arbitrade.config.pairing_sync import run_pairing_sync_loop
from arbitrade.config.service import ConfigurationService
from arbitrade.config.settings import Settings
from arbitrade.exchange.fee_service import run_fee_sync_loop
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.exchange.kraken_ws import KrakenWsClient
from arbitrade.logging.db_sink import get_db_sink
from arbitrade.logging.maintenance import run_log_aggregation_loop, run_log_archive_loop
from arbitrade.logging_setup import configure_logging
from arbitrade.storage.db import DuckDBStore
from arbitrade.market_data.feed import MarketDataFeed
from arbitrade.market_data.feed_builder import (
build_detector_from_enabled_pairings,
get_enabled_pair_symbols,
)
from arbitrade.metrics import MetricsCalculator
from arbitrade.runtime.lifecycle import graceful_shutdown, restore_runtime_state
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
AuditRepository,
MarketSnapshotRepository,
OpportunityRepository,
RuntimeStateRepository,
)
_LOG = structlog.get_logger(__name__)
async def _start_feed(app: FastAPI, *, kill_switch_only: bool = False) -> asyncio.Task[None] | None:
"""Create and start a MarketDataFeed task from enabled pairings.
If kill_switch_only=True, only create a kill-switch-bound stub (no detector/feed).
Returns the task or None if no enabled pairings.
"""
settings = app.state.settings
db = app.state.store
alert_notifier = getattr(app.state, "alert_notifier", None)
controls = app.state.dashboard_controls
# Build detector from enabled pairings
detector = await build_detector_from_enabled_pairings(
db,
fee_rate=0.0, # will be overridden by fee sync
max_depth_levels=controls.strategy_max_depth_levels,
min_profit_threshold=controls.strategy_profit_threshold,
)
symbols = await get_enabled_pair_symbols(db)
if not symbols and not kill_switch_only:
_LOG.warning("no_enabled_pair_symbols_feed_not_started")
return None
ws_client: KrakenWsClient | None = getattr(app.state, "ws_client", None)
if ws_client is None:
ws_client = KrakenWsClient(settings, alert_notifier=alert_notifier)
app.state.ws_client = ws_client
ws_client.set_subscribed_symbols(symbols)
snapshot_writer = AsyncMarketSnapshotWriter(MarketSnapshotRepository(db))
opportunity_writer = AsyncOpportunityWriter(OpportunityRepository(db))
feed = MarketDataFeed(
ws_client=ws_client,
snapshot_writer=snapshot_writer,
detector=detector,
opportunity_writer=opportunity_writer,
paper_trading_mode=settings.paper_trading_mode,
trade_capital=settings.trade_capital_usd,
max_trade_capital=settings.max_trade_capital_usd,
kill_switch=controls.kill_switch,
alert_notifier=alert_notifier,
audit_repository=getattr(app.state, "audit_repository", None),
)
app.state.feed = feed
task = asyncio.create_task(feed.run(), name="market_data_feed")
app.state.feed_task = task
_LOG.info("market_data_feed_started", symbols=symbols)
return task
def create_app(settings: Settings) -> FastAPI:
configure_logging(settings.log_level, settings.log_json)
db = DuckDBStore(settings)
db.migrate()
db = PgStore(settings)
app = FastAPI(title="arbitrade", version="0.1.0")
kraken_client = KrakenRestClient(settings)
fee_sync_stop_event = asyncio.Event()
pairing_sync_stop_event = asyncio.Event()
backtest_queue: asyncio.Queue[tuple[str, str, dict[str, object] | None] | None] = (
asyncio.Queue()
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await app.state.store.start()
await app.state.store.migrate()
get_db_sink().start_consumer(db)
await app.state.configuration_service.load_database_settings()
await restore_runtime_state(app)
fee_sync_task = asyncio.create_task(
run_fee_sync_loop(
kraken_client,
db,
fee_sync_stop_event,
),
name="fee_sync_loop",
)
pairing_sync_task = asyncio.create_task(
run_pairing_sync_loop(
kraken_client,
db,
pairing_sync_stop_event,
),
name="pairing_sync_loop",
)
backtest_task = asyncio.create_task(
backtest_worker(backtest_queue, db), # type: ignore
name="backtest_worker",
)
# Start market data feed from enabled pairings
await _start_feed(app)
app.state.fee_sync_task = fee_sync_task
app.state.pairing_sync_task = pairing_sync_task
app.state.backtest_task = backtest_task
app.state.log_aggregation_task = asyncio.create_task(
run_log_aggregation_loop(db), name="log_aggregation"
)
app.state.log_archive_task = asyncio.create_task(
run_log_archive_loop(db), name="log_archive"
)
yield
fee_sync_stop_event.set()
pairing_sync_stop_event.set()
# Stop feed
feed = getattr(app.state, "feed", None)
if feed is not None:
ws_client = getattr(app.state, "ws_client", None)
if ws_client is not None:
await ws_client.stop()
ft = getattr(app.state, "feed_task", None)
if ft is not None:
ft.cancel()
try:
await ft
except asyncio.CancelledError:
pass
fee_sync_task.cancel()
try:
await fee_sync_task
except asyncio.CancelledError:
pass
pairing_sync_task.cancel()
try:
await pairing_sync_task
except asyncio.CancelledError:
pass
await backtest_queue.put(None) # poison pill
backtest_task.cancel()
try:
await backtest_task
except asyncio.CancelledError:
pass
await kraken_client.close()
await graceful_shutdown(app)
await app.state.store.stop()
await get_db_sink().stop_consumer()
app = FastAPI(title="arbitrade", version="0.1.0", lifespan=lifespan)
app.state.settings = settings
app.state.store = db
app.state.kraken_client = kraken_client
app.state.fee_sync_stop_event = fee_sync_stop_event
app.state.pairing_sync_stop_event = pairing_sync_stop_event
app.state.backtest_queue = backtest_queue
app.state.metrics = MetricsCalculator(db)
app.state.audit_repository = AuditRepository(db)
app.state.runtime_state_repository = RuntimeStateRepository(db)
app.state.alert_notifier = build_notifier_from_settings(settings)
svc = ConfigurationService(settings, db, app.state.audit_repository)
app.state.configuration_service = svc
app.state.backtest_recent_reports = []
app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active,
)
app.include_router(public_router)
app.include_router(router)
return app
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from secrets import compare_digest
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
dashboard_basic_auth = HTTPBasic(auto_error=False)
def require_dashboard_auth(
request: Request,
credentials: Annotated[HTTPBasicCredentials | None, Depends(dashboard_basic_auth)],
) -> None:
settings = request.app.state.settings
username = settings.dashboard_auth_username
password = settings.dashboard_auth_password
if username is None and password is None:
return
if username is None or password is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Dashboard auth misconfigured",
)
if (
credentials is None
or not compare_digest(credentials.username, username)
or not compare_digest(credentials.password, password)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": 'Basic realm="Arbitrade Dashboard"'},
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from arbitrade.risk.kill_switch import KillSwitch
@dataclass(slots=True)
class DashboardControlState:
is_running: bool = True
kill_switch: KillSwitch = field(default_factory=KillSwitch)
tradable_pairs: list[str] = field(default_factory=list)
strategy_mode: str = "incremental"
strategy_profit_threshold: float = 0.0005
strategy_max_depth_levels: int = 10
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def mark_updated(self) -> None:
self.updated_at = datetime.now(UTC)
+465 -9
View File
@@ -1,28 +1,484 @@
from __future__ import annotations
import json
from asyncio import Lock
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from importlib import resources
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
from arbitrade.api.auth import require_dashboard_auth
from arbitrade.config.pairing_sync import sync_pairings_from_kraken
from arbitrade.dashboard.context import (
_backtesting_panel_context,
_recent_backtest_reports,
)
from arbitrade.dashboard.dashboard import (
_alert_status_snapshot,
_dashboard_audit,
_dashboard_backtesting_handler,
_dashboard_backtesting_job_export,
_dashboard_backtesting_job_handler,
_dashboard_charts,
_dashboard_config_context,
_dashboard_controls,
_dashboard_ctl_cfg,
_dashboard_ctl_kill,
_dashboard_ctl_start,
_dashboard_ctl_stop,
_dashboard_metrics,
_dashboard_overview,
_dashboard_pairings_response,
_dashboard_response,
_pairing_repo,
_toggle_pairing,
)
from arbitrade.storage.repositories import (
BacktestJobRepository,
ConfigPairingRepository,
LogRepository,
)
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
public_router = APIRouter()
def _resolve_templates_directory() -> str:
# Support source layout, Docker runtime (/app), and installed package data.
source_layout_path = Path(__file__).resolve().parents[3] / "web" / "templates"
if source_layout_path.is_dir():
return str(source_layout_path)
docker_runtime_path = Path.cwd() / "web" / "templates"
if docker_runtime_path.is_dir():
return str(docker_runtime_path)
try:
package_path = resources.files("arbitrade").joinpath("web", "templates")
if package_path.is_dir():
return str(package_path)
except (ModuleNotFoundError, AttributeError):
pass
return str(source_layout_path)
templates = Jinja2Templates(directory=_resolve_templates_directory())
_BACKTEST_ROOT = Path(__file__).resolve().parents[3]
_BACKTEST_RUN_LOCK = Lock()
async def _health_response(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="health.html",
context={"title": "Arbitrade Health Check"},
)
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
return await _dashboard_response(request)
@router.get("/dashboard/health", response_class=HTMLResponse)
async def dashboard_health_page(request: Request) -> HTMLResponse:
return await _health_response(request)
@router.get("/dashboard/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="health.html",
name="backtesting.html",
context={
"status": "ok",
"time": datetime.now(UTC).isoformat(),
"title": "Arbitrade Health",
"title": "Arbitrade Backtesting",
"request": request,
"panel_endpoint": "/dashboard/fragment/backtesting",
"dashboard_endpoint": "/dashboard",
},
)
@router.get("/health", response_class=JSONResponse)
@router.get("/dashboard/fragment/backtesting", response_class=HTMLResponse)
async def dashboard_backtesting_fragment(request: Request) -> HTMLResponse:
ctx = await _backtesting_panel_context(request)
ctx["flash_message"] = ""
# Check if any pairings are enabled
repo = ConfigPairingRepository(request.app.state.store)
enabled = await repo.list_pairings(enabled_only=True)
ctx["no_enabled_pairings"] = len(enabled) == 0
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **ctx},
)
@router.get("/dashboard/fragment/backtesting-pairings", response_class=HTMLResponse)
async def dashboard_backtesting_pairings_fragment(request: Request) -> HTMLResponse:
"""HTMX fragment: pairing checkboxes for backtest form."""
store = request.app.state.store
repo = ConfigPairingRepository(store)
pairings = await repo.list_pairings()
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/backtesting_pairings.html",
context={"request": request, "pairings": pairings},
)
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
async def dashboard_metrics(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/metrics.html",
context={"request": request, **(await _dashboard_metrics(request))},
)
@router.get("/dashboard/fragment/overview", response_class=HTMLResponse)
async def dashboard_overview(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/overview.html",
context={"request": request, **(await _dashboard_overview(request))},
)
@router.get("/dashboard/fragment/controls", response_class=HTMLResponse)
async def dashboard_controls(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.get("/dashboard/fragment/charts", response_class=HTMLResponse)
async def dashboard_charts(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/charts.html",
context={"request": request, **(await _dashboard_charts(request))},
)
@router.get("/dashboard/audit", response_class=HTMLResponse)
async def dashboard_audit_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="audit.html",
context={
"title": "Arbitrade Audit Trail",
"request": request,
**(await _dashboard_audit(request)),
},
)
@router.get("/dashboard/audit/fragment", response_class=HTMLResponse)
async def dashboard_audit_fragment(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/audit.html",
context={"request": request, **(await _dashboard_audit(request))},
)
@router.get("/dashboard/fragment/audit", response_class=HTMLResponse)
async def dashboard_audit(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/audit.html",
context={"request": request, **(await _dashboard_audit(request))},
)
@router.get("/dashboard/config/pairings", response_class=HTMLResponse)
async def dashboard_config_pairings_page(
request: Request,
search: str | None = None,
enabled: str | None = None,
) -> HTMLResponse:
"""Standalone pairings management page."""
return templates.TemplateResponse(
request=request,
name="pairings.html",
context={
"title": "Currency Pairings",
"request": request,
"search": search or "",
"enabled": enabled or "all",
},
)
@router.get("/dashboard/config", response_class=HTMLResponse)
async def dashboard_config_page(request: Request) -> HTMLResponse:
d_context = await _dashboard_config_context(request)
return templates.TemplateResponse(
request=request,
name="config.html",
context={
"title": "Arbitrade Configuration",
"request": request,
"config_endpoint": "/dashboard/control/config",
**d_context,
},
)
@router.get("/dashboard/fragment/config", response_class=HTMLResponse)
async def dashboard_config_fragment(request: Request) -> HTMLResponse:
d_context = await _dashboard_config_context(request)
return templates.TemplateResponse(
request=request,
name="partials/config.html",
context={"request": request, **d_context},
)
@router.get("/dashboard/api/alerts/status", response_class=JSONResponse)
async def dashboard_alert_status(request: Request) -> JSONResponse:
return JSONResponse(_alert_status_snapshot(request))
@router.get("/dashboard/api/audit/recent", response_class=JSONResponse)
async def dashboard_audit_recent(request: Request) -> JSONResponse:
return JSONResponse(await _dashboard_audit(request, limit=25))
@router.get("/dashboard/api/backtesting/reports", response_class=JSONResponse)
async def dashboard_backtesting_reports(request: Request) -> JSONResponse:
return JSONResponse(
{
"generated_at": datetime.now(UTC).isoformat(),
"reports": await _recent_backtest_reports(request),
}
)
@router.post("/dashboard/backtesting/run", response_class=HTMLResponse)
async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
"""Submit a backtest job to the async queue. Returns panel with job list."""
return await _dashboard_backtesting_handler(request)
@router.post("/dashboard/backtesting/job/{job_id}/delete", response_class=HTMLResponse)
async def dashboard_backtesting_delete(request: Request, job_id: str) -> HTMLResponse:
store = request.app.state.store
repo = BacktestJobRepository(store)
context = await _backtesting_panel_context(request)
await repo.delete_job(job_id)
return templates.TemplateResponse(
request=request,
name="partials/backtesting_panel.html",
context={"request": request, **context},
)
@router.get("/dashboard/backtesting/job/{job_id}", response_class=HTMLResponse)
async def dashboard_backtesting_job_detail(request: Request, job_id: str) -> HTMLResponse:
return await _dashboard_backtesting_job_handler(request, job_id=job_id)
@router.get("/dashboard/backtesting/job/{job_id}/export", response_class=Response)
async def dashboard_backtesting_export(request: Request, job_id: str) -> Response:
return await _dashboard_backtesting_job_export(request, job_id=job_id)
@router.post("/dashboard/control/start", response_class=HTMLResponse)
async def dashboard_control_start(request: Request) -> HTMLResponse:
return await _dashboard_ctl_start(request)
@router.post("/dashboard/control/stop", response_class=HTMLResponse)
async def dashboard_control_stop(request: Request) -> HTMLResponse:
return await _dashboard_ctl_stop(request)
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
return await _dashboard_ctl_kill(request)
@router.post("/dashboard/control/config", response_class=HTMLResponse)
async def dashboard_control_config(request: Request) -> HTMLResponse:
return await _dashboard_ctl_cfg(request)
@router.get("/dashboard/stream/metrics")
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
metrics = await _dashboard_metrics(request)
fragment = (
templates.get_template("partials/metrics.html")
.render(
request=request,
**metrics,
)
.strip()
.replace("\n", "")
)
async def _event_stream() -> AsyncIterator[bytes]:
payload = json.dumps(fragment)
yield f"event: metrics\ndata: {payload}\n\n".encode()
return StreamingResponse(_event_stream(), media_type="text/event-stream")
@router.get("/dashboard/stream/overview")
async def dashboard_overview_stream(request: Request) -> StreamingResponse:
overview = await _dashboard_overview(request)
fragment = (
templates.get_template("partials/overview.html")
.render(request=request, **overview)
.strip()
.replace("\n", "")
)
async def _event_stream() -> AsyncIterator[bytes]:
payload = json.dumps(fragment)
yield f"event: overview\ndata: {payload}\n\n".encode()
return StreamingResponse(_event_stream(), media_type="text/event-stream")
@public_router.get("/health", response_class=JSONResponse)
async def health() -> JSONResponse:
return JSONResponse({"status": "ok", "service": "arbitrade"})
# ── Pairing API ─────────────────────────────────────────────────────────────
@router.get("/dashboard/api/pairings", response_class=JSONResponse)
async def dashboard_api_pairings(
request: Request,
search: str | None = None,
enabled: str | None = None,
source: str | None = None,
base: str | None = None,
quote: str | None = None,
sort: str = "base_asset",
order: str = "asc",
) -> JSONResponse:
"""List pairings with optional filters."""
return await _dashboard_pairings_response(
request,
search=search,
enabled=enabled,
source=source,
base=base,
quote=quote,
sort=sort,
order=order,
)
@router.get("/dashboard/fragment/pairings", response_class=HTMLResponse)
async def dashboard_pairings_fragment(
request: Request,
search: str | None = None,
enabled: str | None = None,
) -> HTMLResponse:
"""HTMX fragment: pairing table for config page."""
repo = _pairing_repo(request)
pairings = await repo.list_pairings()
# Apply search filter
if search:
sl = search.lower()
pairings = [
p for p in pairings if sl in p.base_asset.lower() or sl in p.quote_asset.lower()
]
if enabled is not None and enabled.lower() != "all":
eb = enabled.lower() == "true"
pairings = [p for p in pairings if p.enabled == eb]
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/pairings_table.html",
context={"request": request, "pairings": pairings},
)
@router.post("/dashboard/api/pairings/toggle")
async def dashboard_api_pairings_toggle(request: Request) -> HTMLResponse:
return await _toggle_pairing(request)
@router.post("/dashboard/api/pairings/sync", response_class=HTMLResponse)
async def dashboard_api_pairings_sync(request: Request) -> HTMLResponse:
"""Sync pairings from Kraken and return refreshed table."""
store = request.app.state.store
kraken = getattr(request.app.state, "kraken_client", None)
if kraken is not None:
await sync_pairings_from_kraken(kraken, store)
repo = _pairing_repo(request)
pairings = await repo.list_pairings()
pairings.sort(key=lambda p: (p.base_asset, p.quote_asset))
return templates.TemplateResponse(
request=request,
name="partials/pairings_table.html",
context={"request": request, "pairings": pairings},
)
# ── Log routes ──────────────────────────────────────────────────────────────
@router.get("/dashboard/fragment/logs", response_class=HTMLResponse)
async def dashboard_logs_fragment(
request: Request,
level: str | None = None,
page: int = 1,
per_page: int = 50,
) -> HTMLResponse:
"""HTMX fragment: log table for health page."""
repo = LogRepository(request.app.state.store)
offset = (page - 1) * per_page
records = await repo.query(level=level, limit=per_page, offset=offset)
total = await repo.count_filtered(level=level)
total_pages = max(1, (total + per_page - 1) // per_page)
return templates.TemplateResponse(
request=request,
name="partials/log_table.html",
context={
"request": request,
"records": records,
"page": page,
"total_pages": total_pages,
"total": total,
"current_level": level or "all",
},
)
@router.post("/dashboard/api/logging/aggregate", response_class=JSONResponse)
async def dashboard_logging_aggregate(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_aggregation
await run_log_aggregation(request.app.state.store)
return JSONResponse({"status": "ok"})
@router.post("/dashboard/api/logging/archive", response_class=JSONResponse)
async def dashboard_logging_archive(request: Request) -> JSONResponse:
from arbitrade.logging.maintenance import run_log_archive
count = await run_log_archive(request.app.state.store)
return JSONResponse({"status": "ok", "archived": count})
+35
View File
@@ -0,0 +1,35 @@
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
BacktestReport,
ReplayBookEvent,
ReplayClock,
load_replay_events,
)
from arbitrade.backtesting.sweep import (
PromotionCriteria,
SweepArtifacts,
SweepParameters,
SweepResult,
build_parameter_grid,
persist_sweep_results,
run_parameter_search,
split_events_time_windows,
)
__all__ = [
"ReplayClock",
"ReplayBookEvent",
"BacktestConfig",
"BacktestReport",
"BacktestReplayEngine",
"load_replay_events",
"SweepParameters",
"SweepResult",
"SweepArtifacts",
"PromotionCriteria",
"split_events_time_windows",
"build_parameter_grid",
"run_parameter_search",
"persist_sweep_results",
]
+410
View File
@@ -0,0 +1,410 @@
from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import orjson
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
from arbitrade.detection.graph import TriangularCycle
from arbitrade.exchange.models import BookLevel
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.trade_limits import TradeLimitsGuard
from arbitrade.storage.pg_store import PgStore
@dataclass(slots=True)
class ReplayClock:
_current: datetime
@classmethod
def at(cls, started_at: datetime) -> ReplayClock:
return cls(_current=started_at.astimezone(UTC))
@property
def now(self) -> datetime:
return self._current
def advance_to(self, next_time: datetime) -> None:
normalized = next_time.astimezone(UTC)
if normalized < self._current:
raise ValueError("Replay events must be monotonic by timestamp")
self._current = normalized
def advance_ms(self, milliseconds: float) -> None:
if milliseconds < 0.0:
raise ValueError("milliseconds must be >= 0")
self._current = self._current.fromtimestamp(
self._current.timestamp() + (milliseconds / 1000.0),
tz=UTC,
)
@dataclass(frozen=True, slots=True)
class ReplayBookEvent:
occurred_at: datetime
symbol: str
bids: tuple[BookLevel, ...]
asks: tuple[BookLevel, ...]
@dataclass(frozen=True, slots=True)
class BacktestConfig:
fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
min_profit_threshold: float = 0.0005
trade_capital: float = 100.0
quote_asset: str = "USD"
slippage_bps: float = 4.0
execution_latency_ms: float = 20.0
max_depth_levels: int = 10
max_concurrent_trades: int = 1
min_order_size_by_pair: Mapping[str, float] | None = None
@dataclass(frozen=True, slots=True)
class BacktestReport:
started_at: datetime
finished_at: datetime
processed_events: int
opportunities_seen: int
trades_executed: int
win_rate: float | None
fill_rate: float | None
realized_pnl_usd: float
max_drawdown_usd: float
miss_reasons: Mapping[str, int]
execution_latency_p50_ms: float | None
execution_latency_p95_ms: float | None
execution_latency_p99_ms: float | None
class _SimulatedRestClient:
def __init__(
self, clock: ReplayClock, *, slippage_bps: float, execution_latency_ms: float
) -> None:
self._clock = clock
self._slippage_bps = slippage_bps
self._execution_latency_ms = execution_latency_ms
self._sequence = 0
self._last_fill_ratio = 1.0
self._last_trade_latency_ms = execution_latency_ms
@property
def last_fill_ratio(self) -> float:
return self._last_fill_ratio
@property
def last_trade_latency_ms(self) -> float:
return self._last_trade_latency_ms
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, Any]:
self._sequence += 1
self._clock.advance_ms(self._execution_latency_ms)
await asyncio.sleep(0)
normalized_fill = max(0.85, 1.0 - (self._slippage_bps / 10000.0) * 8.0)
self._last_fill_ratio = normalized_fill
self._last_trade_latency_ms = self._execution_latency_ms
return {
"txid": [f"sim-{self._sequence}"],
"status": "closed",
"pair": pair,
"side": side,
"requested_volume": volume,
"filled_volume": volume * normalized_fill,
"simulated_at": self._clock.now.isoformat(),
}
def _percentile(values: Sequence[float], percentile: float) -> float | None:
if not values:
return None
ordered = sorted(values)
if percentile <= 0.0:
return ordered[0]
if percentile >= 100.0:
return ordered[-1]
rank = (len(ordered) - 1) * (percentile / 100.0)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
if not isinstance(raw_levels, list):
raise ValueError("Book levels must be a list")
levels: list[BookLevel] = []
for raw_level in raw_levels:
if (
not isinstance(raw_level, list)
or len(raw_level) != 2
or not isinstance(raw_level[0], int | float)
or not isinstance(raw_level[1], int | float)
):
raise ValueError("Each level must be [price, volume]")
levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
return tuple(levels)
def load_replay_events(path: Path) -> list[ReplayBookEvent]:
events: list[ReplayBookEvent] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parsed = orjson.loads(line)
if not isinstance(parsed, dict):
raise ValueError("Each JSONL row must be an object")
timestamp_raw = parsed.get("timestamp")
symbol_raw = parsed.get("symbol")
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
raise ValueError("Each event must include timestamp and symbol")
occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
events.append(
ReplayBookEvent(
occurred_at=occurred_at,
symbol=symbol_raw,
bids=_parse_book_levels(parsed.get("bids")),
asks=_parse_book_levels(parsed.get("asks")),
)
)
return sorted(events, key=lambda event: event.occurred_at)
async def load_replay_events_from_db(
store: PgStore,
*,
symbols: list[str] | None = None,
start: datetime | None = None,
end: datetime | None = None,
) -> list[ReplayBookEvent]:
"""Load replay events from market_snapshots table.
Each market_snapshots row has snapshot_at, symbol, payload (raw Kraken WS).
Payload format: {channel, symbol, data: [{bids: [{price, qty}], asks: [{price, qty}]}]}
"""
async with store.pool.acquire() as conn:
query = "SELECT snapshot_at, symbol, payload FROM market_snapshots WHERE 1=1"
params: list[object] = []
if symbols:
placeholders = ",".join(f"${i+1}" for i in range(len(symbols)))
query += f" AND symbol IN ({placeholders})"
params.extend(symbols)
if start is not None:
params.append(start)
query += f" AND snapshot_at >= ${len(params)}"
if end is not None:
params.append(end)
query += f" AND snapshot_at <= ${len(params)}"
query += " ORDER BY snapshot_at ASC"
rows = await conn.fetch(query, *params)
events: list[ReplayBookEvent] = []
for row in rows:
snapshot_at: datetime = row["snapshot_at"]
symbol: str = row["symbol"]
payload_raw = row["payload"]
if isinstance(payload_raw, str):
payload = orjson.loads(payload_raw)
elif isinstance(payload_raw, dict):
payload = payload_raw
else:
continue
data = payload.get("data")
if not isinstance(data, list) or not data:
continue
first = data[0]
if not isinstance(first, dict):
continue
bids = _parse_kraken_book_levels(first.get("bids"))
asks = _parse_kraken_book_levels(first.get("asks"))
if bids or asks:
events.append(
ReplayBookEvent(
occurred_at=snapshot_at,
symbol=symbol,
bids=bids,
asks=asks,
)
)
return events
def _parse_kraken_book_levels(
raw_levels: object | None,
) -> tuple[BookLevel, ...]:
"""Parse Kraken WS book level format: [{price, qty}, ...]."""
if not isinstance(raw_levels, list):
return ()
levels: list[BookLevel] = []
for level in raw_levels:
if isinstance(level, dict) and "price" in level and "qty" in level:
levels.append(BookLevel(price=float(level["price"]), volume=float(level["qty"])))
return tuple(levels)
class BacktestReplayEngine:
def __init__(
self,
*,
cycles_by_pair: Mapping[str, list[TriangularCycle]],
available_pairs: Sequence[str],
config: BacktestConfig,
started_at: datetime,
) -> None:
self._config = config
self._clock = ReplayClock.at(started_at)
self._books: dict[str, OrderBook] = {}
self._detector = IncrementalCycleDetector(
cycles_by_pair,
fee_rate=config.fee_rate,
max_depth_levels=config.max_depth_levels,
min_profit_threshold=config.min_profit_threshold,
min_order_size_by_pair=config.min_order_size_by_pair,
)
self._pre_trade = PreTradeValidator()
self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
self._simulated_rest = _SimulatedRestClient(
self._clock,
slippage_bps=config.slippage_bps,
execution_latency_ms=config.execution_latency_ms,
)
self._sequencer = TriangularExecutionSequencer(
self._simulated_rest,
available_pairs=available_pairs,
)
@staticmethod
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
currencies = [part for part in event.cycle.split("->") if part]
if len(currencies) < 2:
return {}
origin = currencies[0]
return {
currency: event.allocated_capital for currency in currencies[1:] if currency != origin
}
async def run(
self,
events: Sequence[ReplayBookEvent],
*,
starting_balances: Mapping[str, float],
) -> BacktestReport:
miss_reasons: Counter[str] = Counter()
processed_events = 0
opportunities_seen = 0
trades_executed = 0
realized_pnl = 0.0
equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
peak_equity = equity
max_drawdown = 0.0
fill_samples: list[float] = []
realized_samples: list[float] = []
execution_latencies: list[float] = []
for event in events:
self._clock.advance_to(event.occurred_at)
processed_events += 1
book = self._books.setdefault(event.symbol.upper(), OrderBook())
book.apply_bids(event.bids)
book.apply_asks(event.asks)
opportunities = self._detector.opportunities_for_updated_pair(
event.symbol,
self._books,
base_capital=self._config.trade_capital,
)
opportunities_seen += len(opportunities)
for opportunity in opportunities:
required_by_asset = {
self._config.quote_asset.upper(): opportunity.allocated_capital
}
if not self._pre_trade.validate(
balances_by_asset=starting_balances,
required_by_asset=required_by_asset,
):
miss_reasons["insufficient_balance"] += 1
continue
exposure = self._exposure_for_event(opportunity)
if not self._trade_limits.is_trade_allowed(exposure):
miss_reasons["trade_limit"] += 1
continue
self._trade_limits.open_trade(exposure)
result = await self._sequencer.execute(opportunity)
self._trade_limits.close_trade(exposure)
execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
fill_samples.append(self._simulated_rest.last_fill_ratio)
if not result.success:
miss_reasons["execution_failed"] += 1
continue
slippage_cost = (
opportunity.allocated_capital
* (self._config.slippage_bps / 10000.0)
* max(result.completed_legs, 1)
)
realized_trade_pnl = opportunity.est_profit - slippage_cost
realized_samples.append(realized_trade_pnl)
realized_pnl += realized_trade_pnl
equity += realized_trade_pnl
peak_equity = max(peak_equity, equity)
max_drawdown = max(max_drawdown, peak_equity - equity)
trades_executed += 1
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
win_rate = (wins / len(realized_samples)) if realized_samples else None
fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
return BacktestReport(
started_at=events[0].occurred_at if events else self._clock.now,
finished_at=events[-1].occurred_at if events else self._clock.now,
processed_events=processed_events,
opportunities_seen=opportunities_seen,
trades_executed=trades_executed,
win_rate=win_rate,
fill_rate=fill_rate,
realized_pnl_usd=realized_pnl,
max_drawdown_usd=max_drawdown,
miss_reasons=dict(miss_reasons),
execution_latency_p50_ms=_percentile(execution_latencies, 50.0),
execution_latency_p95_ms=_percentile(execution_latencies, 95.0),
execution_latency_p99_ms=_percentile(execution_latencies, 99.0),
)
+171
View File
@@ -0,0 +1,171 @@
"""Async backtest job runner — picks up pending jobs from DB and executes them."""
from __future__ import annotations
import asyncio
from datetime import datetime
from pathlib import Path
import structlog
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
load_replay_events,
load_replay_events_from_db,
)
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import BacktestJobRepository
_LOG = structlog.get_logger(__name__)
def _build_cycles_from_events(
symbols: set[str],
) -> tuple[dict[str, list[TriangularCycle]], list[str]]:
graph = CurrencyGraph()
for symbol in sorted(symbols):
if "/" not in symbol:
continue
base, quote = symbol.upper().split("/", 1)
graph.add_pair(base, quote, f"{base}/{quote}")
cycles = graph.triangular_cycles()
return graph.index_cycles_by_pair(cycles), sorted(symbols)
def _parse_balances(raw: str) -> dict[str, float]:
balances: dict[str, float] = {}
for entry in raw.split(","):
stripped = entry.strip()
if not stripped:
continue
if "=" not in stripped:
continue
asset, value = stripped.split("=", 1)
balances[asset.strip().upper()] = float(value)
return balances or {"USD": 1000.0}
async def run_backtest_job(
job_id: str,
config_dict: dict[str, object] | None,
store: PgStore,
) -> None:
"""Execute a single backtest job: load events from DB or file, run engine, store report."""
repo = BacktestJobRepository(store)
await repo.update_status(job_id, "running")
_LOG.info("backtest_job_started", job_id=job_id)
try:
config = config_dict or {}
events_path = str(config.get("events_path", ""))
symbols_raw = config.get("symbols")
source = str(config.get("source", "db"))
start_dt = None
end_dt = None
if source == "db":
start_str = config.get("start_time")
end_str = config.get("end_time")
if isinstance(start_str, str) and start_str:
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
if isinstance(end_str, str) and end_str:
end_dt = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
symbols: list[str] | None = None
if isinstance(symbols_raw, str) and symbols_raw.strip():
symbols = [s.strip().upper() for s in symbols_raw.split(",") if s.strip()]
elif isinstance(symbols_raw, list):
symbols = [str(s).upper() for s in symbols_raw]
events = await load_replay_events_from_db(
store,
symbols=symbols,
start=start_dt,
end=end_dt,
)
else:
path = Path(events_path)
if not path.is_absolute():
path = Path("data") / path
path = path.resolve()
events = load_replay_events(path)
if not events:
raise ValueError("No events found for backtest")
starting_balances_raw = str(config.get("starting_balances", "USD=1000.0"))
starting_balances = _parse_balances(starting_balances_raw)
fee_rate = float(config.get("fee_rate", 0.0026)) # type: ignore
trade_capital = float(config.get("trade_capital", 100.0)) # type: ignore
min_profit_threshold = float(config.get("min_profit_threshold", 0.0005)) # type: ignore
slippage_bps = float(config.get("slippage_bps", 4.0)) # type: ignore
execution_latency_ms = float(config.get("execution_latency_ms", 20.0)) # type: ignore
cycles_by_pair, available_pairs = _build_cycles_from_events(
{e.symbol.upper() for e in events}
)
if not cycles_by_pair:
raise ValueError("No triangular cycles found in event data")
bt_config = BacktestConfig(
fee_rate=fee_rate,
min_profit_threshold=min_profit_threshold,
trade_capital=trade_capital,
slippage_bps=slippage_bps,
execution_latency_ms=execution_latency_ms,
)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=bt_config,
started_at=events[0].occurred_at,
)
report = await engine.run(events, starting_balances=starting_balances)
report_dict = {
"processed_events": report.processed_events,
"opportunities_seen": report.opportunities_seen,
"trades_executed": report.trades_executed,
"win_rate": report.win_rate,
"fill_rate": report.fill_rate,
"realized_pnl_usd": report.realized_pnl_usd,
"max_drawdown_usd": report.max_drawdown_usd,
"execution_latency_p50_ms": report.execution_latency_p50_ms,
"execution_latency_p95_ms": report.execution_latency_p95_ms,
"execution_latency_p99_ms": report.execution_latency_p99_ms,
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
}
await repo.store_report(job_id, report_dict)
await repo.update_status(job_id, "completed")
_LOG.info("backtest_job_completed", job_id=job_id, pnl=report.realized_pnl_usd)
except Exception as exc:
await repo.update_status(job_id, "failed", error=str(exc))
_LOG.exception("backtest_job_failed", job_id=job_id, error=str(exc))
async def backtest_worker(
queue: asyncio.Queue[tuple[str, dict[str, object] | None] | None],
store: PgStore,
) -> None:
"""Worker coroutine: pull jobs from queue and execute them one at a time."""
_LOG.info("backtest_worker_started")
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
job_id, config = item
try:
await run_backtest_job(job_id, config, store)
except Exception:
_LOG.exception("backtest_worker_unhandled_error", job_id=job_id)
finally:
queue.task_done()
_LOG.info("backtest_worker_stopped")
+391
View File
@@ -0,0 +1,391 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import orjson
from arbitrade.backtesting.replay import (
BacktestConfig,
BacktestReplayEngine,
BacktestReport,
ReplayBookEvent,
)
from arbitrade.detection.graph import TriangularCycle
@dataclass(frozen=True, slots=True)
class SweepParameters:
min_profit_threshold: float
trade_capital: float
pair_universe: tuple[str, ...]
staleness_threshold_seconds: float
@dataclass(frozen=True, slots=True)
class PromotionCriteria:
min_test_realized_pnl_usd: float = 0.0
min_test_win_rate: float = 0.5
min_test_fill_rate: float = 0.9
max_test_drawdown_usd: float = 25.0
max_generalization_gap_ratio: float = 0.5
@dataclass(frozen=True, slots=True)
class SweepResult:
parameters: SweepParameters
train_report: BacktestReport
test_report: BacktestReport
train_score: float
test_score: float
generalization_gap_ratio: float
overfit_detected: bool
promotion_ready: bool
promotion_reasons: tuple[str, ...]
train_event_count: int
test_event_count: int
@dataclass(frozen=True, slots=True)
class SweepArtifacts:
results: tuple[SweepResult, ...]
promoted: tuple[SweepResult, ...]
train_window: tuple[datetime, datetime] | None
test_window: tuple[datetime, datetime] | None
def split_events_time_windows(
events: Sequence[ReplayBookEvent],
*,
train_ratio: float,
) -> tuple[list[ReplayBookEvent], list[ReplayBookEvent]]:
if train_ratio <= 0.0 or train_ratio >= 1.0:
raise ValueError("train_ratio must be between 0 and 1")
if len(events) < 2:
raise ValueError("at least two events are required for time split")
split_index = max(1, min(len(events) - 1, int(len(events) * train_ratio)))
return list(events[:split_index]), list(events[split_index:])
def build_parameter_grid(
*,
theta_values: Sequence[float],
trade_capital_values: Sequence[float],
pair_universes: Sequence[Sequence[str]],
staleness_threshold_values: Sequence[float],
) -> list[SweepParameters]:
if not theta_values:
raise ValueError("theta_values must not be empty")
if not trade_capital_values:
raise ValueError("trade_capital_values must not be empty")
if not pair_universes:
raise ValueError("pair_universes must not be empty")
if not staleness_threshold_values:
raise ValueError("staleness_threshold_values must not be empty")
grid: list[SweepParameters] = []
for theta in theta_values:
for trade_capital in trade_capital_values:
for pair_universe in pair_universes:
normalized_universe = tuple(sorted({pair.upper() for pair in pair_universe}))
for staleness_threshold in staleness_threshold_values:
grid.append(
SweepParameters(
min_profit_threshold=float(theta),
trade_capital=float(trade_capital),
pair_universe=normalized_universe,
staleness_threshold_seconds=float(staleness_threshold),
)
)
return grid
def _filter_events_for_parameters(
events: Sequence[ReplayBookEvent],
*,
pair_universe: set[str],
staleness_threshold_seconds: float,
) -> list[ReplayBookEvent]:
if staleness_threshold_seconds <= 0.0:
raise ValueError("staleness_threshold_seconds must be > 0")
filtered: list[ReplayBookEvent] = []
last_seen_by_symbol: dict[str, datetime] = {}
for event in events:
symbol = event.symbol.upper()
if symbol not in pair_universe:
continue
previous = last_seen_by_symbol.get(symbol)
last_seen_by_symbol[symbol] = event.occurred_at
if previous is None:
filtered.append(event)
continue
gap_seconds = (event.occurred_at - previous).total_seconds()
if gap_seconds <= staleness_threshold_seconds:
filtered.append(event)
return filtered
def _restrict_cycles_by_pair(
cycles_by_pair: Mapping[str, list[TriangularCycle]],
*,
pair_universe: set[str],
) -> dict[str, list[TriangularCycle]]:
restricted: dict[str, list[TriangularCycle]] = {}
for pair_symbol, cycles in cycles_by_pair.items():
normalized_pair = pair_symbol.upper()
if normalized_pair not in pair_universe:
continue
kept = [
cycle for cycle in cycles if all(pair.upper() in pair_universe for pair in cycle.pairs)
]
if kept:
restricted[normalized_pair] = kept
return restricted
def _score_report(report: BacktestReport) -> float:
win_rate_bonus = (report.win_rate or 0.0) * 100.0
fill_rate_bonus = (report.fill_rate or 0.0) * 50.0
return report.realized_pnl_usd + win_rate_bonus + fill_rate_bonus - report.max_drawdown_usd
def _safe_ratio(numerator: float, denominator: float) -> float:
if denominator <= 0.0:
return 0.0 if numerator <= 0.0 else 1.0
return max(0.0, numerator / denominator)
def _evaluate_promotion(
*,
result: SweepResult,
criteria: PromotionCriteria,
) -> tuple[bool, tuple[str, ...]]:
reasons: list[str] = []
test = result.test_report
if test.realized_pnl_usd < criteria.min_test_realized_pnl_usd:
reasons.append("test_realized_pnl_below_threshold")
if (test.win_rate or 0.0) < criteria.min_test_win_rate:
reasons.append("test_win_rate_below_threshold")
if (test.fill_rate or 0.0) < criteria.min_test_fill_rate:
reasons.append("test_fill_rate_below_threshold")
if test.max_drawdown_usd > criteria.max_test_drawdown_usd:
reasons.append("test_drawdown_above_threshold")
if result.generalization_gap_ratio > criteria.max_generalization_gap_ratio:
reasons.append("generalization_gap_above_threshold")
return (not reasons), tuple(reasons)
def _run_backtest(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
available_pairs: Sequence[str],
config: BacktestConfig,
starting_balances: Mapping[str, float],
) -> BacktestReport:
started_at = events[0].occurred_at if events else datetime.now(UTC)
engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs,
config=config,
started_at=started_at,
)
return asyncio.run(engine.run(events, starting_balances=starting_balances))
def run_parameter_search(
*,
events: Sequence[ReplayBookEvent],
cycles_by_pair: Mapping[str, list[TriangularCycle]],
parameter_grid: Sequence[SweepParameters],
starting_balances: Mapping[str, float],
train_ratio: float,
promotion_criteria: PromotionCriteria | None = None,
max_concurrent_trades: int = 1,
max_depth_levels: int = 10,
quote_asset: str = "USD",
) -> SweepArtifacts:
criteria = promotion_criteria or PromotionCriteria()
train_events, test_events = split_events_time_windows(events, train_ratio=train_ratio)
results: list[SweepResult] = []
promoted: list[SweepResult] = []
for parameters in parameter_grid:
allowed_pairs = set(parameters.pair_universe)
filtered_train = _filter_events_for_parameters(
train_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
filtered_test = _filter_events_for_parameters(
test_events,
pair_universe=allowed_pairs,
staleness_threshold_seconds=parameters.staleness_threshold_seconds,
)
if not filtered_train or not filtered_test:
continue
restricted_cycles = _restrict_cycles_by_pair(
cycles_by_pair,
pair_universe=allowed_pairs,
)
if not restricted_cycles:
continue
config = BacktestConfig(
min_profit_threshold=parameters.min_profit_threshold,
trade_capital=parameters.trade_capital,
max_concurrent_trades=max_concurrent_trades,
max_depth_levels=max_depth_levels,
quote_asset=quote_asset,
)
train_report = _run_backtest(
events=filtered_train,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
test_report = _run_backtest(
events=filtered_test,
cycles_by_pair=restricted_cycles,
available_pairs=sorted(allowed_pairs),
config=config,
starting_balances=starting_balances,
)
train_score = _score_report(train_report)
test_score = _score_report(test_report)
score_drop = max(0.0, train_score - test_score)
generalization_gap_ratio = _safe_ratio(score_drop, abs(train_score))
overfit_detected = generalization_gap_ratio > criteria.max_generalization_gap_ratio
base_result = SweepResult(
parameters=parameters,
train_report=train_report,
test_report=test_report,
train_score=train_score,
test_score=test_score,
generalization_gap_ratio=generalization_gap_ratio,
overfit_detected=overfit_detected,
promotion_ready=False,
promotion_reasons=(),
train_event_count=len(filtered_train),
test_event_count=len(filtered_test),
)
promotion_ready, promotion_reasons = _evaluate_promotion(
result=base_result, criteria=criteria
)
completed_result = SweepResult(
parameters=base_result.parameters,
train_report=base_result.train_report,
test_report=base_result.test_report,
train_score=base_result.train_score,
test_score=base_result.test_score,
generalization_gap_ratio=base_result.generalization_gap_ratio,
overfit_detected=base_result.overfit_detected,
promotion_ready=promotion_ready,
promotion_reasons=promotion_reasons,
train_event_count=base_result.train_event_count,
test_event_count=base_result.test_event_count,
)
results.append(completed_result)
if completed_result.promotion_ready:
promoted.append(completed_result)
results.sort(key=lambda item: item.test_score, reverse=True)
promoted.sort(key=lambda item: item.test_score, reverse=True)
train_window: tuple[datetime, datetime] | None = None
test_window: tuple[datetime, datetime] | None = None
if train_events:
train_window = (train_events[0].occurred_at, train_events[-1].occurred_at)
if test_events:
test_window = (test_events[0].occurred_at, test_events[-1].occurred_at)
return SweepArtifacts(
results=tuple(results),
promoted=tuple(promoted),
train_window=train_window,
test_window=test_window,
)
def _report_to_dict(report: BacktestReport) -> dict[str, object]:
return {
"started_at": report.started_at.isoformat(),
"finished_at": report.finished_at.isoformat(),
"processed_events": report.processed_events,
"opportunities_seen": report.opportunities_seen,
"trades_executed": report.trades_executed,
"win_rate": report.win_rate,
"fill_rate": report.fill_rate,
"realized_pnl_usd": report.realized_pnl_usd,
"max_drawdown_usd": report.max_drawdown_usd,
"miss_reasons": dict(report.miss_reasons),
"execution_latency_p50_ms": report.execution_latency_p50_ms,
"execution_latency_p95_ms": report.execution_latency_p95_ms,
"execution_latency_p99_ms": report.execution_latency_p99_ms,
}
def persist_sweep_results(path: Path, artifacts: SweepArtifacts) -> None:
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"train_window": (
{
"started_at": artifacts.train_window[0].isoformat(),
"finished_at": artifacts.train_window[1].isoformat(),
}
if artifacts.train_window is not None
else None
),
"test_window": (
{
"started_at": artifacts.test_window[0].isoformat(),
"finished_at": artifacts.test_window[1].isoformat(),
}
if artifacts.test_window is not None
else None
),
"results": [
{
"parameters": {
"min_profit_threshold": result.parameters.min_profit_threshold,
"trade_capital": result.parameters.trade_capital,
"pair_universe": list(result.parameters.pair_universe),
"staleness_threshold_seconds": result.parameters.staleness_threshold_seconds,
},
"train_report": _report_to_dict(result.train_report),
"test_report": _report_to_dict(result.test_report),
"train_score": result.train_score,
"test_score": result.test_score,
"generalization_gap_ratio": result.generalization_gap_ratio,
"overfit_detected": result.overfit_detected,
"promotion_ready": result.promotion_ready,
"promotion_reasons": list(result.promotion_reasons),
"train_event_count": result.train_event_count,
"test_event_count": result.test_event_count,
}
for result in artifacts.results
],
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS))
+79
View File
@@ -0,0 +1,79 @@
"""Sync available Kraken asset pairs into the config_pairings table."""
from __future__ import annotations
import asyncio
import structlog
from arbitrade.config.service import ConfigPairing
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import ConfigPairingRepository
_LOG = structlog.get_logger(__name__)
async def sync_pairings_from_kraken(
kraken_client: KrakenRestClient,
store: PgStore,
) -> dict[str, int]:
"""Fetch all asset pairs from Kraken and upsert into config_pairings.
Returns a summary dict with 'added', 'updated', 'total' counts.
"""
asset_pairs = await kraken_client.asset_pairs()
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
repo = ConfigPairingRepository(store)
added = 0
updated = 0
total = 0
# Dedupe: pair_by_direction has entries for both (base,quote) and (quote,base).
seen_symbols: set[str] = set()
for (base, quote), symbol in graph.pair_by_direction.items():
if symbol in seen_symbols:
continue
seen_symbols.add(symbol)
existing = await repo.get_pairing(base, quote)
pairing = ConfigPairing(
base_asset=base,
quote_asset=quote,
enabled=existing.enabled if existing else False,
source="kraken",
)
try:
await repo.upsert_pairing(pairing)
total += 1
if existing:
updated += 1
else:
added += 1
except Exception:
_LOG.warning("sync_pairing_failed", base=base, quote=quote)
_LOG.info(
"pairing_sync_complete",
added=added,
updated=updated,
total=total,
)
return {"added": added, "updated": updated, "total": total}
async def run_pairing_sync_loop(
kraken_client: KrakenRestClient,
store: PgStore,
stop_event: asyncio.Event,
interval_seconds: int = 86400,
) -> None:
"""Periodically sync pairings from Kraken (default daily)."""
await sync_pairings_from_kraken(kraken_client, store)
try:
while not stop_event.is_set():
await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
await sync_pairings_from_kraken(kraken_client, store)
except (TimeoutError, asyncio.CancelledError):
pass
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import orjson
from pydantic import BaseModel
from arbitrade.config.settings import Settings
from arbitrade.storage.pg_store import PgStore
class ConfigSection(BaseModel):
id: int | None = None
name: str
description: str | None = None
updated_at: datetime | None = None
class ConfigSetting(BaseModel):
key: str
section: str
value_json: str
value_type: str
is_secret: bool = False
is_runtime_reloadable: bool = False
updated_at: datetime | None = None
updated_by: str | None = None
class ConfigPairing(BaseModel):
id: int | None = None
base_asset: str
quote_asset: str
enabled: bool = True
source: str
created_at: datetime | None = None
updated_at: datetime | None = None
class ConfigBacktestingDefaults(BaseModel):
starting_balances: dict[str, float] | None = None
trade_capital: float | None = None
min_profit_threshold: float | None = None
slippage_bps: int | None = None
execution_latency_ms: int | None = None
class ConfigurationService:
"""Manages application configuration from environment and database sources."""
def __init__(self, settings: Settings, store: PgStore, audit_repo: Any) -> None:
self._settings = settings
self._store = store
self._audit_repo = audit_repo
self._config_version = 0
self._loaded_settings: dict[str, Any] = {}
self._last_updated_at: datetime | None = None
async def load_database_settings(self) -> None:
"""Load user settings from database and merge with defaults."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Load all settings from database
db_settings = await setting_repo.list_settings()
# Convert to dictionary for easy access
for setting in db_settings:
# Parse JSON value based on type
if setting.value_type == "str":
parsed_value = setting.value_json
elif setting.value_type == "int":
parsed_value = int(setting.value_json) # type: ignore
elif setting.value_type == "float":
parsed_value = float(setting.value_json) # type: ignore
elif setting.value_type == "bool":
parsed_value = setting.value_json.lower() == "true" # type: ignore
elif setting.value_type == "list":
parsed_value = orjson.loads(setting.value_json)
elif setting.value_type == "dict":
parsed_value = orjson.loads(setting.value_json)
else:
parsed_value = setting.value_json
self._loaded_settings[setting.key] = parsed_value
# Track the latest update time
if db_settings:
latest_updated = max(
setting.updated_at for setting in db_settings if setting.updated_at
)
self._last_updated_at = latest_updated
# Initialize with default values from settings model
self._initialize_default_settings()
def _initialize_default_settings(self) -> None:
"""Initialize default settings from the Settings model."""
# This is a placeholder - in a real implementation we'd map
# the Settings model fields to config keys
pass
def get_setting(self, key: str, default: Any = None) -> Any:
"""Get a configuration setting value."""
return self._loaded_settings.get(key, default)
def get_config_version(self) -> int:
"""Get the current configuration version for hot-reloading."""
return self._config_version
def get_last_updated_at(self) -> datetime | None:
"""Get the timestamp of the last configuration update."""
return self._last_updated_at
async def is_config_outdated(self) -> bool:
"""Check if configuration has been updated since last load."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Get the latest update timestamp from database
latest_db_update = await setting_repo.get_latest_updated_at()
# Compare with our last loaded timestamp
if latest_db_update and self._last_updated_at:
return latest_db_update > self._last_updated_at
elif latest_db_update:
return True
return False
async def reload_if_changed(self) -> bool:
"""Reload configuration if it has been updated in the database."""
if await self.is_config_outdated():
await self.load_database_settings()
self._config_version += 1
return True
return False
async def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None:
"""Set a configuration setting value and persist to database."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Convert value to JSON string and determine type
if isinstance(value, str):
value_json = value
value_type = "str"
elif isinstance(value, int):
value_json = str(value)
value_type = "int"
elif isinstance(value, float):
value_json = str(value)
value_type = "float"
elif isinstance(value, bool):
value_json = str(value).lower()
value_type = "bool"
elif isinstance(value, list):
value_json = orjson.dumps(value).decode("utf-8")
value_type = "list"
elif isinstance(value, dict):
value_json = orjson.dumps(value).decode("utf-8")
value_type = "dict"
else:
value_json = str(value)
value_type = "str"
# Create or update setting
setting = ConfigSetting(
key=key,
section="general", # Default section
value_json=value_json,
value_type=value_type,
is_secret=False,
is_runtime_reloadable=False,
updated_by=updated_by,
)
# Check if setting exists
existing_setting = await setting_repo.get_setting(key)
if existing_setting:
# Update existing setting
updated_setting = await setting_repo.update_setting(key, setting)
else:
# Create new setting
updated_setting = await setting_repo.create_setting(setting)
# Update in-memory cache
self._loaded_settings[key] = value
# Update version for hot reloading
self._config_version += 1
# Update last updated timestamp
self._last_updated_at = updated_setting.updated_at
def get_all_settings(self) -> dict[str, Any]:
"""Get all configuration settings."""
return self._loaded_settings.copy()
# --- Pairing & Fee Management ---
def _pairing_repo(self): # type: ignore
from arbitrade.storage.repositories import ConfigPairingRepository
return ConfigPairingRepository(self._store)
async def list_pairings(self) -> list[ConfigPairing]:
"""List all currency pairings."""
r = self._pairing_repo() # type: ignore[no-untyped-call]
p = await r.list_pairings()
return p # type: ignore[no-any-return]
async def create_pairing(
self, base_asset: str, quote_asset: str, source: str = "manual"
) -> ConfigPairing:
"""Create a new currency pairing."""
r = self._pairing_repo() # type: ignore[no-untyped-call]
e = await r.get_pairing(base_asset, quote_asset)
if e:
return e # type: ignore[no-any-return]
pairing = ConfigPairing(
base_asset=base_asset, quote_asset=quote_asset, enabled=True, source=source
)
p = r.create_pairing(pairing)
return p # type: ignore[no-any-return]
+164 -5
View File
@@ -1,26 +1,185 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_ignore_empty=True,
)
app_env: str = Field(default="dev", alias="APP_ENV")
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
app_port: int = Field(default=8000, alias="APP_PORT")
app_port: int = Field(default=9090, alias="APP_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_json: bool = Field(default=True, alias="LOG_JSON")
duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
dashboard_auth_username: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_USERNAME",
)
dashboard_auth_password: str | None = Field(
default=None,
alias="DASHBOARD_AUTH_PASSWORD",
)
alerts_enabled: bool = Field(default=True, alias="ALERTS_ENABLED")
alert_min_severity: str = Field(default="warning", alias="ALERT_MIN_SEVERITY")
alert_dedup_seconds: float = Field(default=30.0, alias="ALERT_DEDUP_SECONDS")
alert_on_trade_events: bool = Field(default=True, alias="ALERT_ON_TRADE_EVENTS")
alert_on_error_events: bool = Field(default=True, alias="ALERT_ON_ERROR_EVENTS")
alert_on_threshold_events: bool = Field(default=True, alias="ALERT_ON_THRESHOLD_EVENTS")
alert_on_system_events: bool = Field(default=True, alias="ALERT_ON_SYSTEM_EVENTS")
telegram_alerts_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTS_ENABLED")
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
discord_alerts_enabled: bool = Field(default=False, alias="DISCORD_ALERTS_ENABLED")
discord_webhook_url: str | None = Field(default=None, alias="DISCORD_WEBHOOK_URL")
email_alerts_enabled: bool = Field(default=False, alias="EMAIL_ALERTS_ENABLED")
email_smtp_host: str | None = Field(default=None, alias="EMAIL_SMTP_HOST")
email_smtp_port: int = Field(default=587, alias="EMAIL_SMTP_PORT")
email_smtp_username: str | None = Field(default=None, alias="EMAIL_SMTP_USERNAME")
email_smtp_password: str | None = Field(default=None, alias="EMAIL_SMTP_PASSWORD")
email_alert_from: str | None = Field(default=None, alias="EMAIL_ALERT_FROM")
email_alert_to: str | None = Field(default=None, alias="EMAIL_ALERT_TO")
email_smtp_use_tls: bool = Field(default=True, alias="EMAIL_SMTP_USE_TLS")
# PostgreSQL connection settings
pg_host: str = Field(default="192.168.88.35", alias="PG_HOST")
pg_port: int = Field(default=5432, alias="PG_PORT")
pg_database: str = Field(default="arbitrade", alias="PG_DATABASE")
pg_user: str = Field(default="arbitrade", alias="PG_USER")
pg_password: str = Field(default="arbitrade", alias="PG_PASSWORD")
pg_min_connections: int = Field(default=2, alias="PG_MIN_CONNECTIONS")
pg_max_connections: int = Field(default=10, alias="PG_MAX_CONNECTIONS")
kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
kraken_private_rate_limit_seconds: float = Field(
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
)
kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS")
kraken_retry_base_delay_seconds: float = Field(
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
)
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET")
kraken_api_key_permissions: str = Field(
default="query,trade",
alias="KRAKEN_API_KEY_PERMISSIONS",
)
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
strategy_enable_stat_arb_experiment: bool = Field(
default=False,
alias="STRATEGY_ENABLE_STAT_ARB_EXPERIMENT",
)
strategy_stat_arb_lookback_window: int = Field(
default=120,
alias="STRATEGY_STAT_ARB_LOOKBACK_WINDOW",
)
strategy_stat_arb_entry_zscore: float = Field(
default=2.0,
alias="STRATEGY_STAT_ARB_ENTRY_ZSCORE",
)
strategy_stat_arb_exit_zscore: float = Field(
default=0.5,
alias="STRATEGY_STAT_ARB_EXIT_ZSCORE",
)
strategy_stat_arb_max_holding_seconds: float = Field(
default=900.0,
alias="STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS",
)
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
max_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES")
max_exposure_per_asset_usd: float | None = Field(
default=None,
alias="MAX_EXPOSURE_PER_ASSET_USD",
)
quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET")
min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD")
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD")
cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
max_source_latency_ms: float | None = Field(default=None, alias="MAX_SOURCE_LATENCY_MS")
max_apply_latency_ms: float | None = Field(default=None, alias="MAX_APPLY_LATENCY_MS")
max_consecutive_failures: int | None = Field(default=None, alias="MAX_CONSECUTIVE_FAILURES")
fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
@field_validator("app_env")
@classmethod
def _validate_app_env(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"dev", "test", "prod"}:
raise ValueError("APP_ENV must be one of: dev, test, prod")
return normalized
@field_validator("log_level")
@classmethod
def _validate_log_level(cls, value: str) -> str:
normalized = value.strip().upper()
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError("LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
return normalized
@field_validator("alert_min_severity")
@classmethod
def _validate_alert_severity(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"info", "warning", "error", "critical"}:
raise ValueError("ALERT_MIN_SEVERITY must be one of: info, warning, error, critical")
return normalized
@model_validator(mode="after")
def _validate_security_constraints(self) -> Settings:
if bool(self.dashboard_auth_username) ^ bool(self.dashboard_auth_password):
raise ValueError("dashboard auth requires both username and password")
if bool(self.kraken_api_key) ^ bool(self.kraken_api_secret):
raise ValueError("Kraken API auth requires both API key and secret")
permissions = {
token.strip().lower()
for token in self.kraken_api_key_permissions.split(",")
if token.strip()
}
if permissions and ("query" not in permissions or "trade" not in permissions):
raise ValueError("KRAKEN_API_KEY_PERMISSIONS must include query and trade")
if "withdraw" in permissions or "withdrawals" in permissions:
raise ValueError("KRAKEN_API_KEY_PERMISSIONS must not include withdrawal scope")
if self.alert_dedup_seconds < 0.0:
raise ValueError("ALERT_DEDUP_SECONDS must be >= 0")
if self.strategy_stat_arb_lookback_window < 2:
raise ValueError("STRATEGY_STAT_ARB_LOOKBACK_WINDOW must be >= 2")
if self.strategy_stat_arb_entry_zscore <= 0.0:
raise ValueError("STRATEGY_STAT_ARB_ENTRY_ZSCORE must be > 0")
if self.strategy_stat_arb_exit_zscore < 0.0:
raise ValueError("STRATEGY_STAT_ARB_EXIT_ZSCORE must be >= 0")
if self.strategy_stat_arb_entry_zscore <= self.strategy_stat_arb_exit_zscore:
raise ValueError(
"STRATEGY_STAT_ARB_ENTRY_ZSCORE must be greater than STRATEGY_STAT_ARB_EXIT_ZSCORE"
)
if self.strategy_stat_arb_max_holding_seconds <= 0.0:
raise ValueError("STRATEGY_STAT_ARB_MAX_HOLDING_SECONDS must be > 0")
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+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
+12
View File
@@ -0,0 +1,12 @@
"""Arbitrage detection package."""
from arbitrade.detection.engine import CycleScore, IncrementalCycleDetector, OpportunityEvent
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
__all__ = [
"CurrencyGraph",
"TriangularCycle",
"CycleScore",
"OpportunityEvent",
"IncrementalCycleDetector",
]
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import argparse
import statistics
import time
from dataclasses import asdict, dataclass
import orjson
from arbitrade.detection.engine import IncrementalCycleDetector
from arbitrade.detection.graph import CurrencyGraph
from arbitrade.exchange.models import BookLevel
from arbitrade.market_data.order_book import OrderBook
@dataclass(frozen=True, slots=True)
class DetectionBenchmarkResult:
iterations: int
total_ms: float
avg_ms: float
p50_ms: float
p95_ms: float
max_ms: float
target_ms: float
@property
def meets_target(self) -> bool:
return self.p95_ms <= self.target_ms
def _make_book(*, bid: float, ask: float) -> OrderBook:
book = OrderBook()
book.apply_bids([BookLevel(price=bid, volume=10.0)])
book.apply_asks([BookLevel(price=ask, volume=10.0)])
return book
def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, OrderBook]]:
asset_pairs = {
"XXBTZUSD": {"wsname": "BTC/USD"},
"XETHXXBT": {"wsname": "ETH/BTC"},
"XETHZUSD": {"wsname": "ETH/USD"},
}
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
cycles = graph.triangular_cycles()
index = graph.index_cycles_by_pair(cycles)
detector = IncrementalCycleDetector(
index,
fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
min_profit_threshold=0.001,
max_depth_levels=5,
max_book_age_seconds=10.0,
)
books = {
"BTC/USD": _make_book(bid=99.9, ask=100.0),
"ETH/BTC": _make_book(bid=0.049, ask=0.05),
"ETH/USD": _make_book(bid=5.2, ask=5.21),
}
return detector, books
def run_incremental_detection_benchmark(
*,
iterations: int = 50_000,
target_ms: float = 1.0,
) -> DetectionBenchmarkResult:
if iterations <= 0:
raise ValueError("iterations must be > 0")
detector, books = _build_detector_and_books()
samples_ms: list[float] = []
started_ns = time.perf_counter_ns()
for _ in range(iterations):
t0_ns = time.perf_counter_ns()
detector.score_updated_pair("ETH/BTC", books)
elapsed_ms = (time.perf_counter_ns() - t0_ns) / 1_000_000
samples_ms.append(elapsed_ms)
total_ms = (time.perf_counter_ns() - started_ns) / 1_000_000
return DetectionBenchmarkResult(
iterations=iterations,
total_ms=total_ms,
avg_ms=statistics.fmean(samples_ms),
p50_ms=statistics.quantiles(samples_ms, n=100)[49],
p95_ms=statistics.quantiles(samples_ms, n=100)[94],
max_ms=max(samples_ms),
target_ms=target_ms,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Benchmark incremental detection latency")
parser.add_argument("--iterations", type=int, default=50_000)
parser.add_argument("--target-ms", type=float, default=1.0)
args = parser.parse_args()
result = run_incremental_detection_benchmark(
iterations=args.iterations,
target_ms=args.target_ms,
)
payload = {
**asdict(result),
"meets_target": result.meets_target,
}
print(orjson.dumps(payload).decode("utf-8"))
if __name__ == "__main__":
main()
+295
View File
@@ -0,0 +1,295 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from arbitrade.detection.graph import TriangularCycle
from arbitrade.exchange.models import BookLevel
from arbitrade.market_data.order_book import OrderBook
def _normalize_pair_symbol(symbol: str) -> str:
if "/" not in symbol:
return symbol.upper()
base, quote = symbol.split("/", 1)
return f"{base.upper()}/{quote.upper()}"
@dataclass(frozen=True, slots=True)
class CycleScore:
cycle: TriangularCycle
gross_rate: float
net_rate: float
min_profit_threshold: float
updated_pair: str
scored_at: datetime
@property
def is_profitable(self) -> bool:
return (self.net_rate - 1.0) >= self.min_profit_threshold
@dataclass(frozen=True, slots=True)
class OpportunityEvent:
detected_at: datetime
cycle: str
updated_pair: str
gross_rate: float
net_rate: float
gross_pct: float
net_pct: float
est_profit: float
allocated_capital: float = 1.0
@classmethod
def from_cycle_score(cls, score: CycleScore, base_capital: float = 1.0) -> OpportunityEvent:
gross_pct = (score.gross_rate - 1.0) * 100.0
net_pct = (score.net_rate - 1.0) * 100.0
est_profit = (score.net_rate - 1.0) * base_capital
a, b, c = score.cycle.currencies
cycle = f"{a}->{b}->{c}->{a}"
return cls(
detected_at=score.scored_at,
cycle=cycle,
updated_pair=score.updated_pair,
gross_rate=score.gross_rate,
net_rate=score.net_rate,
gross_pct=gross_pct,
net_pct=net_pct,
est_profit=est_profit,
allocated_capital=base_capital,
)
class IncrementalCycleDetector:
def __init__(
self,
cycles_by_pair: Mapping[str, list[TriangularCycle]],
*,
fee_rate: float = 0.0,
max_depth_levels: int = 10,
min_profit_threshold: float = 0.0,
min_order_size_by_pair: Mapping[str, float] | None = None,
max_book_age_seconds: float | None = None,
) -> None:
self._cycles_by_pair = {
_normalize_pair_symbol(pair): list(cycles) for pair, cycles in cycles_by_pair.items()
}
self._fee_multiplier = 1.0 - fee_rate
self._max_depth_levels = max_depth_levels
self._min_profit_threshold = min_profit_threshold
self._max_book_age_seconds = max_book_age_seconds
self._min_order_size_by_pair = {
_normalize_pair_symbol(pair): float(min_size)
for pair, min_size in (min_order_size_by_pair or {}).items()
}
if self._fee_multiplier < 0.0:
raise ValueError("fee_rate must be <= 1.0")
if self._max_depth_levels <= 0:
raise ValueError("max_depth_levels must be > 0")
if self._min_profit_threshold < 0.0:
raise ValueError("min_profit_threshold must be >= 0.0")
if self._max_book_age_seconds is not None and self._max_book_age_seconds <= 0.0:
raise ValueError("max_book_age_seconds must be > 0.0")
for min_size in self._min_order_size_by_pair.values():
if min_size <= 0.0:
raise ValueError("minimum order size must be > 0.0")
def score_updated_pair(
self,
updated_pair: str,
books: Mapping[str, OrderBook],
) -> list[CycleScore]:
normalized_pair = _normalize_pair_symbol(updated_pair)
impacted_cycles = self._cycles_by_pair.get(normalized_pair, [])
normalized_books = {_normalize_pair_symbol(symbol): book for symbol, book in books.items()}
scores: list[CycleScore] = []
scored_at = datetime.now(UTC)
for cycle in impacted_cycles:
rates = self._score_cycle(cycle, normalized_books, scored_at)
if rates is None:
continue
gross_rate, net_rate = rates
if (net_rate - 1.0) < self._min_profit_threshold:
continue
scores.append(
CycleScore(
cycle=cycle,
gross_rate=gross_rate,
net_rate=net_rate,
min_profit_threshold=self._min_profit_threshold,
updated_pair=normalized_pair,
scored_at=scored_at,
)
)
return scores
def opportunities_for_updated_pair(
self,
updated_pair: str,
books: Mapping[str, OrderBook],
*,
base_capital: float = 1.0,
) -> list[OpportunityEvent]:
if base_capital <= 0.0:
raise ValueError("base_capital must be > 0.0")
scores = self.score_updated_pair(updated_pair, books)
return [OpportunityEvent.from_cycle_score(score, base_capital) for score in scores]
def _score_cycle(
self,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
scored_at: datetime,
) -> tuple[float, float] | None:
if not self._is_cycle_fresh(cycle, books, scored_at):
return None
a, b, c = cycle.currencies
gross_amount = 1.0
gross_ab = self._convert(gross_amount, a, b, cycle, books)
if gross_ab is None:
return None
net_ab = gross_ab * self._fee_multiplier
gross_bc = self._convert(gross_ab, b, c, cycle, books)
if gross_bc is None:
return None
net_bc = self._convert(net_ab, b, c, cycle, books)
if net_bc is None:
return None
net_bc *= self._fee_multiplier
gross_ca = self._convert(gross_bc, c, a, cycle, books)
if gross_ca is None:
return None
net_ca = self._convert(net_bc, c, a, cycle, books)
if net_ca is None:
return None
net_ca *= self._fee_multiplier
return gross_ca, net_ca
def _is_cycle_fresh(
self,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
scored_at: datetime,
) -> bool:
if self._max_book_age_seconds is None:
return True
for pair in cycle.pairs:
normalized_pair = _normalize_pair_symbol(pair)
book = books.get(normalized_pair)
if book is None:
return False
age_seconds = (scored_at - book.updated_at).total_seconds()
if age_seconds > self._max_book_age_seconds:
return False
return True
@staticmethod
def _pair_for_edge(cycle: TriangularCycle, from_currency: str, to_currency: str) -> str | None:
for pair in cycle.pairs:
if "/" not in pair:
continue
base, quote = pair.split("/", 1)
base = base.upper()
quote = quote.upper()
if {base, quote} == {from_currency, to_currency}:
return f"{base}/{quote}"
return None
def _convert(
self,
amount: float,
from_currency: str,
to_currency: str,
cycle: TriangularCycle,
books: Mapping[str, OrderBook],
) -> float | None:
pair = self._pair_for_edge(cycle, from_currency, to_currency)
if pair is None:
return None
book = books.get(pair)
if book is None:
return None
bids, asks = book.top_levels(depth=self._max_depth_levels)
base, quote = pair.split("/", 1)
base = base.upper()
quote = quote.upper()
if from_currency == base and to_currency == quote:
quote_out = self._sell_base_for_quote(amount, bids)
if quote_out is None:
return None
if not self._is_min_order_size_satisfied(pair, amount):
return None
return quote_out
if from_currency == quote and to_currency == base:
base_out = self._buy_base_with_quote(amount, asks)
if base_out is None:
return None
if not self._is_min_order_size_satisfied(pair, base_out):
return None
return base_out
return None
def _is_min_order_size_satisfied(self, pair: str, base_amount: float) -> bool:
min_size = self._min_order_size_by_pair.get(pair)
if min_size is None:
return True
return base_amount >= min_size
@staticmethod
def _sell_base_for_quote(amount_base: float, bids: list[BookLevel]) -> float | None:
remaining = amount_base
quote_out = 0.0
for level in bids:
if remaining <= 0.0:
break
if level.price <= 0.0 or level.volume <= 0.0:
continue
executed = min(remaining, level.volume)
quote_out += executed * level.price
remaining -= executed
if remaining > 0.0:
return None
return quote_out
@staticmethod
def _buy_base_with_quote(amount_quote: float, asks: list[BookLevel]) -> float | None:
remaining_quote = amount_quote
base_out = 0.0
for level in asks:
if remaining_quote <= 0.0:
break
if level.price <= 0.0 or level.volume <= 0.0:
continue
level_quote_capacity = level.volume * level.price
spend = min(remaining_quote, level_quote_capacity)
base_out += spend / level.price
remaining_quote -= spend
if remaining_quote > 0.0:
return None
return base_out
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class TriangularCycle:
currencies: tuple[str, str, str]
pairs: tuple[str, str, str]
def _canonical_pair(base: str, quote: str) -> str:
return f"{base}/{quote}"
class CurrencyGraph:
def __init__(self) -> None:
self._adjacency: dict[str, set[str]] = {}
self._pair_by_direction: dict[tuple[str, str], str] = {}
@property
def adjacency(self) -> dict[str, set[str]]:
return self._adjacency
@property
def pair_by_direction(self) -> dict[tuple[str, str], str]:
return self._pair_by_direction
def add_pair(self, base: str, quote: str, pair_symbol: str | None = None) -> None:
normalized_base = base.upper()
normalized_quote = quote.upper()
symbol = pair_symbol or _canonical_pair(normalized_base, normalized_quote)
self._adjacency.setdefault(normalized_base, set()).add(normalized_quote)
self._adjacency.setdefault(normalized_quote, set()).add(normalized_base)
self._pair_by_direction[(normalized_base, normalized_quote)] = symbol
self._pair_by_direction[(normalized_quote, normalized_base)] = symbol
@classmethod
def from_kraken_asset_pairs(cls, asset_pairs: dict[str, Any]) -> CurrencyGraph:
graph = cls()
for value in asset_pairs.values():
if not isinstance(value, dict):
continue
wsname = value.get("wsname")
if isinstance(wsname, str) and "/" in wsname:
base, quote = wsname.split("/", 1)
graph.add_pair(base, quote, wsname)
continue
raw_base = value.get("base")
raw_quote = value.get("quote")
if isinstance(raw_base, str) and isinstance(raw_quote, str):
graph.add_pair(raw_base, raw_quote)
return graph
def triangular_cycles(self) -> list[TriangularCycle]:
found: dict[tuple[str, str, str], TriangularCycle] = {}
for a, neighbors_a in self._adjacency.items():
for b in neighbors_a:
if a >= b:
continue
neighbors_b = self._adjacency.get(b, set())
for c in neighbors_b:
if b >= c:
continue
if a not in self._adjacency.get(c, set()):
continue
p_ab = self._pair_by_direction[(a, b)]
p_bc = self._pair_by_direction[(b, c)]
p_ca = self._pair_by_direction[(c, a)]
key = (a, b, c)
found[key] = TriangularCycle(currencies=key, pairs=(p_ab, p_bc, p_ca))
return list(found.values())
@staticmethod
def index_cycles_by_pair(cycles: list[TriangularCycle]) -> dict[str, list[TriangularCycle]]:
index: dict[str, list[TriangularCycle]] = {}
for cycle in cycles:
for pair in cycle.pairs:
index.setdefault(pair, []).append(cycle)
return index
+1
View File
@@ -0,0 +1 @@
"""Kraken exchange integration package."""
+142
View File
@@ -0,0 +1,142 @@
"""Fee service -- fetch Kraken account fee tier, sync pair fees, persist snapshots."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
import orjson
import structlog
from arbitrade.exchange.kraken_rest import KrakenRestClient
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
KrakenAccountSnapshot,
KrakenAccountSnapshotRepository,
)
_LOG = structlog.get_logger(__name__)
_FEE_REFRESH_INTERVAL_SECONDS = 86400 # 1 day
async def fetch_and_store_account_snapshot(
client: KrakenRestClient,
store: PgStore,
) -> KrakenAccountSnapshot | None:
"""Query TradeVolume + TradeBalance, persist as snapshot.
Returns the snapshot or None if either call failed.
"""
repo = KrakenAccountSnapshotRepository(store)
try:
volume_data = await client.trade_volume()
except Exception:
_LOG.exception("trade_volume_fetch_failed")
return None
try:
balance_data = await client.trade_balance()
except Exception:
_LOG.exception("trade_balance_fetch_failed")
return None
fee_tier = volume_data.get("fee_tier") if isinstance(volume_data, dict) else None
fees_dict = volume_data.get("fees") if isinstance(volume_data, dict) else None
fees_maker = volume_data.get("fees_maker") if isinstance(volume_data, dict) else None
currency = volume_data.get("currency")
thirty_day_volume_str = volume_data.get("volume")
maker_fee = None
taker_fee = None
fee_tier_str = str(fee_tier) if fee_tier is not None else None
# Extract current tier's maker/taker rates from fees dict
if isinstance(fees_dict, dict) and fee_tier_str is not None:
tier_fees = fees_dict.get(fee_tier_str)
if isinstance(tier_fees, dict):
maker_val = tier_fees.get("maker")
taker_val = tier_fees.get("taker")
maker_fee = float(maker_val) if maker_val is not None else None
taker_fee = float(taker_val) if taker_val is not None else None
# Build fee schedule as combined dict
fee_schedule: dict[str, object] = {}
if isinstance(fees_dict, dict):
fee_schedule["fees"] = fees_dict
if isinstance(fees_maker, dict):
fee_schedule["fees_maker"] = fees_maker
if currency is not None:
fee_schedule["currency"] = currency
thirty_day_volume = float(thirty_day_volume_str) if thirty_day_volume_str is not None else None
snapshot = KrakenAccountSnapshot(
snapshot_at=datetime.now(UTC),
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
thirty_day_volume=thirty_day_volume,
trade_balance_raw=balance_data if isinstance(balance_data, dict) else None,
fee_schedule_raw=fee_schedule if fee_schedule else None,
)
await repo.insert_snapshot(snapshot)
_LOG.info(
"account_snapshot_stored",
fee_tier=fee_tier_str,
maker_fee=maker_fee,
taker_fee=taker_fee,
)
# Fetch wallet balances and write to portfolio_snapshots
try:
wallet_balances = await client.balances()
total_value = 0.0
if isinstance(balance_data, dict):
eb = balance_data.get("eb")
total_value = float(eb) if eb is not None else 0.0
async with store.pool.acquire() as conn:
await conn.execute(
"INSERT INTO portfolio_snapshots"
" (snapshot_at, balances, total_value_usd) VALUES ($1, $2, $3)",
datetime.now(UTC),
orjson.dumps(wallet_balances).decode("utf-8") if wallet_balances else None,
total_value,
)
_LOG.info("portfolio_snapshot_stored", total_value_usd=total_value)
except Exception:
_LOG.exception("balances_fetch_or_store_failed")
return snapshot
async def run_fee_sync_loop(
client: KrakenRestClient,
store: PgStore,
stop_event: asyncio.Event,
) -> None:
"""Periodic loop: fetch account snapshot every hour.
Runs until stop_event is set.
"""
_LOG.info("fee_sync_loop_started", interval_s=_FEE_REFRESH_INTERVAL_SECONDS)
while not stop_event.is_set():
try:
await fetch_and_store_account_snapshot(client, store)
except Exception:
_LOG.exception("fee_sync_loop_iteration_failed")
# Wait with stop_event check
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=_FEE_REFRESH_INTERVAL_SECONDS,
)
break # stop_event was set
except TimeoutError:
pass # timeout elapsed, loop again
_LOG.info("fee_sync_loop_stopped")
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from urllib.parse import urlencode
import httpx
import structlog
from arbitrade.config.settings import Settings
from arbitrade.exchange.models import KrakenApiResult, LatencySample
from arbitrade.exchange.signing import sign_kraken_private_path
_LOG = structlog.get_logger(__name__)
def _result_dict(payload: dict[str, Any]) -> dict[str, Any]:
result = payload.get("result", {})
if isinstance(result, dict):
return result
return {}
class KrakenRestClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = httpx.AsyncClient(
base_url=settings.kraken_rest_url,
timeout=settings.kraken_http_timeout_seconds,
limits=httpx.Limits(max_keepalive_connections=10, max_connections=50),
headers={"User-Agent": "arbitrade/0.1.0"},
)
self._private_lock = asyncio.Lock()
issues = self.validate_compliance()
if issues:
_LOG.warning("kraken_compliance_issues", issues=issues)
else:
_LOG.info("kraken_compliance_ok")
def validate_compliance(self) -> list[str]:
issues: list[str] = []
if not self._settings.kraken_rest_url.startswith("https://"):
issues.append("KRAKEN_REST_URL should use https://")
if self._settings.kraken_private_rate_limit_seconds < 1.0:
issues.append("KRAKEN_PRIVATE_RATE_LIMIT_SECONDS below 1.0 may violate Kraken limits")
if self._settings.kraken_retry_attempts < 1:
issues.append("KRAKEN_RETRY_ATTEMPTS must be >= 1")
if self._settings.kraken_retry_base_delay_seconds < 0:
issues.append("KRAKEN_RETRY_BASE_DELAY_SECONDS must be >= 0")
return issues
async def close(self) -> None:
await self._client.aclose()
async def warm_connection_pool(self) -> None:
await self.server_time()
async def _request_with_retry(
self,
endpoint: str,
params: dict[str, Any] | None = None,
) -> KrakenApiResult:
attempts = self._settings.kraken_retry_attempts
delay = self._settings.kraken_retry_base_delay_seconds
params = params or {}
for attempt in range(1, attempts + 1):
t0 = time.perf_counter()
try:
response = await self._client.get(endpoint, params=params)
response.raise_for_status()
payload = response.json()
if payload.get("error"):
raise RuntimeError(f"Kraken error: {payload['error']}")
latency = (time.perf_counter() - t0) * 1000
_LOG.info(
"kraken_rest_request_ok",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
sample=LatencySample.now("rest_request", latency_ms=latency).latency_ms,
)
return KrakenApiResult(endpoint=endpoint, payload=payload)
except Exception as exc:
latency = (time.perf_counter() - t0) * 1000
_LOG.warning(
"kraken_rest_request_failed",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
error=str(exc),
)
if attempt >= attempts:
raise
await asyncio.sleep(delay * (2 ** (attempt - 1)))
raise RuntimeError("unreachable retry loop")
async def _private_post_with_retry(
self,
endpoint: str,
data: dict[str, str] | None = None,
) -> KrakenApiResult:
api_key = self._settings.kraken_api_key
api_secret = self._settings.kraken_api_secret
if not api_key or not api_secret:
raise RuntimeError("Missing Kraken API credentials for private endpoint")
attempts = self._settings.kraken_retry_attempts
delay = self._settings.kraken_retry_base_delay_seconds
for attempt in range(1, attempts + 1):
t0 = time.perf_counter()
try:
nonce = str(int(time.time() * 1000))
payload = {"nonce": nonce}
if data is not None:
payload.update(data)
encoded = urlencode(payload)
signature = sign_kraken_private_path(endpoint, nonce, encoded, api_secret)
response = await self._client.post(
endpoint,
data=payload,
headers={
"API-Key": api_key,
"API-Sign": signature,
},
)
response.raise_for_status()
body = response.json()
if body.get("error"):
raise RuntimeError(f"Kraken error: {body['error']}")
latency = (time.perf_counter() - t0) * 1000
_LOG.info(
"kraken_private_rest_request_ok",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
sample=LatencySample.now("private_rest_request", latency_ms=latency).latency_ms,
)
return KrakenApiResult(endpoint=endpoint, payload=body)
except Exception as exc:
latency = (time.perf_counter() - t0) * 1000
_LOG.warning(
"kraken_private_rest_request_failed",
endpoint=endpoint,
attempt=attempt,
latency_ms=latency,
error=str(exc),
)
if attempt >= attempts:
raise
await asyncio.sleep(delay * (2 ** (attempt - 1)))
raise RuntimeError("unreachable retry loop")
async def server_time(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/Time")
return _result_dict(result.payload)
async def assets(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/Assets")
return _result_dict(result.payload)
async def asset_pairs(self) -> dict[str, Any]:
result = await self._request_with_retry("/0/public/AssetPairs")
return _result_dict(result.payload)
async def _throttled_private_call(
self,
endpoint: str,
data: dict[str, str] | None = None,
) -> dict[str, Any]:
async with self._private_lock:
result = await self._private_post_with_retry(endpoint, data=data)
await asyncio.sleep(self._settings.kraken_private_rate_limit_seconds)
return _result_dict(result.payload)
async def balances(self) -> dict[str, Any]:
return await self._throttled_private_call("/0/private/Balance")
async def place_market_order(
self,
*,
pair: str,
side: str,
volume: float,
user_ref: int | None = None,
) -> dict[str, Any]:
normalized_side = side.lower()
if normalized_side not in {"buy", "sell"}:
raise ValueError("side must be 'buy' or 'sell'")
if volume <= 0.0:
raise ValueError("volume must be > 0.0")
if user_ref is not None and user_ref < 0:
raise ValueError("user_ref must be >= 0")
data = {
"pair": pair,
"type": normalized_side,
"ordertype": "market",
"volume": str(volume),
}
if user_ref is not None:
data["userref"] = str(user_ref)
return await self._throttled_private_call(
"/0/private/AddOrder",
data=data,
)
async def place_limit_order(
self,
*,
pair: str,
side: str,
volume: float,
price: float,
user_ref: int | None = None,
) -> dict[str, Any]:
normalized_side = side.lower()
if normalized_side not in {"buy", "sell"}:
raise ValueError("side must be 'buy' or 'sell'")
if volume <= 0.0:
raise ValueError("volume must be > 0.0")
if price <= 0.0:
raise ValueError("price must be > 0.0")
if user_ref is not None and user_ref < 0:
raise ValueError("user_ref must be >= 0")
data = {
"pair": pair,
"type": normalized_side,
"ordertype": "limit",
"price": str(price),
"volume": str(volume),
}
if user_ref is not None:
data["userref"] = str(user_ref)
return await self._throttled_private_call(
"/0/private/AddOrder",
data=data,
)
async def query_order(
self,
*,
order_id: str,
include_trades: bool = True,
) -> dict[str, Any]:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
return await self._throttled_private_call(
"/0/private/QueryOrders",
data={
"txid": order_id,
"trades": "true" if include_trades else "false",
},
)
async def cancel_order(self, *, order_id: str) -> dict[str, Any]:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
return await self._throttled_private_call(
"/0/private/CancelOrder",
data={"txid": order_id},
)
async def trade_volume(self, *, pair: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeVolume for fee tier, 30d volume, and fee schedule.
Returns dict with keys: currency, volume, fees (dict of tiers),
fees_maker (dict of tier->fee mappings), fee_tier (current tier).
If pair provided, returns pair-specific fee info.
"""
data: dict[str, str] = {}
if pair is not None:
data["pair"] = pair
return await self._throttled_private_call(
"/0/private/TradeVolume",
data=data if data else None,
)
async def trade_balance(self, *, asset: str | None = None) -> dict[str, Any]:
"""Query Kraken TradeBalance for equity, trade balance, margin info.
Returns dict with keys: eb (equivalent balance/equity),
tb (trade balance), m (margin amount), n (unrealized net P&L),
c (cost basis), v (current valuation), e (equity).
If asset provided, returns asset-class-specific balance.
"""
data: dict[str, str] = {}
if asset is not None:
data["asset"] = asset
return await self._throttled_private_call(
"/0/private/TradeBalance",
data=data if data else None,
)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import orjson
import structlog
import websockets
from arbitrade.alerting.notifier import AlertSeverity, SupportsAlerts
from arbitrade.config.settings import Settings
from arbitrade.exchange.models import BookDelta, BookLevel
_LOG = structlog.get_logger(__name__)
@dataclass(slots=True)
class WsMessage:
received_at: datetime
payload: dict[str, Any]
class KrakenWsClient:
def __init__(self, settings: Settings, *, alert_notifier: SupportsAlerts | None = None) -> None:
self._settings = settings
self._last_message_at: datetime | None = None
self._stop = asyncio.Event()
self._alert_notifier = alert_notifier
self._has_connected_once = False
self._was_disconnected = False
self._subscribed_symbols: list[str] = []
@property
def is_stale(self) -> bool:
if self._last_message_at is None:
return True
return (
datetime.now(UTC) - self._last_message_at
).total_seconds() > self._settings.ws_max_staleness_seconds
async def stop(self) -> None:
self._stop.set()
def set_subscribed_symbols(self, symbols: list[str]) -> None:
"""Set the list of symbols to subscribe to on (re)connect."""
self._subscribed_symbols = list(symbols)
async def _subscribe(self, ws: Any) -> None:
"""Send Kraken WS v2 subscribe message for book channel."""
if not self._subscribed_symbols:
_LOG.warning("kraken_ws_no_symbols_to_subscribe")
return
depth = 10
if hasattr(self._settings, "kraken_ws_book_depth"):
depth = self._settings.kraken_ws_book_depth
msg = orjson.dumps(
{
"method": "subscribe",
"params": {
"channel": "book",
"symbol": self._subscribed_symbols,
"depth": depth,
},
}
)
await ws.send(msg)
_LOG.info(
"kraken_ws_subscribed",
symbol_count=len(self._subscribed_symbols),
symbols=self._subscribed_symbols,
)
async def connect_stream(self) -> AsyncIterator[WsMessage]:
delay = 1.0
while not self._stop.is_set():
try:
url = self._settings.kraken_ws_url
async with websockets.connect(url, max_size=2_000_000) as ws:
_LOG.info("kraken_ws_connected", url=url)
if self._has_connected_once and self._was_disconnected:
await self._notify(
category="system",
severity="info",
title="WebSocket reconnected",
message="Kraken WebSocket connection restored.",
details={"url": url},
)
self._has_connected_once = True
self._was_disconnected = False
delay = 1.0
await self._subscribe(ws)
async for raw in self._recv_loop(ws):
yield raw
except Exception as exc:
log = (
"kraken_ws_disconnected_first_time"
if not self._has_connected_once
else "kraken_ws_disconnected"
)
_LOG.warning(log, error=str(exc), reconnect_in=delay)
self._was_disconnected = True
await self._notify(
category="system",
severity="warning",
title="WebSocket disconnected",
message="Kraken WebSocket disconnected, reconnect scheduled.",
details={
"error": str(exc),
"reconnect_in_seconds": f"{delay}",
},
)
await asyncio.sleep(delay)
delay = min(delay * 2, 30.0)
async def _recv_loop(self, ws: Any) -> AsyncIterator[WsMessage]:
while not self._stop.is_set():
t0 = time.perf_counter()
try:
raw = await asyncio.wait_for(
ws.recv(), timeout=self._settings.ws_heartbeat_timeout_seconds
)
except TimeoutError:
await self._notify(
category="system",
severity="critical",
title="WebSocket staleness abort",
message="No WebSocket heartbeat within configured timeout; reconnecting.",
details={
"heartbeat_timeout_seconds": (
f"{self._settings.ws_heartbeat_timeout_seconds}"
),
},
)
raise
parse_start = time.perf_counter()
payload = orjson.loads(raw)
self._last_message_at = datetime.now(UTC)
_LOG.debug(
"kraken_ws_message",
recv_latency_ms=(parse_start - t0) * 1000,
parse_latency_ms=(time.perf_counter() - parse_start) * 1000,
)
if isinstance(payload, dict):
yield WsMessage(received_at=self._last_message_at, payload=payload)
async def _notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> None:
if self._alert_notifier is None:
return
await self._alert_notifier.notify(
category=category,
severity=severity,
title=title,
message=message,
details=details,
)
@staticmethod
def parse_book_delta(message: dict[str, Any]) -> BookDelta | None:
# Kraken v2 book update shape can vary by channel; keep parser defensive.
channel = str(message.get("channel", ""))
if "book" not in channel:
return None
symbol = str(message.get("symbol", ""))
data = message.get("data")
if not isinstance(data, list) or not data:
return None
first = data[0]
if not isinstance(first, dict):
return None
bids = [
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
for level in first.get("bids", [])
if isinstance(level, dict) and "price" in level and "qty" in level
]
asks = [
BookLevel(price=float(level["price"]), volume=float(level["qty"]))
for level in first.get("asks", [])
if isinstance(level, dict) and "price" in level and "qty" in level
]
checksum: int | None = None
raw_checksum = first.get("checksum")
if isinstance(raw_checksum, int):
checksum = raw_checksum
source_timestamp_ms: int | None = None
if isinstance(first.get("timestamp"), int):
source_timestamp_ms = first["timestamp"]
return BookDelta(
symbol=symbol,
bids=bids,
asks=asks,
checksum=checksum,
source_timestamp_ms=source_timestamp_ms,
)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@dataclass(slots=True)
class KrakenApiResult:
endpoint: str
payload: dict[str, Any]
@dataclass(slots=True)
class LatencySample:
stage: str
at: datetime
latency_ms: float
@classmethod
def now(cls, stage: str, latency_ms: float) -> LatencySample:
return cls(stage=stage, at=datetime.now(UTC), latency_ms=latency_ms)
@dataclass(slots=True)
class BookLevel:
price: float
volume: float
@dataclass(slots=True)
class BookDelta:
symbol: str
bids: list[BookLevel]
asks: list[BookLevel]
checksum: int | None = None
source_timestamp_ms: int | None = None
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import base64
import hashlib
import hmac
from functools import lru_cache
@lru_cache(maxsize=2048)
def sign_kraken_private_path(path: str, nonce: str, post_data: str, api_secret: str) -> str:
message = nonce.encode("utf-8") + post_data.encode("utf-8")
sha256 = hashlib.sha256(message).digest()
mac = hmac.new(base64.b64decode(api_secret), path.encode("utf-8") + sha256, hashlib.sha512)
return base64.b64encode(mac.digest()).decode("utf-8")
+32
View File
@@ -0,0 +1,32 @@
"""Trade execution helpers."""
from arbitrade.execution.fill_monitor import (
FillMonitor,
FillMonitorResult,
OrderFillState,
)
from arbitrade.execution.idempotency import (
IdempotencyKeyFactory,
OrderReconciler,
ReconciliationReport,
)
from arbitrade.execution.recovery import PartialFillRecovery, RecoveryAction
from arbitrade.execution.sequencer import (
ExecutionLeg,
TriangularExecutionResult,
TriangularExecutionSequencer,
)
__all__ = [
"ExecutionLeg",
"OrderFillState",
"FillMonitorResult",
"FillMonitor",
"IdempotencyKeyFactory",
"ReconciliationReport",
"OrderReconciler",
"RecoveryAction",
"PartialFillRecovery",
"TriangularExecutionResult",
"TriangularExecutionSequencer",
]
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
class SupportsOrderStatusPolling(Protocol):
async def query_order(
self, *, order_id: str, include_trades: bool = True
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class OrderFillState:
order_id: str
status: str
filled_volume: float | None
avg_price: float | None
updated_at: datetime
source: str
@property
def is_terminal(self) -> bool:
return self.status in {"closed", "canceled", "expired"}
@dataclass(frozen=True, slots=True)
class FillMonitorResult:
order_id: str
timed_out: bool
terminal_state: OrderFillState | None
last_state: OrderFillState | None
elapsed_seconds: float
class FillMonitor:
def __init__(
self,
poll_client: SupportsOrderStatusPolling,
*,
poll_interval_seconds: float = 0.5,
max_wait_seconds: float = 10.0,
ws_status_provider: Callable[[str], OrderFillState | None] | None = None,
) -> None:
if poll_interval_seconds <= 0.0:
raise ValueError("poll_interval_seconds must be > 0.0")
if max_wait_seconds <= 0.0:
raise ValueError("max_wait_seconds must be > 0.0")
self._poll_client = poll_client
self._poll_interval_seconds = poll_interval_seconds
self._max_wait_seconds = max_wait_seconds
self._ws_status_provider = ws_status_provider
@staticmethod
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def _state_from_payload(
cls, order_id: str, payload: dict[str, Any], *, source: str
) -> OrderFillState:
status = str(payload.get("status", "unknown")).lower()
return OrderFillState(
order_id=order_id,
status=status,
filled_volume=cls._to_float(payload.get("vol_exec")),
avg_price=cls._to_float(payload.get("price") or payload.get("avg_price")),
updated_at=datetime.now(UTC),
source=source,
)
@classmethod
def _extract_order_payload(cls, order_id: str, response: dict[str, Any]) -> dict[str, Any]:
if order_id in response and isinstance(response[order_id], dict):
payload = response[order_id]
return {str(key): value for key, value in payload.items()}
return response
async def wait_for_terminal_fill(self, order_id: str) -> FillMonitorResult:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
started = time.monotonic()
last_state: OrderFillState | None = None
while True:
elapsed = time.monotonic() - started
if elapsed >= self._max_wait_seconds:
return FillMonitorResult(
order_id=order_id,
timed_out=True,
terminal_state=None,
last_state=last_state,
elapsed_seconds=elapsed,
)
if self._ws_status_provider is not None:
ws_state = self._ws_status_provider(order_id)
if ws_state is not None:
last_state = ws_state
if ws_state.is_terminal:
return FillMonitorResult(
order_id=order_id,
timed_out=False,
terminal_state=ws_state,
last_state=ws_state,
elapsed_seconds=elapsed,
)
response = await self._poll_client.query_order(order_id=order_id, include_trades=True)
payload = self._extract_order_payload(order_id, response)
polled_state = self._state_from_payload(order_id, payload, source="rest_poll")
last_state = polled_state
if polled_state.is_terminal:
return FillMonitorResult(
order_id=order_id,
timed_out=False,
terminal_state=polled_state,
last_state=polled_state,
elapsed_seconds=time.monotonic() - started,
)
await asyncio.sleep(self._poll_interval_seconds)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Protocol
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import ExecutionLeg
class SupportsOrderHistoryLookup(Protocol):
async def query_order(
self, *, order_id: str, include_trades: bool = True
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class ReconciliationReport:
order_id: str
user_ref: int
status: str
filled_volume: float | None
avg_price: float | None
is_terminal: bool
matches_request: bool
raw_payload: dict[str, Any]
class IdempotencyKeyFactory:
def user_ref_for_leg(self, event: OpportunityEvent, leg: ExecutionLeg, leg_index: int) -> int:
material = "|".join(
[
event.cycle,
event.updated_pair,
leg.from_currency,
leg.to_currency,
leg.pair,
leg.side,
f"{leg.volume:.12f}",
str(leg_index),
]
).encode("utf-8")
digest = hashlib.sha256(material).digest()
value = int.from_bytes(digest[:8], "big") % 2_147_483_647
return value or 1
class OrderReconciler:
def __init__(self, history_client: SupportsOrderHistoryLookup) -> None:
self._history_client = history_client
@staticmethod
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _extract_payload(order_id: str, response: dict[str, Any]) -> dict[str, Any]:
if order_id in response and isinstance(response[order_id], dict):
payload = response[order_id]
return {str(key): value for key, value in payload.items()}
return response
async def reconcile_order(
self,
*,
order_id: str,
user_ref: int,
expected_pair: str,
expected_side: str,
expected_volume: float,
) -> ReconciliationReport:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
response = await self._history_client.query_order(order_id=order_id, include_trades=True)
payload = self._extract_payload(order_id, response)
status = str(payload.get("status", "unknown")).lower()
filled_volume = self._to_float(payload.get("vol_exec"))
avg_price = self._to_float(payload.get("price") or payload.get("avg_price"))
reported_pair = str(payload.get("pair", expected_pair))
reported_side = str(payload.get("type", expected_side)).lower()
matches_request = (
reported_pair == expected_pair
and reported_side == expected_side.lower()
and (
expected_volume <= 0.0 or filled_volume is None or filled_volume <= expected_volume
)
and payload.get("userref") in {None, str(user_ref), user_ref}
)
return ReconciliationReport(
order_id=order_id,
user_ref=user_ref,
status=status,
filled_volume=filled_volume,
avg_price=avg_price,
is_terminal=status in {"closed", "canceled", "expired"},
matches_request=matches_request,
raw_payload=payload,
)
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from arbitrade.execution.fill_monitor import FillMonitorResult, OrderFillState
class SupportsOrderLifecycle(Protocol):
async def cancel_order(self, *, order_id: str) -> dict[str, Any]: ...
async def place_market_order(
self, *, pair: str, side: str, volume: float
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class RecoveryAction:
order_id: str
canceled: bool
hedged: bool
hedge_pair: str | None = None
hedge_side: str | None = None
hedge_volume: float | None = None
cancel_response: dict[str, Any] | None = None
hedge_response: dict[str, Any] | None = None
reason: str | None = None
class PartialFillRecovery:
def __init__(self, rest_client: SupportsOrderLifecycle) -> None:
self._rest_client = rest_client
@staticmethod
def _counter_side(side: str) -> str:
normalized = side.lower()
if normalized == "buy":
return "sell"
if normalized == "sell":
return "buy"
raise ValueError("side must be 'buy' or 'sell'")
@staticmethod
def _residual_volume(terminal_state: OrderFillState | None, requested_volume: float) -> float:
if requested_volume <= 0.0:
raise ValueError("requested_volume must be > 0.0")
if terminal_state is None or terminal_state.filled_volume is None:
return requested_volume
residual = requested_volume - terminal_state.filled_volume
return residual if residual > 0.0 else 0.0
async def recover_partial_fill(
self,
*,
order_id: str,
pair: str,
side: str,
requested_volume: float,
fill_result: FillMonitorResult,
) -> RecoveryAction:
if not order_id.strip():
raise ValueError("order_id must be non-empty")
cancel_response: dict[str, Any] | None = None
hedge_response: dict[str, Any] | None = None
hedged = False
canceled = False
reason = None
state = fill_result.terminal_state or fill_result.last_state
residual_volume = self._residual_volume(state, requested_volume)
if state is not None and state.status in {"open", "partial"}:
cancel_response = await self._rest_client.cancel_order(order_id=order_id)
canceled = True
reason = f"canceled_{state.status}_order"
if residual_volume > 0.0 and fill_result.timed_out:
hedge_response = await self._rest_client.place_market_order(
pair=pair,
side=self._counter_side(side),
volume=residual_volume,
)
hedged = True
if reason is None:
reason = "hedged_timed_out_order"
return RecoveryAction(
order_id=order_id,
canceled=canceled,
hedged=hedged,
hedge_pair=pair if hedged else None,
hedge_side=self._counter_side(side) if hedged else None,
hedge_volume=residual_volume if hedged else None,
cancel_response=cancel_response,
hedge_response=hedge_response,
reason=reason,
)
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
from arbitrade.alerting.notifier import SupportsAlerts
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import (
AuditRecord,
AuditRepository,
OrderRecord,
PnLRecord,
TradeRecord,
)
class SupportsOrderPlacement(Protocol):
async def place_market_order(
self, *, pair: str, side: str, volume: float
) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class ExecutionLeg:
from_currency: str
to_currency: str
pair: str
side: str
volume: float
@dataclass(frozen=True, slots=True)
class TriangularExecutionResult:
success: bool
requested_legs: tuple[ExecutionLeg, ...]
completed_legs: int
responses: tuple[dict[str, Any], ...]
failure_reason: str | None = None
class TriangularExecutionSequencer:
def __init__(
self,
rest_client: SupportsOrderPlacement,
*,
available_pairs: Sequence[str],
volume_for_leg: Callable[[OpportunityEvent, ExecutionLeg, int], float] | None = None,
execution_writer: AsyncExecutionWriter | None = None,
alert_notifier: SupportsAlerts | None = None,
audit_repository: AuditRepository | None = None,
) -> None:
self._rest_client = rest_client
self._available_pairs = {self._normalize_pair(pair) for pair in available_pairs}
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
self._execution_writer = execution_writer
self._alert_notifier = alert_notifier
self._audit_repository = audit_repository
@staticmethod
def _normalize_pair(pair: str) -> str:
normalized = pair.strip().upper().replace("-", "/")
if "/" not in normalized:
return normalized
base, quote = normalized.split("/", 1)
return f"{base}/{quote}"
@staticmethod
def _default_volume_for_leg(event: OpportunityEvent, _leg: ExecutionLeg, _idx: int) -> float:
if event.allocated_capital <= 0.0:
raise ValueError("allocated_capital must be > 0.0")
return event.allocated_capital
def _resolve_leg(self, from_currency: str, to_currency: str, volume: float) -> ExecutionLeg:
from_cur = from_currency.upper()
to_cur = to_currency.upper()
buy_pair = f"{to_cur}/{from_cur}"
if buy_pair in self._available_pairs:
return ExecutionLeg(
from_currency=from_cur,
to_currency=to_cur,
pair=buy_pair,
side="buy",
volume=volume,
)
sell_pair = f"{from_cur}/{to_cur}"
if sell_pair in self._available_pairs:
return ExecutionLeg(
from_currency=from_cur,
to_currency=to_cur,
pair=sell_pair,
side="sell",
volume=volume,
)
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
currencies = [part.strip().upper() for part in event.cycle.split("->") if part.strip()]
if len(currencies) < 4 or currencies[0] != currencies[-1]:
raise ValueError("cycle must be a closed triangular path like A->B->C->A")
if len(currencies) != 4:
raise ValueError("cycle must contain exactly three unique currencies")
legs: list[ExecutionLeg] = []
for idx in range(3):
from_currency = currencies[idx]
to_currency = currencies[idx + 1]
placeholder_leg = ExecutionLeg(
from_currency=from_currency,
to_currency=to_currency,
pair="",
side="buy",
volume=0.0,
)
volume = self._volume_for_leg(event, placeholder_leg, idx)
if volume <= 0.0:
raise ValueError("volume_for_leg must return a positive volume")
legs.append(self._resolve_leg(from_currency, to_currency, volume))
return tuple(legs)
@staticmethod
def _trade_ref_for_event(event: OpportunityEvent) -> str:
material = (
f"{event.cycle}|{event.updated_pair}|"
f"{event.detected_at.timestamp():.6f}|"
f"{event.allocated_capital:.12f}"
)
return material.encode("utf-8").hex()[:32]
@staticmethod
def _order_ref_from_response(response: dict[str, Any], default: str) -> str:
txid = response.get("txid")
if isinstance(txid, list) and txid:
return str(txid[0])
if isinstance(txid, str) and txid.strip():
return txid
return default
async def execute(self, event: OpportunityEvent) -> TriangularExecutionResult:
legs = self._build_legs(event)
responses: list[dict[str, Any]] = []
trade_ref = self._trade_ref_for_event(event)
started_at = datetime.now(UTC)
for idx, leg in enumerate(legs):
try:
response = await self._rest_client.place_market_order(
pair=leg.pair,
side=leg.side,
volume=leg.volume,
)
except Exception as exc:
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.trade.failed",
decision="rejected",
payload={
"cycle": event.cycle,
"failed_leg_index": idx,
"error": str(exc),
},
correlation_id=trade_ref,
)
)
if self._alert_notifier is not None:
await self._alert_notifier.notify(
category="error",
severity="error",
title="Trade execution failed",
message="Triangular execution failed before completing all legs.",
details={
"cycle": event.cycle,
"failed_leg_index": str(idx),
"error": str(exc),
},
)
if self._execution_writer is not None:
await self._execution_writer.enqueue(
TradeRecord(
trade_ref=trade_ref,
started_at=started_at,
finished_at=datetime.now(UTC),
status="failed",
realized_pnl=None,
estimated_pnl=event.est_profit,
capital_used=event.allocated_capital,
cycle=event.cycle,
leg_count=len(legs),
)
)
return TriangularExecutionResult(
success=False,
requested_legs=legs,
completed_legs=idx,
responses=tuple(responses),
failure_reason=str(exc),
)
responses.append(response)
if self._execution_writer is not None:
order_ref = self._order_ref_from_response(response, f"leg-{idx}")
await self._execution_writer.enqueue(
OrderRecord(
trade_ref=trade_ref,
order_ref=order_ref,
leg_index=idx,
pair=leg.pair,
side=leg.side,
volume=leg.volume,
user_ref=None,
status=str(response.get("status", "submitted")),
filled_volume=None,
avg_price=None,
raw_response=response,
recorded_at=datetime.now(UTC),
)
)
if self._execution_writer is not None:
await self._execution_writer.enqueue(
TradeRecord(
trade_ref=trade_ref,
started_at=started_at,
finished_at=datetime.now(UTC),
status="filled",
realized_pnl=None,
estimated_pnl=event.est_profit,
capital_used=event.allocated_capital,
cycle=event.cycle,
leg_count=len(legs),
)
)
await self._execution_writer.enqueue(
PnLRecord(
trade_ref=trade_ref,
recorded_at=datetime.now(UTC),
kind="estimated",
pnl_usd=event.est_profit,
source="triangular_sequencer",
)
)
if self._alert_notifier is not None:
await self._alert_notifier.notify(
category="trade",
severity="warning" if event.est_profit < 0.0 else "info",
title="Trade execution completed",
message="Triangular execution completed all requested legs.",
details={
"cycle": event.cycle,
"completed_legs": str(len(legs)),
"estimated_pnl_usd": f"{event.est_profit}",
},
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.trade.completed",
decision="approved",
payload={
"cycle": event.cycle,
"completed_legs": len(legs),
"estimated_pnl_usd": event.est_profit,
},
correlation_id=trade_ref,
)
)
return TriangularExecutionResult(
success=True,
requested_legs=legs,
completed_legs=len(legs),
responses=tuple(responses),
)
+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
from arbitrade.logging.db_sink import db_sink_processor
def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
level = getattr(logging, log_level.upper(), logging.INFO)
@@ -17,6 +19,7 @@ def configure_logging(log_level: str = "INFO", json_logs: bool = True) -> None:
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
timestamper,
db_sink_processor,
]
if json_logs:
+1
View File
@@ -0,0 +1 @@
"""Market data ingestion and book cache package."""
+485
View File
@@ -0,0 +1,485 @@
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
import structlog
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
from arbitrade.exchange.kraken_ws import KrakenWsClient
from arbitrade.market_data.order_book import OrderBook
from arbitrade.risk.kill_switch import KillSwitch
from arbitrade.risk.loss_limits import LossLimitGuard
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.stop_conditions import StopConditionsGuard
from arbitrade.risk.trade_limits import TradeLimitsGuard
from arbitrade.storage.market_snapshots import AsyncMarketSnapshotWriter, MarketSnapshot
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import AuditRecord, AuditRepository
_LOG = structlog.get_logger(__name__)
@dataclass(frozen=True, slots=True)
class ExecutionOutcome:
realized_pnl: float | None = None
close_trade: bool = True
class MarketDataFeed:
def __init__(
self,
ws_client: KrakenWsClient,
snapshot_writer: AsyncMarketSnapshotWriter,
detector: IncrementalCycleDetector | None = None,
opportunity_writer: AsyncOpportunityWriter | None = None,
paper_trading_mode: bool = True,
opportunity_executor: (
Callable[[OpportunityEvent], Awaitable[ExecutionOutcome | float | None]] | None
) = None,
trade_capital: float = 1.0,
max_trade_capital: float | None = None,
loss_limit_guard: LossLimitGuard | None = None,
trade_limits_guard: TradeLimitsGuard | None = None,
pre_trade_validator: PreTradeValidator | None = None,
balance_provider: Callable[[], Mapping[str, float]] | None = None,
quote_balance_asset: str = "USD",
kill_switch: KillSwitch | None = None,
stop_conditions_guard: StopConditionsGuard | None = None,
alert_notifier: SupportsAlerts | None = None,
audit_repository: AuditRepository | None = None,
) -> None:
self._ws_client = ws_client
self._snapshot_writer = snapshot_writer
self._books: dict[str, OrderBook] = {}
self._detector = detector
self._opportunity_writer = opportunity_writer
self._paper_trading_mode = paper_trading_mode
self._opportunity_executor = opportunity_executor
self._trade_capital = trade_capital
self._max_trade_capital = max_trade_capital
self._loss_limit_guard = loss_limit_guard
self._trade_limits_guard = trade_limits_guard
self._pre_trade_validator = pre_trade_validator
self._balance_provider = balance_provider
self._quote_balance_asset = quote_balance_asset.upper()
self._kill_switch = kill_switch
self._stop_conditions_guard = stop_conditions_guard
self._alert_notifier = alert_notifier
self._audit_repository = audit_repository
if self._trade_capital <= 0.0:
raise ValueError("trade_capital must be > 0.0")
if self._max_trade_capital is not None and self._max_trade_capital <= 0.0:
raise ValueError("max_trade_capital must be > 0.0")
@property
def books(self) -> dict[str, OrderBook]:
return self._books
def _effective_trade_capital(self) -> float:
if self._max_trade_capital is None:
return self._trade_capital
return min(self._trade_capital, self._max_trade_capital)
@staticmethod
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
currencies = [part for part in event.cycle.split("->") if part]
if len(currencies) < 2:
return {}
start = currencies[0]
exposure_assets = {currency for currency in currencies[1:] if currency != start}
return {asset: event.allocated_capital for asset in exposure_assets}
async def run(self) -> None:
async for message in self._ws_client.connect_stream():
parse_start = time.perf_counter()
delta = self._ws_client.parse_book_delta(message.payload)
if delta is None:
continue
book = self._books.setdefault(delta.symbol, OrderBook())
book.apply_bids(delta.bids)
book.apply_asks(delta.asks)
checksum_ok = True
if delta.checksum is not None:
checksum_ok = book.compute_checksum() == delta.checksum
apply_latency_ms = (time.perf_counter() - parse_start) * 1000
source_latency_ms: float | None = None
if delta.source_timestamp_ms is not None:
source_latency_ms = datetime.now(UTC).timestamp() * 1000 - float(
delta.source_timestamp_ms
)
_LOG.info(
"book_delta_applied",
symbol=delta.symbol,
bids=len(delta.bids),
asks=len(delta.asks),
checksum_ok=checksum_ok,
apply_latency_ms=apply_latency_ms,
source_latency_ms=source_latency_ms,
)
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.observe_latency(
source_latency_ms=source_latency_ms,
apply_latency_ms=apply_latency_ms,
)
if self._stop_conditions_guard.is_halted:
if self._kill_switch is not None and not self._kill_switch.is_active:
self._kill_switch.activate(
reason=self._stop_conditions_guard.halted_reason
or "stop_conditions_halted",
)
_LOG.warning(
"stop_condition_halt_triggered",
reason=self._stop_conditions_guard.halted_reason,
symbol=delta.symbol,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.stop_condition_halt",
decision="rejected",
payload={
"reason": self._stop_conditions_guard.halted_reason
or "unknown",
"symbol": delta.symbol,
},
)
)
if self._detector is not None:
opportunities = self._detector.opportunities_for_updated_pair(
delta.symbol,
self._books,
base_capital=self._effective_trade_capital(),
)
_LOG.debug(
"incremental_opportunity_scores",
symbol=delta.symbol,
opportunities=len(opportunities),
)
for event in opportunities:
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="detector",
event_type="detector.opportunity",
decision="scored",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"net_pct": event.net_pct,
"est_profit": event.est_profit,
},
)
)
_LOG.info(
"opportunity_detected",
cycle=event.cycle,
updated_pair=event.updated_pair,
gross_pct=event.gross_pct,
net_pct=event.net_pct,
est_profit=event.est_profit,
mode="paper" if self._paper_trading_mode else "live",
)
if self._opportunity_writer is not None:
await self._opportunity_writer.enqueue(event)
if self._paper_trading_mode:
_LOG.info(
"paper_trade_simulated",
cycle=event.cycle,
updated_pair=event.updated_pair,
net_pct=event.net_pct,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.paper_trade",
decision="skipped",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
},
)
)
continue
if self._opportunity_executor is None:
_LOG.warning(
"live_trade_skipped_no_executor",
cycle=event.cycle,
updated_pair=event.updated_pair,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="rejected",
payload={
"reason": "missing_executor",
"cycle": event.cycle,
},
)
)
continue
if self._kill_switch is not None and self._kill_switch.is_active:
_LOG.warning(
"live_trade_skipped_kill_switch",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._kill_switch.reason,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.kill_switch",
decision="rejected",
payload={
"reason": self._kill_switch.reason or "manual",
"cycle": event.cycle,
},
)
)
continue
if (
self._stop_conditions_guard is not None
and self._stop_conditions_guard.is_halted
):
_LOG.warning(
"live_trade_skipped_stop_condition_halt",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._stop_conditions_guard.halted_reason,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.stop_condition",
decision="rejected",
payload={
"reason": self._stop_conditions_guard.halted_reason
or "halted",
"cycle": event.cycle,
},
)
)
continue
if self._loss_limit_guard is not None and self._loss_limit_guard.is_halted:
_LOG.warning(
"live_trade_skipped_loss_limit_halted",
cycle=event.cycle,
updated_pair=event.updated_pair,
reason=self._loss_limit_guard.halted_reason,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.loss_limit",
decision="rejected",
payload={
"reason": self._loss_limit_guard.halted_reason or "halted",
"cycle": event.cycle,
},
)
)
continue
if self._pre_trade_validator is not None and self._balance_provider is not None:
required_balances = {self._quote_balance_asset: event.allocated_capital}
balances = {
asset.upper(): amount
for asset, amount in self._balance_provider().items()
}
if not self._pre_trade_validator.validate(
balances_by_asset=balances,
required_by_asset=required_balances,
):
_LOG.warning(
"live_trade_skipped_pre_trade_validation",
cycle=event.cycle,
updated_pair=event.updated_pair,
required_by_asset=required_balances,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.pre_trade_validation",
decision="rejected",
payload={
"cycle": event.cycle,
"required_by_asset": {
key: required_balances[key]
for key in required_balances
},
},
)
)
continue
exposure_by_asset = self._exposure_for_event(event)
if (
self._trade_limits_guard is not None
and not self._trade_limits_guard.is_trade_allowed(exposure_by_asset)
):
_LOG.warning(
"live_trade_skipped_trade_limits",
cycle=event.cycle,
updated_pair=event.updated_pair,
exposure_by_asset=exposure_by_asset,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="risk_manager",
event_type="risk.trade_limits",
decision="rejected",
payload={
"cycle": event.cycle,
"exposure_by_asset": {
key: exposure_by_asset[key] for key in exposure_by_asset
},
},
)
)
continue
if self._trade_limits_guard is not None:
self._trade_limits_guard.open_trade(exposure_by_asset)
try:
outcome = await self._opportunity_executor(event)
except Exception as exc:
if self._trade_limits_guard is not None:
self._trade_limits_guard.close_trade(exposure_by_asset)
dispatch_alert_nowait(
self._alert_notifier,
category="system",
severity="critical",
title="Critical execution exception",
message="Unhandled exception raised by opportunity executor.",
details={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"error": str(exc),
},
)
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.register_failure()
if self._stop_conditions_guard.is_halted:
if (
self._kill_switch is not None
and not self._kill_switch.is_active
):
self._kill_switch.activate(
reason=self._stop_conditions_guard.halted_reason
or "stop_conditions_halted",
)
_LOG.warning(
"stop_condition_halt_triggered",
reason=self._stop_conditions_guard.halted_reason,
cycle=event.cycle,
updated_pair=event.updated_pair,
)
_LOG.exception(
"live_trade_execution_failed",
cycle=event.cycle,
updated_pair=event.updated_pair,
)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="error",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"error": str(exc),
},
)
)
continue
if self._stop_conditions_guard is not None:
self._stop_conditions_guard.register_success()
realized_pnl: float | None
close_trade = True
if isinstance(outcome, ExecutionOutcome):
realized_pnl = outcome.realized_pnl
close_trade = outcome.close_trade
else:
realized_pnl = outcome
if realized_pnl is not None and self._loss_limit_guard is not None:
self._loss_limit_guard.register_realized_pnl(realized_pnl)
if self._loss_limit_guard.is_halted:
_LOG.warning(
"loss_limit_halt_triggered",
reason=self._loss_limit_guard.halted_reason,
cumulative_pnl=self._loss_limit_guard.cumulative_pnl,
)
if self._trade_limits_guard is not None and close_trade:
self._trade_limits_guard.close_trade(exposure_by_asset)
if self._audit_repository is not None:
await self._audit_repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="execution_engine",
event_type="execution.live_trade",
decision="approved",
payload={
"cycle": event.cycle,
"updated_pair": event.updated_pair,
"realized_pnl": realized_pnl,
"close_trade": close_trade,
},
)
)
await self._snapshot_writer.enqueue(
MarketSnapshot(
snapshot_at=datetime.now(UTC),
symbol=delta.symbol,
source="kraken_ws",
payload=message.payload,
latency_ms=source_latency_ms,
)
)
+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]
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from sortedcontainers import SortedDict
from arbitrade.exchange.models import BookLevel
ZERO_CLEAN_RE = re.compile(r"^0+", re.ASCII)
def _normalize_price_for_checksum(value: float) -> str:
text = f"{value:.10f}".replace(".", "")
text = text.rstrip("0")
stripped = ZERO_CLEAN_RE.sub("", text)
return stripped or "0"
def _normalize_volume_for_checksum(value: float) -> str:
text = f"{value:.10f}".replace(".", "")
text = text.rstrip("0")
stripped = ZERO_CLEAN_RE.sub("", text)
return stripped or "0"
@dataclass(slots=True)
class BookView:
best_bid: BookLevel | None
best_ask: BookLevel | None
updated_at: datetime
class OrderBook:
def __init__(self) -> None:
self._bids: SortedDict[float, float] = SortedDict()
self._asks: SortedDict[float, float] = SortedDict()
self._updated_at: datetime = datetime.now(UTC)
@property
def updated_at(self) -> datetime:
return self._updated_at
def apply_bids(self, updates: Iterable[BookLevel]) -> None:
for level in updates:
if level.volume <= 0:
self._bids.pop(level.price, None)
else:
self._bids[level.price] = level.volume
self._updated_at = datetime.now(UTC)
def apply_asks(self, updates: Iterable[BookLevel]) -> None:
for level in updates:
if level.volume <= 0:
self._asks.pop(level.price, None)
else:
self._asks[level.price] = level.volume
self._updated_at = datetime.now(UTC)
def best_bid(self) -> BookLevel | None:
if not self._bids:
return None
price = self._bids.peekitem(-1)[0]
return BookLevel(price=price, volume=self._bids[price])
def best_ask(self) -> BookLevel | None:
if not self._asks:
return None
price = self._asks.peekitem(0)[0]
return BookLevel(price=price, volume=self._asks[price])
def snapshot(self) -> BookView:
return BookView(
best_bid=self.best_bid(),
best_ask=self.best_ask(),
updated_at=self._updated_at,
)
def top_levels(self, depth: int = 10) -> tuple[list[BookLevel], list[BookLevel]]:
bid_keys = list(self._bids.keys())
ask_keys = list(self._asks.keys())
bids = [
BookLevel(price=price, volume=self._bids[price])
for price in reversed(bid_keys[-depth:])
]
asks = [BookLevel(price=price, volume=self._asks[price]) for price in ask_keys[:depth]]
return bids, asks
def compute_checksum(self, depth: int = 10) -> int:
bids, asks = self.top_levels(depth)
combined: list[str] = []
for level in bids:
combined.append(_normalize_price_for_checksum(level.price))
combined.append(_normalize_volume_for_checksum(level.volume))
for level in asks:
combined.append(_normalize_price_for_checksum(level.price))
combined.append(_normalize_volume_for_checksum(level.volume))
import zlib
return zlib.crc32("".join(combined).encode("utf-8"))
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from arbitrade.storage.pg_store import PgStore
@dataclass(frozen=True, slots=True)
class PerformanceMetrics:
realized_pnl_usd: float
win_rate: float | None
avg_trade_duration_seconds: float | None
opportunities_per_minute: float | None
fill_rate: float | None
latency_p50_seconds: float | None
latency_p95_seconds: float | None
latency_p99_seconds: float | None
class MetricsCalculator:
def __init__(self, store: PgStore) -> None:
self._store = store
async def compute(self) -> PerformanceMetrics:
async with self._store.pool.acquire() as conn:
tm = await conn.fetchrow("""
SELECT
COALESCE(SUM(COALESCE(realized_pnl, 0)), 0) AS realized_pnl_usd,
COUNT(*) AS total_trades,
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) AS winning_trades,
AVG(EXTRACT(EPOCH FROM finished_at - started_at)) AS avg_trade_duration_seconds,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p50_seconds,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p95_seconds,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM finished_at - started_at)) AS latency_p99_seconds
FROM trades
WHERE finished_at IS NOT NULL
""")
om = await conn.fetchrow("""
SELECT
COUNT(*) AS opportunity_count,
MIN(detected_at) AS first_detected_at,
MAX(detected_at) AS last_detected_at
FROM opportunities
""")
fm = await conn.fetchrow("""
SELECT AVG(filled_volume / volume) AS fill_rate
FROM orders
WHERE volume > 0 AND filled_volume IS NOT NULL
""")
r_pnl_usd = (
float(tm["realized_pnl_usd"]) if tm and tm["realized_pnl_usd"] is not None else 0.0
)
tt = int(tm["total_trades"]) if tm and tm["total_trades"] is not None else 0
wt = int(tm["winning_trades"]) if tm and tm["winning_trades"] is not None else 0
wr = wt / tt if tt > 0 else None
atd = (
float(tm["avg_trade_duration_seconds"])
if tm and tm["avg_trade_duration_seconds"] is not None
else None
)
oc = (
int(om["opportunity_count"])
if om is not None and om["opportunity_count"] is not None
else 0
)
fo = (
om["first_detected_at"]
if om is not None and isinstance(om["first_detected_at"], datetime)
else None
)
lo = (
om["last_detected_at"]
if om is not None and isinstance(om["last_detected_at"], datetime)
else None
)
opportunities_per_minute: float | None
if oc >= 2 and fo is not None and lo is not None:
span_seconds = (lo - fo).total_seconds()
opportunities_per_minute = (
oc / (span_seconds / 60.0) if span_seconds > 0.0 else float(oc)
)
elif oc == 1:
opportunities_per_minute = 60.0
else:
opportunities_per_minute = None
fill_rate = float(fm["fill_rate"]) if fm and fm["fill_rate"] is not None else None
lp50 = (
float(tm["latency_p50_seconds"])
if tm and tm["latency_p50_seconds"] is not None
else None
)
lp95 = (
float(tm["latency_p95_seconds"])
if tm and tm["latency_p95_seconds"] is not None
else None
)
lp99 = (
float(tm["latency_p99_seconds"])
if tm and tm["latency_p99_seconds"] is not None
else None
)
return PerformanceMetrics(
realized_pnl_usd=r_pnl_usd,
win_rate=wr,
avg_trade_duration_seconds=atd,
opportunities_per_minute=opportunities_per_minute,
fill_rate=fill_rate,
latency_p50_seconds=lp50,
latency_p95_seconds=lp95,
latency_p99_seconds=lp99,
)
+4
View File
@@ -0,0 +1,4 @@
from arbitrade.perf.guardrails import evaluate_guardrails
from arbitrade.perf.latency import run_latency_profile
__all__ = ["run_latency_profile", "evaluate_guardrails"]
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
def evaluate_guardrails(
*,
baseline: dict[str, object],
current: dict[str, object],
thresholds: dict[str, object],
) -> list[str]:
failures: list[str] = []
baseline_scenarios = baseline.get("scenarios")
current_scenarios = current.get("scenarios")
if not isinstance(baseline_scenarios, dict) or not isinstance(current_scenarios, dict):
return ["invalid profile payload: missing scenarios map"]
default_thresholds = thresholds.get("default")
if not isinstance(default_thresholds, dict):
default_thresholds = {"p95_ms": 2.5, "p99_ms": 3.0}
scenario_thresholds = thresholds.get("scenarios")
if not isinstance(scenario_thresholds, dict):
scenario_thresholds = {}
for scenario, baseline_payload in baseline_scenarios.items():
current_payload = current_scenarios.get(scenario)
if not isinstance(baseline_payload, dict) or not isinstance(current_payload, dict):
failures.append(f"missing scenario in current profile: {scenario}")
continue
baseline_stages = baseline_payload.get("stages")
current_stages = current_payload.get("stages")
if not isinstance(baseline_stages, dict) or not isinstance(current_stages, dict):
failures.append(f"missing stages map for scenario: {scenario}")
continue
scenario_config = scenario_thresholds.get(scenario)
if not isinstance(scenario_config, dict):
scenario_config = {}
for stage, baseline_stage in baseline_stages.items():
current_stage = current_stages.get(stage)
if not isinstance(baseline_stage, dict) or not isinstance(current_stage, dict):
failures.append(f"missing stage in current profile: {scenario}.{stage}")
continue
for percentile_key in ("p95_ms", "p99_ms"):
threshold_ratio_raw = scenario_config.get(
percentile_key,
default_thresholds.get(percentile_key, 3.0),
)
threshold_ratio = (
float(threshold_ratio_raw)
if isinstance(threshold_ratio_raw, int | float)
else 3.0
)
base_value_raw = baseline_stage.get(percentile_key)
current_value_raw = current_stage.get(percentile_key)
if not isinstance(base_value_raw, int | float) or not isinstance(
current_value_raw, int | float
):
failures.append(
f"invalid percentile value: {scenario}.{stage}.{percentile_key}"
)
continue
base_value = float(base_value_raw)
current_value = float(current_value_raw)
# Avoid divide-by-zero while still preserving strict checks.
max_allowed = max(base_value * threshold_ratio, 0.001)
if current_value > max_allowed:
failures.append(
f"latency regression: {scenario}.{stage}.{percentile_key} "
f"current={current_value:.4f}ms "
f"baseline={base_value:.4f}ms "
f"allowed={max_allowed:.4f}ms"
)
return failures
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from time import perf_counter_ns
import orjson
@dataclass(frozen=True, slots=True)
class PercentileSummary:
p50_ms: float
p95_ms: float
p99_ms: float
@dataclass(frozen=True, slots=True)
class ScenarioProfile:
scenario: str
iterations: int
stages: dict[str, PercentileSummary]
def _percentile(samples: list[float], percentile: float) -> float:
if not samples:
return 0.0
ordered = sorted(samples)
if percentile <= 0.0:
return ordered[0]
if percentile >= 100.0:
return ordered[-1]
rank = (len(ordered) - 1) * (percentile / 100.0)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _summarize(samples: list[float]) -> PercentileSummary:
return PercentileSummary(
p50_ms=_percentile(samples, 50.0),
p95_ms=_percentile(samples, 95.0),
p99_ms=_percentile(samples, 99.0),
)
def _ingest_stage(raw_payload: bytes, state: dict[str, float]) -> None:
parsed = orjson.loads(raw_payload)
bids = parsed.get("bids", [])
asks = parsed.get("asks", [])
for price, volume in bids[:4]:
state[str(price)] = float(volume)
for price, volume in asks[:4]:
state[str(price)] = float(volume)
def _detect_stage(values: list[float], cycles: int) -> float:
best = 0.0
size = len(values)
for idx in range(cycles):
a = values[idx % size]
b = values[(idx + 3) % size]
c = values[(idx + 7) % size]
gross = (a / b) * c
net = gross * 0.9975
if net > best:
best = net
return best
def _risk_stage(net_edge: float, capital: float) -> float:
if net_edge < 1.0002:
return 0.0
if capital > 500.0:
capital = 500.0
return capital * min(net_edge - 1.0, 0.02)
def _execution_stage(planned_pnl: float, order_id: int) -> None:
payload = {
"order_id": order_id,
"planned_pnl": planned_pnl,
"legs": [
{"pair": "BTC/USD", "side": "buy", "qty": 0.01},
{"pair": "ETH/BTC", "side": "buy", "qty": 0.1},
{"pair": "ETH/USD", "side": "sell", "qty": 0.1},
],
}
_ = orjson.dumps(payload)
def _run_scenario(
name: str,
iterations: int,
detect_cycles: int,
reconnect_every: int,
) -> ScenarioProfile:
payloads = [
orjson.dumps(
{
"symbol": "BTC/USD",
"bids": [[100000.0 + i, 0.2 + (i % 5) * 0.01] for i in range(12)],
"asks": [[100001.0 + i, 0.2 + (i % 7) * 0.01] for i in range(12)],
}
)
for _ in range(5)
]
value_series = [1.0 + (idx % 31) * 0.0007 for idx in range(128)]
order_state: dict[str, float] = {}
ingest_ms: list[float] = []
detect_ms: list[float] = []
risk_ms: list[float] = []
execution_ms: list[float] = []
end_to_end_ms: list[float] = []
for idx in range(iterations):
start_ns = perf_counter_ns()
payload = payloads[idx % len(payloads)]
t0 = perf_counter_ns()
_ingest_stage(payload, order_state)
if reconnect_every > 0 and idx > 0 and idx % reconnect_every == 0:
order_state.clear()
t1 = perf_counter_ns()
net_edge = _detect_stage(value_series, detect_cycles)
t2 = perf_counter_ns()
planned = _risk_stage(net_edge, capital=100.0 + (idx % 50))
t3 = perf_counter_ns()
_execution_stage(planned, order_id=idx)
t4 = perf_counter_ns()
ingest_ms.append((t1 - t0) / 1_000_000.0)
detect_ms.append((t2 - t1) / 1_000_000.0)
risk_ms.append((t3 - t2) / 1_000_000.0)
execution_ms.append((t4 - t3) / 1_000_000.0)
end_to_end_ms.append((t4 - start_ns) / 1_000_000.0)
return ScenarioProfile(
scenario=name,
iterations=iterations,
stages={
"ingest": _summarize(ingest_ms),
"detect": _summarize(detect_ms),
"risk": _summarize(risk_ms),
"execution": _summarize(execution_ms),
"end_to_end": _summarize(end_to_end_ms),
},
)
def run_latency_profile(iterations: int = 600) -> dict[str, object]:
scenarios: list[Callable[[], ScenarioProfile]] = [
lambda: _run_scenario(
name="book_update_burst",
iterations=iterations,
detect_cycles=32,
reconnect_every=0,
),
lambda: _run_scenario(
name="execution_spike",
iterations=iterations,
detect_cycles=96,
reconnect_every=0,
),
lambda: _run_scenario(
name="reconnect_storm",
iterations=iterations,
detect_cycles=48,
reconnect_every=20,
),
]
result: dict[str, object] = {"iterations": iterations, "scenarios": {}}
scenario_map = result["scenarios"]
assert isinstance(scenario_map, dict)
for scenario in scenarios:
profile = scenario()
scenario_map[profile.scenario] = {
"iterations": profile.iterations,
"stages": {
stage: {
"p50_ms": summary.p50_ms,
"p95_ms": summary.p95_ms,
"p99_ms": summary.p99_ms,
}
for stage, summary in profile.stages.items()
},
}
return result
+15
View File
@@ -0,0 +1,15 @@
"""Risk management helpers."""
from arbitrade.risk.kill_switch import KillSwitch
from arbitrade.risk.loss_limits import LossLimitGuard
from arbitrade.risk.pre_trade import PreTradeValidator
from arbitrade.risk.stop_conditions import StopConditionsGuard
from arbitrade.risk.trade_limits import TradeLimitsGuard
__all__ = [
"LossLimitGuard",
"TradeLimitsGuard",
"PreTradeValidator",
"KillSwitch",
"StopConditionsGuard",
]
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
class KillSwitch:
def __init__(self, *, active: bool = False, reason: str | None = None) -> None:
self._active = active
self._reason = reason or ("manual" if active else None)
@property
def is_active(self) -> bool:
return self._active
@property
def reason(self) -> str | None:
return self._reason
def activate(self, *, reason: str = "manual") -> None:
self._active = True
self._reason = reason
def deactivate(self) -> None:
self._active = False
self._reason = None
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class LossLimitGuard:
def __init__(
self,
*,
daily_loss_limit: float | None = None,
cumulative_loss_limit: float | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
self._daily_loss_limit = daily_loss_limit
self._cumulative_loss_limit = cumulative_loss_limit
if self._daily_loss_limit is not None and self._daily_loss_limit <= 0.0:
raise ValueError("daily_loss_limit must be > 0.0")
if self._cumulative_loss_limit is not None and self._cumulative_loss_limit <= 0.0:
raise ValueError("cumulative_loss_limit must be > 0.0")
self._cumulative_pnl = 0.0
self._daily_pnl: dict[date, float] = {}
self._halted_reason: str | None = None
self._alert_notifier = alert_notifier
@property
def cumulative_pnl(self) -> float:
return self._cumulative_pnl
@property
def halted_reason(self) -> str | None:
return self._halted_reason
@property
def is_halted(self) -> bool:
return self._halted_reason is not None
def daily_pnl(self, day: date) -> float:
return self._daily_pnl.get(day, 0.0)
def register_realized_pnl(self, pnl: float, *, at: datetime | None = None) -> None:
if self.is_halted:
return
timestamp = at or datetime.now(UTC)
day_key = timestamp.date()
self._cumulative_pnl += pnl
self._daily_pnl[day_key] = self._daily_pnl.get(day_key, 0.0) + pnl
if (
self._daily_loss_limit is not None
and self._daily_pnl[day_key] <= -self._daily_loss_limit
):
self._halted_reason = "daily_loss_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Daily loss limit breached",
message="Trading halted because daily realized PnL crossed configured loss limit.",
details={
"daily_pnl": f"{self._daily_pnl[day_key]}",
"daily_loss_limit": f"{self._daily_loss_limit}",
},
)
return
if (
self._cumulative_loss_limit is not None
and self._cumulative_pnl <= -self._cumulative_loss_limit
):
self._halted_reason = "cumulative_loss_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Cumulative loss limit breached",
message=(
"Trading halted because cumulative realized PnL crossed "
"configured loss limit."
),
details={
"cumulative_pnl": f"{self._cumulative_pnl}",
"cumulative_loss_limit": f"{self._cumulative_loss_limit}",
},
)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from collections.abc import Mapping
class PreTradeValidator:
def __init__(
self,
*,
min_order_size_by_asset: Mapping[str, float] | None = None,
) -> None:
self._min_order_size_by_asset = {
asset.upper(): float(value) for asset, value in (min_order_size_by_asset or {}).items()
}
for value in self._min_order_size_by_asset.values():
if value <= 0.0:
raise ValueError("minimum order size must be > 0.0")
def validate(
self,
*,
balances_by_asset: Mapping[str, float],
required_by_asset: Mapping[str, float],
) -> bool:
# Minimum order size checks first to fail fast on structural invalid sizes.
for asset, required in required_by_asset.items():
if required <= 0.0:
continue
min_size = self._min_order_size_by_asset.get(asset.upper())
if min_size is not None and required < min_size:
return False
# Balance checks ensure required quantity is currently available.
for asset, required in required_by_asset.items():
if required <= 0.0:
continue
available = balances_by_asset.get(asset.upper(), 0.0)
if available < required:
return False
return True
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class StopConditionsGuard:
def __init__(
self,
*,
max_source_latency_ms: float | None = None,
max_apply_latency_ms: float | None = None,
max_consecutive_failures: int | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
if max_source_latency_ms is not None and max_source_latency_ms <= 0.0:
raise ValueError("max_source_latency_ms must be > 0.0")
if max_apply_latency_ms is not None and max_apply_latency_ms <= 0.0:
raise ValueError("max_apply_latency_ms must be > 0.0")
if max_consecutive_failures is not None and max_consecutive_failures <= 0:
raise ValueError("max_consecutive_failures must be > 0")
self._max_source_latency_ms = max_source_latency_ms
self._max_apply_latency_ms = max_apply_latency_ms
self._max_consecutive_failures = max_consecutive_failures
self._consecutive_failures = 0
self._halted_reason: str | None = None
self._alert_notifier = alert_notifier
@property
def halted_reason(self) -> str | None:
return self._halted_reason
@property
def is_halted(self) -> bool:
return self._halted_reason is not None
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
def observe_latency(
self,
*,
source_latency_ms: float | None,
apply_latency_ms: float,
) -> None:
if self.is_halted:
return
if (
self._max_source_latency_ms is not None
and source_latency_ms is not None
and source_latency_ms > self._max_source_latency_ms
):
self._halted_reason = "source_latency_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Source latency limit breached",
message="Trading halted because source latency exceeded configured limit.",
details={
"source_latency_ms": f"{source_latency_ms}",
"max_source_latency_ms": f"{self._max_source_latency_ms}",
},
)
return
if self._max_apply_latency_ms is not None and apply_latency_ms > self._max_apply_latency_ms:
self._halted_reason = "apply_latency_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Apply latency limit breached",
message="Trading halted because apply latency exceeded configured limit.",
details={
"apply_latency_ms": f"{apply_latency_ms}",
"max_apply_latency_ms": f"{self._max_apply_latency_ms}",
},
)
def register_failure(self) -> None:
if self.is_halted:
return
self._consecutive_failures += 1
if (
self._max_consecutive_failures is not None
and self._consecutive_failures >= self._max_consecutive_failures
):
self._halted_reason = "consecutive_failures_limit_breached"
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="critical",
title="Consecutive failures limit breached",
message="Trading halted because consecutive failures exceeded configured limit.",
details={
"consecutive_failures": f"{self._consecutive_failures}",
"max_consecutive_failures": f"{self._max_consecutive_failures}",
},
)
def register_success(self) -> None:
if self.is_halted:
return
self._consecutive_failures = 0
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from collections.abc import Mapping
from arbitrade.alerting.notifier import SupportsAlerts, dispatch_alert_nowait
class TradeLimitsGuard:
def __init__(
self,
*,
max_concurrent_trades: int | None = None,
max_exposure_per_asset: float | None = None,
alert_notifier: SupportsAlerts | None = None,
) -> None:
if max_concurrent_trades is not None and max_concurrent_trades <= 0:
raise ValueError("max_concurrent_trades must be > 0")
if max_exposure_per_asset is not None and max_exposure_per_asset <= 0.0:
raise ValueError("max_exposure_per_asset must be > 0.0")
self._max_concurrent_trades = max_concurrent_trades
self._max_exposure_per_asset = max_exposure_per_asset
self._active_trades = 0
self._asset_exposure: dict[str, float] = {}
self._alert_notifier = alert_notifier
@property
def active_trades(self) -> int:
return self._active_trades
def exposure_for_asset(self, asset: str) -> float:
return self._asset_exposure.get(asset.upper(), 0.0)
def is_trade_allowed(self, exposure_by_asset: Mapping[str, float]) -> bool:
if (
self._max_concurrent_trades is not None
and self._active_trades >= self._max_concurrent_trades
):
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="warning",
title="Concurrent trade limit reached",
message="Trade rejected by concurrent trade cap.",
details={
"active_trades": f"{self._active_trades}",
"max_concurrent_trades": f"{self._max_concurrent_trades}",
},
)
return False
if self._max_exposure_per_asset is None:
return True
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
next_exposure = self._asset_exposure.get(key, 0.0) + exposure
if next_exposure > self._max_exposure_per_asset:
dispatch_alert_nowait(
self._alert_notifier,
category="threshold",
severity="warning",
title="Asset exposure limit reached",
message="Trade rejected by per-asset exposure cap.",
details={
"asset": key,
"next_exposure": f"{next_exposure}",
"max_exposure_per_asset": f"{self._max_exposure_per_asset}",
},
)
return False
return True
def open_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
self._active_trades += 1
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
self._asset_exposure[key] = self._asset_exposure.get(key, 0.0) + exposure
def close_trade(self, exposure_by_asset: Mapping[str, float]) -> None:
if self._active_trades > 0:
self._active_trades -= 1
for asset, exposure in exposure_by_asset.items():
if exposure <= 0.0:
continue
key = asset.upper()
current = self._asset_exposure.get(key, 0.0)
next_exposure = max(current - exposure, 0.0)
if next_exposure == 0.0:
self._asset_exposure.pop(key, None)
else:
self._asset_exposure[key] = next_exposure
+15
View File
@@ -0,0 +1,15 @@
"""Runtime lifecycle and recovery helpers."""
from arbitrade.runtime.lifecycle import (
RuntimeRecoveryReport,
graceful_shutdown,
persist_runtime_snapshot,
restore_runtime_state,
)
__all__ = [
"RuntimeRecoveryReport",
"graceful_shutdown",
"persist_runtime_snapshot",
"restore_runtime_state",
]
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import inspect
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, cast
from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
AuditRecord,
AuditRepository,
RuntimeStateRecord,
RuntimeStateRepository,
)
@dataclass(slots=True)
class RuntimeRecoveryReport:
restored_from_snapshot: bool
snapshot_at: str | None
open_trades_detected: int
restart_guard_active: bool
def _controls(app: FastAPI) -> DashboardControlState:
return cast(DashboardControlState, app.state.dashboard_controls)
def _store(app: FastAPI) -> PgStore:
return cast(PgStore, app.state.store)
def _audit_repository(app: FastAPI) -> AuditRepository | None:
repository = getattr(app.state, "audit_repository", None)
return repository if isinstance(repository, AuditRepository) else None
def _runtime_repository(app: FastAPI) -> RuntimeStateRepository | None:
repository = getattr(app.state, "runtime_state_repository", None)
return repository if isinstance(repository, RuntimeStateRepository) else None
async def _open_trade_count(store: PgStore) -> int:
async with store.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT COUNT(*)
FROM trades
WHERE finished_at IS NULL
""")
return int(row[0]) if row is not None else 0
async def _latest_balances(store: PgStore) -> dict[str, Any] | None:
async with store.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT balances
FROM portfolio_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""")
if row is None or row["balances"] is None:
return None
raw_balances = row["balances"]
if isinstance(raw_balances, str):
return {"raw": raw_balances}
return {"raw": str(raw_balances)}
async def _record_audit(
app: FastAPI,
*,
event_type: str,
decision: str,
payload: dict[str, Any] | None = None,
) -> None:
repository = _audit_repository(app)
if repository is None:
return
await repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="runtime",
event_type=event_type,
decision=decision,
payload=payload,
correlation_id=None,
)
)
async def _run_startup_reconciler(app: FastAPI) -> None:
reconciler = getattr(app.state, "startup_reconciler", None)
if reconciler is None:
return
reconcile_member = getattr(reconciler, "reconcile_open_trades", None)
if reconcile_member is None or not callable(reconcile_member):
return
result = reconcile_member()
if inspect.isawaitable(result):
await result
async def persist_runtime_snapshot(
app: FastAPI, *, note: str | None = None
) -> RuntimeStateRecord | None:
repository = _runtime_repository(app)
if repository is None:
return None
controls = _controls(app)
store = _store(app)
snapshot = RuntimeStateRecord(
snapshot_at=datetime.now(UTC),
is_running=controls.is_running,
kill_switch_active=controls.kill_switch.is_active,
kill_switch_reason=controls.kill_switch.reason,
open_trade_count=await _open_trade_count(store),
last_known_balances=await _latest_balances(store),
note=note,
)
await repository.insert(snapshot)
return snapshot
async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
ctl = _controls(app)
store = _store(app)
repo = _runtime_repository(app)
restored_from_snapshot = False
snapshot_at: str | None = None
latest = await repo.latest() if repo is not None else None
if latest is not None:
restored_from_snapshot = True
snapshot_at = latest.snapshot_at.isoformat()
ctl.is_running = latest.is_running
if latest.kill_switch_active:
r = latest.kill_switch_reason or "recovered"
ctl.kill_switch.activate(reason=r)
else:
ctl.kill_switch.deactivate()
ctl.mark_updated()
open_trades = await _open_trade_count(store)
restart_guard_active = False
if open_trades > 0:
ctl.is_running = False
if not ctl.kill_switch.is_active:
ctl.kill_switch.activate(reason="recovery_open_trades_detected")
ctl.mark_updated()
restart_guard_active = True
report = RuntimeRecoveryReport(
restored_from_snapshot=restored_from_snapshot,
snapshot_at=snapshot_at,
open_trades_detected=open_trades,
restart_guard_active=restart_guard_active,
)
app.state.recovery_report = report
await _record_audit(
app,
event_type="runtime.startup_recovery",
decision="applied",
payload={
"restored_from_snapshot": restored_from_snapshot,
"open_trades_detected": open_trades,
"restart_guard_active": restart_guard_active,
},
)
await _run_startup_reconciler(app)
return report
async def drain_background_workers(app: FastAPI) -> None:
workers: list[object] = []
declared = getattr(app.state, "background_workers", None)
if isinstance(declared, list):
workers.extend(declared)
for attr_name in ("execution_writer", "opportunity_writer", "snapshot_writer"):
worker = getattr(app.state, attr_name, None)
if worker is not None:
workers.append(worker)
seen: set[int] = set()
for worker in workers:
worker_id = id(worker)
if worker_id in seen:
continue
seen.add(worker_id)
stop_member = getattr(worker, "stop", None)
if stop_member is None or not callable(stop_member):
continue
result = stop_member()
if inspect.isawaitable(result):
await result
async def graceful_shutdown(app: FastAPI) -> None:
controls = _controls(app)
controls.is_running = False
controls.mark_updated()
await _record_audit(
app,
event_type="runtime.shutdown",
decision="initiated",
payload={"execution_status": "stopped"},
)
await drain_background_workers(app)
await persist_runtime_snapshot(app, note="graceful_shutdown")
+1
View File
@@ -0,0 +1 @@
"""Storage helpers."""
-59
View File
@@ -1,59 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import duckdb
from arbitrade.config.settings import Settings
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT uuid(),
detected_at TIMESTAMP NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE,
net_pct DOUBLE,
est_profit DOUBLE,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT uuid(),
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP,
status VARCHAR NOT NULL,
realized_pnl DOUBLE,
capital_used DOUBLE
);
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMP NOT NULL,
balances JSON,
total_value_usd DOUBLE
);
"""
class DuckDBStore:
def __init__(self, settings: Settings) -> None:
self._db_path = Path(settings.duckdb_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
@contextmanager
def connect(self) -> Iterator[duckdb.DuckDBPyConnection]:
conn = duckdb.connect(str(self._db_path))
try:
yield conn
finally:
conn.close()
def migrate(self) -> None:
with self.connect() as conn:
conn.execute(SCHEMA_SQL)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import structlog
from arbitrade.storage.repositories import (
OrderRecord,
OrderRepository,
PnLRecord,
PnLRepository,
TradeRecord,
TradeRepository,
)
_LOG = structlog.get_logger(__name__)
class AsyncExecutionWriter:
def __init__(
self,
trade_repository: TradeRepository,
order_repository: OrderRepository,
pnl_repository: PnLRepository,
max_queue_size: int = 50_000,
) -> None:
self._trade_repository = trade_repository
self._order_repository = order_repository
self._pnl_repository = pnl_repository
self._queue: asyncio.Queue[TradeRecord | OrderRecord | PnLRecord] = asyncio.Queue(
maxsize=max_queue_size
)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="execution-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, record: TradeRecord | OrderRecord | PnLRecord) -> None:
await self._queue.put(record)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
record = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
if isinstance(record, TradeRecord):
await self._trade_repository.insert(record)
elif isinstance(record, OrderRecord):
await self._order_repository.insert(record)
else:
await self._pnl_repository.insert(record)
except Exception as exc:
_LOG.error("execution_write_failed", error=str(exc))
finally:
self._queue.task_done()
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import structlog
from arbitrade.storage.repositories import MarketSnapshotRecord, MarketSnapshotRepository
_LOG = structlog.get_logger(__name__)
@dataclass(slots=True)
class MarketSnapshot:
snapshot_at: datetime
symbol: str
source: str
payload: dict[str, Any]
latency_ms: float | None
class AsyncMarketSnapshotWriter:
def __init__(self, repository: MarketSnapshotRepository, max_queue_size: int = 50_000) -> None:
self._repository = repository
self._queue: asyncio.Queue[MarketSnapshot] = asyncio.Queue(maxsize=max_queue_size)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="market-snapshot-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, snapshot: MarketSnapshot) -> None:
await self._queue.put(snapshot)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
item = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
await self._repository.insert(
MarketSnapshotRecord(
snapshot_at=item.snapshot_at,
symbol=item.symbol,
source=item.source,
payload=item.payload,
latency_ms=item.latency_ms,
)
)
except Exception as exc:
_LOG.error("market_snapshot_write_failed", error=str(exc), symbol=item.symbol)
finally:
self._queue.task_done()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import asyncio
import structlog
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.repositories import OpportunityRecord, OpportunityRepository
_LOG = structlog.get_logger(__name__)
class AsyncOpportunityWriter:
def __init__(self, repository: OpportunityRepository, max_queue_size: int = 50_000) -> None:
self._repository = repository
self._queue: asyncio.Queue[OpportunityEvent] = asyncio.Queue(maxsize=max_queue_size)
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task is None or self._task.done():
self._stop.clear()
self._task = asyncio.create_task(self._run(), name="opportunity-writer")
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
async def enqueue(self, event: OpportunityEvent) -> None:
await self._queue.put(event)
async def _run(self) -> None:
while not (self._stop.is_set() and self._queue.empty()):
try:
event = await asyncio.wait_for(self._queue.get(), timeout=0.5)
except TimeoutError:
continue
try:
await self._repository.insert(
OpportunityRecord(
detected_at=event.detected_at,
cycle=event.cycle,
gross_pct=event.gross_pct,
net_pct=event.net_pct,
est_profit=event.est_profit,
)
)
except Exception as exc:
_LOG.error(
"opportunity_write_failed",
error=str(exc),
cycle=event.cycle,
updated_pair=event.updated_pair,
)
finally:
self._queue.task_done()
+132
View File
@@ -0,0 +1,132 @@
"""PostgreSQL store — async connection pool wrapper around asyncpg."""
from __future__ import annotations
from pathlib import Path
import asyncpg
import structlog
from arbitrade.config.settings import Settings
_LOG = structlog.get_logger(__name__)
SCHEMA_VERSION = 1
class PgStore:
"""Async PostgreSQL connection pool for the arbitrade bot.
Wraps an ``asyncpg.Pool`` with schema migration support.
"""
def __init__(self, settings: Settings) -> None:
self._dsn: str | None = None
self._pool: asyncpg.Pool | None = None
self._settings = settings
# ── lifecycle ────────────────────────────────────────────────
async def start(self) -> None:
"""Create the connection pool."""
s = self._settings
self._pool = await asyncpg.create_pool(
host=s.pg_host,
port=s.pg_port,
database=s.pg_database,
user=s.pg_user,
password=s.pg_password,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
_LOG.info(
"pg_pool_created",
host=s.pg_host,
database=s.pg_database,
min_size=s.pg_min_connections,
max_size=s.pg_max_connections,
)
async def stop(self) -> None:
"""Close the connection pool."""
if self._pool is not None:
await self._pool.close()
self._pool = None
_LOG.info("pg_pool_closed")
@property
def pool(self) -> asyncpg.Pool:
"""Return the underlying connection pool.
Raises ``RuntimeError`` if ``start()`` has not been called yet.
"""
if self._pool is None:
raise RuntimeError("PgStore not started — call start() first")
return self._pool
# ── schema migration ─────────────────────────────────────────
async def migrate(self) -> None:
"""Apply the PostgreSQL schema.
Reads ``schema_pg.sql`` from the same package directory and
executes it, then records the migration version.
"""
schema_path = Path(__file__).with_name("schema_pg.sql")
schema_sql = schema_path.read_text(encoding="utf-8")
async with self.pool.acquire() as conn:
# Apply the full schema (CREATE TABLE IF NOT EXISTS …)
await conn.execute(schema_sql)
# Record the current schema version
await conn.execute(
"""
INSERT INTO schema_migrations (version, applied_at)
VALUES ($1, CURRENT_TIMESTAMP)
ON CONFLICT (version) DO UPDATE SET applied_at = CURRENT_TIMESTAMP
""",
SCHEMA_VERSION,
)
_LOG.info("pg_schema_migrated", version=SCHEMA_VERSION)
# ── helpers ──────────────────────────────────────────────────
async def table_exists(self, table_name: str) -> bool:
"""Check if a table exists in the current schema."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return bool(row and row["cnt"] > 0)
async def get_table_columns(self, table_name: str) -> set[str]:
"""Return the set of column names for *table_name*."""
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
""",
table_name,
)
return {str(r["column_name"]) for r in rows}
async def ensure_column(self, table_name: str, column_def: str) -> None:
"""Add a column to *table_name* if it does not already exist.
``column_def`` should be something like ``"my_col VARCHAR"``.
"""
existing = await self.get_table_columns(table_name)
col_name = column_def.split()[0]
if col_name not in existing:
async with self.pool.acquire() as conn:
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_def}")
_LOG.info("pg_column_added", table=table_name, column=col_name)
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
-- PostgreSQL schema for arbitrade bot
-- Requires pgcrypto extension for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ========================================
-- Schema version tracking
-- ========================================
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ========================================
-- Configuration
-- ========================================
CREATE TABLE IF NOT EXISTS config_sections (
id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS config_settings (
key VARCHAR PRIMARY KEY,
section VARCHAR NOT NULL,
value_json TEXT NOT NULL,
value_type VARCHAR NOT NULL,
is_secret BOOLEAN DEFAULT FALSE,
is_runtime_reloadable BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR
);
CREATE TABLE IF NOT EXISTS config_pairings (
id SERIAL PRIMARY KEY,
base_asset VARCHAR NOT NULL,
quote_asset VARCHAR NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
source VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(base_asset, quote_asset)
);
CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
id SERIAL PRIMARY KEY,
starting_balances JSONB,
trade_capital DOUBLE PRECISION,
min_profit_threshold DOUBLE PRECISION,
slippage_bps INTEGER,
execution_latency_ms INTEGER,
fee_source VARCHAR DEFAULT 'api'
);
-- ========================================
-- Detection & Execution
-- ========================================
CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
detected_at TIMESTAMPTZ NOT NULL,
cycle VARCHAR NOT NULL,
gross_pct DOUBLE PRECISION,
net_pct DOUBLE PRECISION,
est_profit DOUBLE PRECISION,
executed BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
status VARCHAR NOT NULL,
realized_pnl DOUBLE PRECISION,
estimated_pnl DOUBLE PRECISION,
capital_used DOUBLE PRECISION,
cycle VARCHAR,
leg_count INTEGER
);
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
order_ref VARCHAR NOT NULL,
leg_index INTEGER NOT NULL,
pair VARCHAR NOT NULL,
side VARCHAR NOT NULL,
volume DOUBLE PRECISION NOT NULL,
user_ref INTEGER,
status VARCHAR,
filled_volume DOUBLE PRECISION,
avg_price DOUBLE PRECISION,
raw_response JSONB,
recorded_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
kind VARCHAR NOT NULL,
pnl_usd DOUBLE PRECISION NOT NULL,
source VARCHAR NOT NULL
);
-- ========================================
-- Snapshots & Monitoring
-- ========================================
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
balances JSONB,
total_value_usd DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL,
payload JSONB NOT NULL,
latency_ms DOUBLE PRECISION
);
CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL,
payload JSONB,
correlation_id VARCHAR
);
CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
is_running BOOLEAN NOT NULL,
kill_switch_active BOOLEAN NOT NULL,
kill_switch_reason VARCHAR,
open_trade_count INTEGER NOT NULL,
last_known_balances JSONB,
note VARCHAR
);
CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMPTZ NOT NULL,
fee_tier VARCHAR,
maker_fee DOUBLE PRECISION,
taker_fee DOUBLE PRECISION,
thirty_day_volume DOUBLE PRECISION,
trade_balance_raw JSONB,
fee_schedule_raw JSONB
);
-- ========================================
-- Backtesting
-- ========================================
CREATE TABLE IF NOT EXISTS backtest_jobs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
status VARCHAR NOT NULL DEFAULT 'pending',
events_path VARCHAR NOT NULL,
config JSONB,
report JSONB,
error VARCHAR,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
-- ========================================
-- Migration: convert legacy TIMESTAMP→TIMESTAMPTZ
-- for databases created before the fix.
-- These are idempotent (no-op when already TIMESTAMPTZ).
-- ========================================
ALTER TABLE audit_events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at AT TIME ZONE 'UTC';
ALTER TABLE runtime_state_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE schema_migrations ALTER COLUMN applied_at TYPE TIMESTAMPTZ USING applied_at AT TIME ZONE 'UTC';
ALTER TABLE config_sections ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_settings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE opportunities ALTER COLUMN detected_at TYPE TIMESTAMPTZ USING detected_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
ALTER TABLE orders ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE pnl_events ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE portfolio_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE market_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE kraken_account_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
-- ========================================
-- Logging tables
-- ========================================
CREATE TABLE IF NOT EXISTS app_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB
);
CREATE INDEX IF NOT EXISTS idx_app_logs_recorded_at ON app_logs (recorded_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs (level);
CREATE TABLE IF NOT EXISTS app_log_archives (
id UUID PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
level VARCHAR NOT NULL,
logger VARCHAR NOT NULL,
message TEXT NOT NULL,
context JSONB,
archived_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_log_archives_recorded_at ON app_log_archives (recorded_at DESC);
CREATE TABLE IF NOT EXISTS app_log_aggregates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
bucket_start TIMESTAMPTZ NOT NULL,
period VARCHAR NOT NULL,
level VARCHAR NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
UNIQUE (bucket_start, period, level)
);
CREATE INDEX IF NOT EXISTS idx_app_log_aggregates_bucket ON app_log_aggregates (bucket_start DESC, period);
+5
View File
@@ -0,0 +1,5 @@
"""Experimental strategy modules."""
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig, StatArbSignal
__all__ = ["StatArbExperiment", "StatArbExperimentConfig", "StatArbSignal"]
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from statistics import fmean, pstdev
from typing import Literal
@dataclass(frozen=True, slots=True)
class StatArbExperimentConfig:
pair_a: str
pair_b: str
lookback_window: int = 120
entry_zscore: float = 2.0
exit_zscore: float = 0.5
max_holding_seconds: float = 900.0
@dataclass(frozen=True, slots=True)
class StatArbSignal:
action: Literal[
"warmup",
"hold",
"enter_long_spread",
"enter_short_spread",
"exit_position",
]
observed_at: datetime
spread: float
zscore: float | None
position: Literal["long", "short", "flat"]
class StatArbExperiment:
"""Simple mean-reversion experiment scaffold behind feature flags."""
def __init__(self, config: StatArbExperimentConfig) -> None:
if config.lookback_window < 2:
raise ValueError("lookback_window must be >= 2")
if config.entry_zscore <= 0.0:
raise ValueError("entry_zscore must be > 0")
if config.exit_zscore < 0.0:
raise ValueError("exit_zscore must be >= 0")
if config.entry_zscore <= config.exit_zscore:
raise ValueError("entry_zscore must be > exit_zscore")
if config.max_holding_seconds <= 0.0:
raise ValueError("max_holding_seconds must be > 0")
self._config = config
self._spreads: deque[float] = deque(maxlen=config.lookback_window)
self._position: Literal["long", "short", "flat"] = "flat"
self._position_opened_at: datetime | None = None
@property
def config(self) -> StatArbExperimentConfig:
return self._config
def reset(self) -> None:
self._spreads.clear()
self._position = "flat"
self._position_opened_at = None
def observe(
self,
*,
price_a: float,
price_b: float,
observed_at: datetime,
) -> StatArbSignal:
if price_a <= 0.0 or price_b <= 0.0:
raise ValueError("prices must be > 0")
at = observed_at.astimezone(UTC)
spread = price_a - price_b
self._spreads.append(spread)
if len(self._spreads) < self._config.lookback_window:
return StatArbSignal(
action="warmup",
observed_at=at,
spread=spread,
zscore=None,
position=self._position,
)
mean_spread = fmean(self._spreads)
std_spread = pstdev(self._spreads)
if std_spread == 0.0:
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=0.0,
position=self._position,
)
zscore = (spread - mean_spread) / std_spread
if self._position == "flat":
if zscore >= self._config.entry_zscore:
self._position = "short"
self._position_opened_at = at
return StatArbSignal(
action="enter_short_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
if zscore <= -self._config.entry_zscore:
self._position = "long"
self._position_opened_at = at
return StatArbSignal(
action="enter_long_spread",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
assert self._position_opened_at is not None
held_seconds = (at - self._position_opened_at).total_seconds()
should_exit = abs(zscore) <= self._config.exit_zscore
if held_seconds >= self._config.max_holding_seconds:
should_exit = True
if should_exit:
self._position = "flat"
self._position_opened_at = None
return StatArbSignal(
action="exit_position",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
return StatArbSignal(
action="hold",
observed_at=at,
spread=spread,
zscore=zscore,
position=self._position,
)
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title or "Arbitrade" }}{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
{% block head_scripts %}{% endblock %}
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #0b1220;
color: #e5eefb;
}
.shell {
max-width: none;
padding: 24px 32px 48px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.title {
font-size: 2rem;
margin: 0 0 8px;
}
.subtitle {
margin: 0;
color: #9fb2d0;
}
.panel {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 20px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
background: rgba(255, 255, 255, 0.04);
border-radius: 14px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.label {
color: #9fb2d0;
font-size: 0.85rem;
margin-bottom: 8px;
}
.value {
font-size: 1.4rem;
font-weight: 700;
}
.meta {
margin-top: 18px;
color: #7f95b7;
font-size: 0.85rem;
}
.toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.toolbar form {
margin: 0;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
cursor: pointer;
padding: 10px 14px;
border-radius: 999px;
background: #2d6cdf;
color: white;
text-decoration: none;
font: inherit;
}
.button.secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.button.danger {
background: #ba3d4f;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
color: #9fb2d0;
font-size: 0.9rem;
}
.field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: #e5eefb;
font: inherit;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.field.checkbox input {
width: auto;
}
.control-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.chart-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-canvas {
width: 100%;
min-height: 260px;
}
</style>
{% block extra_style %}{% endblock %}
</head>
<body>
<main class="{% block main_class %}shell{% endblock %}">
{% block header %}{% endblock %} {% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<section class="hero">
<div>
<h1 class="title">{{ page_title }}</h1>
<p class="subtitle">{{ page_subtitle }}</p>
</div>
{% set nav_links = [ {"url": "/dashboard", "label": "Dashboard", "class":
"secondary"}, {"url": "/dashboard/config", "label": "Config", "class":
"secondary"}, {"url": "/dashboard/config/pairings", "label": "Pairings",
"class": "secondary"}, {"url": "/dashboard/backtesting", "label":
"Backtesting", "class": "secondary"}, {"url": "/dashboard/health", "label":
"Health", "class": "secondary"}, ] %}
<div class="toolbar">
{% for link in nav_links %}
<a
class="button{% if link.class %} {{ link.class }}{% endif %}"
href="{{ link.url }}"
{%
if
link.hx_get
%}hx-get="{{ link.hx_get }}"
hx-target="{{ link.hx_target }}"
hx-swap="{{ link.hx_swap | default('outerHTML') }}"
{%
endif
%}
>
{{ link.label }}
</a>
{% endfor %}
</div>
</section>
+16
View File
@@ -0,0 +1,16 @@
{% extends "_base.html" %} {% block title %}Audit Trail{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with page_title="Audit
Trail", page_subtitle="System activity, configuration changes, and execution
decisions." %} {% include "_header.html" %} {% endwith %} {% endblock %} {%
block content %}
<section
id="audit-shell"
hx-get="/dashboard/audit/fragment"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/audit.html" %}
</section>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Backtesting", page_subtitle="Replay controls, run status, and recent
summary reports." %} {% include "_header.html" %} {% endwith %} {% endblock %}
{% block content %}
<section
id="backtesting-shell"
hx-get="{{ panel_endpoint }}"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
>
{% include "partials/backtesting_panel.html" %}
</section>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
main_class %}shell{% endblock %} {% block header %} {% with
page_title="Configuration", page_subtitle="Runtime settings, alerts, exchange,
risk, and strategy." %} {% include "_header.html" %} {% endwith %} {% endblock
%} {% block content %}
<section
id="config-shell"
hx-get="/dashboard/fragment/config"
hx-target="this"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
{% include "partials/config.html" %}
</section>
{% endblock %}
@@ -0,0 +1,192 @@
<div class="card">
<div class="label">Alerting</div>
<label class="field checkbox">
<input name="alerts_enabled" type="checkbox" {{ alerts_enabled }} />
<span>Alerts enabled</span>
</label>
<label class="field">
<span>Min severity</span>
<select name="alert_min_severity">
{% for sev in ["info", "warning", "error", "critical"] %} {% set sel =
"selected" if alert_min_severity == sev else "" %}
<option value="{{ sev }}" {{ sel }}>{{ sev }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Dedup seconds</span>
<input
name="alert_dedup_seconds"
type="number"
min="0"
step="1"
value="{{ alert_dedup_seconds }}"
/>
</label>
<label class="field checkbox">
<input
name="alert_on_trade_events"
type="checkbox"
{{
alert_on_trade_events
}}
/>
<span>Trade events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_error_events"
type="checkbox"
{{
alert_on_error_events
}}
/>
<span>Error events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_threshold_events"
type="checkbox"
{{
alert_on_threshold_events
}}
/>
<span>Threshold events</span>
</label>
<label class="field checkbox">
<input
name="alert_on_system_events"
type="checkbox"
{{
alert_on_system_events
}}
/>
<span>System events</span>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="telegram_alerts_enabled"
type="checkbox"
{{
telegram_alerts_enabled
}}
/>
<span>Telegram</span>
</label>
<label class="field">
<span>Telegram bot token</span>
<input
name="telegram_bot_token"
type="password"
value="{{ telegram_bot_token }}"
placeholder="Bot token"
/>
</label>
<label class="field">
<span>Telegram chat ID</span>
<input
name="telegram_chat_id"
type="text"
value="{{ telegram_chat_id }}"
placeholder="Chat ID"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="discord_alerts_enabled"
type="checkbox"
{{
discord_alerts_enabled
}}
/>
<span>Discord</span>
</label>
<label class="field">
<span>Discord webhook URL</span>
<input
name="discord_webhook_url"
type="password"
value="{{ discord_webhook_url }}"
placeholder="Webhook URL"
/>
</label>
<hr
style="
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 12px 0;
"
/>
<label class="field checkbox">
<input
name="email_alerts_enabled"
type="checkbox"
{{
email_alerts_enabled
}}
/>
<span>Email</span>
</label>
<label class="field">
<span>SMTP host</span>
<input
name="email_smtp_host"
type="text"
value="{{ email_smtp_host }}"
placeholder="smtp.example.com"
/>
</label>
<label class="field">
<span>SMTP port</span>
<input
name="email_smtp_port"
type="number"
min="1"
max="65535"
value="{{ email_smtp_port }}"
/>
</label>
<label class="field">
<span>SMTP username</span>
<input
name="email_smtp_username"
type="text"
value="{{ email_smtp_username }}"
/>
</label>
<label class="field">
<span>SMTP password</span>
<input
name="email_smtp_password"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>From address</span>
<input name="email_alert_from" type="text" value="{{ email_alert_from }}" />
</label>
<label class="field">
<span>To address</span>
<input name="email_alert_to" type="text" value="{{ email_alert_to }}" />
</label>
<label class="field checkbox">
<input name="email_smtp_use_tls" type="checkbox" {{ email_smtp_use_tls }} />
<span>Use TLS</span>
</label>
</div>
@@ -0,0 +1,93 @@
<div class="card">
<div class="label">Kraken Exchange</div>
<label class="field">
<span>REST URL</span>
<input name="kraken_rest_url" type="text" value="{{ kraken_rest_url }}" />
</label>
<label class="field">
<span>WebSocket URL</span>
<input name="kraken_ws_url" type="text" value="{{ kraken_ws_url }}" />
</label>
<label class="field">
<span>Private rate limit (s)</span>
<input
name="kraken_private_rate_limit_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_private_rate_limit_seconds }}"
/>
</label>
<label class="field">
<span>HTTP timeout (s)</span>
<input
name="kraken_http_timeout_seconds"
type="number"
min="1"
step="0.5"
value="{{ kraken_http_timeout_seconds }}"
/>
</label>
<label class="field">
<span>Retry attempts</span>
<input
name="kraken_retry_attempts"
type="number"
min="0"
step="1"
value="{{ kraken_retry_attempts }}"
/>
</label>
<label class="field">
<span>Retry base delay (s)</span>
<input
name="kraken_retry_base_delay_seconds"
type="number"
min="0"
step="0.01"
value="{{ kraken_retry_base_delay_seconds }}"
/>
</label>
<label class="field">
<span>API key</span>
<input name="kraken_api_key" type="text" value="{{ kraken_api_key }}" />
</label>
<label class="field">
<span>API secret</span>
<input
name="kraken_api_secret"
type="password"
value=""
placeholder="Leave blank to keep existing"
/>
</label>
<label class="field">
<span>API key permissions</span>
<input
name="kraken_api_key_permissions"
type="text"
value="{{ kraken_api_key_permissions }}"
disabled
/>
</label>
<label class="field">
<span>WS heartbeat timeout (s)</span>
<input
name="ws_heartbeat_timeout_seconds"
type="number"
min="1"
step="1"
value="{{ ws_heartbeat_timeout_seconds }}"
/>
</label>
<label class="field">
<span>WS max staleness (s)</span>
<input
name="ws_max_staleness_seconds"
type="number"
min="1"
step="1"
value="{{ ws_max_staleness_seconds }}"
/>
</label>
</div>
@@ -0,0 +1,57 @@
<div class="card">
<div class="label">Risk & Guardrails</div>
<label class="field">
<span>Daily loss limit USD</span>
<input
name="daily_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ daily_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Cumulative loss limit USD</span>
<input
name="cumulative_loss_limit_usd"
type="number"
min="0"
step="0.01"
value="{{ cumulative_loss_limit_value }}"
/>
</label>
<label class="field">
<span>Max source latency (ms)</span>
<input
name="max_source_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_source_latency_value }}"
/>
</label>
<label class="field">
<span>Max apply latency (ms)</span>
<input
name="max_apply_latency_ms"
type="number"
min="0"
step="1"
value="{{ max_apply_latency_value }}"
/>
</label>
<label class="field">
<span>Max consecutive failures</span>
<input
name="max_consecutive_failures"
type="number"
min="0"
step="1"
value="{{ max_consecutive_failures_value }}"
/>
</label>
<label class="field checkbox">
<input name="kill_switch_active" type="checkbox" {{ kill_switch_active }} />
<span>Kill switch active</span>
</label>
</div>
@@ -0,0 +1,140 @@
<div class="card">
<div class="label">Runtime</div>
<label class="field">
<span>App env</span>
<input type="text" value="{{ app_env }}" disabled />
</label>
<label class="field">
<span>App host</span>
<input name="app_host" type="text" value="{{ app_host }}" />
</label>
<label class="field">
<span>App port</span>
<input
name="app_port"
type="number"
min="1"
max="65535"
value="{{ app_port }}"
/>
</label>
<label class="field">
<span>Log level</span>
<select name="log_level">
{% for lvl in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] %} {% set
sel = "selected" if log_level == lvl else "" %}
<option value="{{ lvl }}" {{ sel }}>{{ lvl }}</option>
{% endfor %}
</select>
</label>
<label class="field checkbox">
<input name="log_json" type="checkbox" {{ log_json }} />
<span>JSON logs</span>
</label>
<label class="field checkbox">
<input name="paper_trading_mode" type="checkbox" {{ paper_trading_mode }} />
<span>Paper trading mode</span>
</label>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field">
<span>Max exposure per asset USD</span>
<input
name="max_exposure_per_asset_usd"
type="number"
min="0"
step="0.01"
value="{{ max_exposure_per_asset_value }}"
/>
</label>
<label class="field">
<span>Quote balance asset</span>
<input
name="quote_balance_asset"
type="text"
value="{{ quote_balance_asset }}"
/>
</label>
<label class="field">
<span>Min order size USD</span>
<input
name="min_order_size_usd"
type="number"
min="0"
step="0.01"
value="{{ min_order_size_usd_value }}"
/>
</label>
<label class="field">
<span>Tradable pairs (comma-separated)</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
<option value="incremental" {{ sel }}>incremental</option>
{% set sel = "selected" if strategy_mode == "paper" else "" %}
<option value="paper" {{ sel }}>paper</option>
{% set sel = "selected" if strategy_mode == "live" else "" %}
<option value="live" {{ sel }}>live</option>
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
strategy_mode == "stat_arb_experiment" else "" %}
<option value="stat_arb_experiment" {{ sel }}>stat_arb_experiment</option>
{% endif %}
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
</div>
+156
View File
@@ -0,0 +1,156 @@
{% extends "_base.html" %} {% block title %}{{ title }}{% endblock %} {% block
head_scripts %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
{% endblock %} {% block header %} {% with page_title="Arbitrade Dashboard",
page_subtitle="Live execution, P&L, and system state." %} {% include
"_header.html" %} {% endwith %} {% endblock %} {% block main_class %}shell{%
endblock %} {% block content %}
<section
id="metrics-shell"
hx-get="{{ metrics_endpoint }}"
hx-target="this"
hx-trigger="load, every 15s"
hx-swap="outerHTML"
>
{% include "partials/metrics.html" %}
</section>
<section
id="overview-shell"
hx-get="{{ overview_endpoint }}"
hx-target="this"
hx-trigger="load, every 10s"
hx-swap="outerHTML"
>
{% include "partials/overview.html" %}
</section>
<section
id="controls-shell"
hx-get="{{ controls_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/controls.html" %}
</section>
<section
id="charts-shell"
hx-get="{{ charts_endpoint }}"
hx-target="this"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
{% include "partials/charts.html" %}
</section>
{% endblock %} {% block scripts %}
<script>
window.arbitradeRenderCharts = (payload) => {
const chartHost = document.getElementById("opportunity-chart");
if (!chartHost || typeof Chart === "undefined") {
return;
}
const existing = Chart.getChart(chartHost);
if (existing) {
existing.destroy();
}
const data = JSON.parse(payload);
if (!data.has_chart_data) {
return;
}
new Chart(chartHost, {
type: "line",
data: {
labels: data.labels,
datasets: [
{
label: "Net %",
data: data.net_pct_values,
borderColor: "#2d6cdf",
backgroundColor: "rgba(45, 108, 223, 0.18)",
tension: 0.3,
fill: true,
yAxisID: "y",
},
{
label: "Est profit USD",
data: data.est_profit_values,
borderColor: "#52c41a",
backgroundColor: "rgba(82, 196, 26, 0.12)",
tension: 0.3,
fill: false,
yAxisID: "y1",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
plugins: {
legend: {
labels: {
color: "#e5eefb",
},
},
},
scales: {
x: {
ticks: {
color: "#9fb2d0",
maxRotation: 0,
autoSkip: true,
},
grid: {
color: "rgba(255, 255, 255, 0.06)",
},
},
y: {
position: "left",
ticks: {
color: "#9fb2d0",
},
grid: {
color: "rgba(255, 255, 255, 0.06)",
},
},
y1: {
position: "right",
ticks: {
color: "#9fb2d0",
},
grid: {
drawOnChartArea: false,
},
},
},
},
});
};
const stream = new EventSource("{{ stream_endpoint }}");
stream.addEventListener("metrics", (event) => {
const panel = document.getElementById("metrics-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
const overviewStream = new EventSource("{{ overview_stream_endpoint }}");
overviewStream.addEventListener("overview", (event) => {
const panel = document.getElementById("overview-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
</script>
{% endblock %}
@@ -0,0 +1,153 @@
{% extends "base.html" %} {% block title %}Currency Pairings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Currency Pairings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<!-- Create Pairing Form -->
<div class="card mb-4">
<div class="card-header">
<h5>Create New Pairing</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/pairs/create">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="base_asset" class="form-label">Base Asset</label>
<input
type="text"
class="form-control"
id="base_asset"
name="base_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="quote_asset" class="form-label"
>Quote Asset</label
>
<input
type="text"
class="form-control"
id="quote_asset"
name="quote_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="source" class="form-label">Source</label>
<input
type="text"
class="form-control"
id="source"
name="source"
placeholder="e.g., Kraken, Binance"
/>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input
type="checkbox"
class="form-check-input"
id="enabled"
name="enabled"
checked
/>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<button type="submit" class="btn btn-primary">
Create Pairing
</button>
</form>
</div>
</div>
<!-- Existing Pairings Table -->
<div class="card">
<div class="card-header">
<h5>Existing Pairings</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Base Asset</th>
<th>Quote Asset</th>
<th>Enabled</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>{{ pairing.base_asset }}</td>
<td>{{ pairing.quote_asset }}</td>
<td>
{% if pairing.enabled %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</td>
<td>{{ pairing.source or '—' }}</td>
<td>
<form
method="post"
action="/dashboard/config/pairs/delete"
style="display: inline"
>
<input
type="hidden"
name="base_asset"
value="{{ pairing.base_asset }}"
/>
<input
type="hidden"
name="quote_asset"
value="{{ pairing.quote_asset }}"
/>
<button
type="submit"
class="btn btn-sm btn-danger"
onclick="
return confirm(
'Are you sure you want to delete this pairing?',
);
"
>
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %} {% block title %}Application Settings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Application Settings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Settings</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/settings/save">
{% for section_name, settings in settings_by_section.items() %}
<div class="mb-4">
<h5>{{ section_name }}</h5>
{% for setting in settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label"
>{{ setting.key }}</label
>
<input
type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value_json }}"
/>
</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "_base.html" %} {% block title %}Arbitrade Health Check{% endblock %}
{% block header %} {% with page_title="Arbitrade Health Check",
page_subtitle="Live system state and logs." %} {% include "_header.html" %} {%
endwith %} {% endblock %} {% block main_class %}shell{% endblock %} {% block
content %}
<section class="card" style="margin-bottom: 24px">
<h1>Arbitrade Bootstrap Complete</h1>
<p><span class="badge">Status: {{ status }}</span></p>
<p>UTC: {{ time }}</p>
<p>
Health JSON:
<a
href="/health"
hx-get="/health"
hx-target="#health-json"
hx-swap="innerHTML"
>refresh</a
>
</p>
<pre id="health-json">{"status":"ok","service":"arbitrade"}</pre>
</section>
<section class="card">
<h2>System Logs</h2>
<div class="toolbar" style="display: flex; gap: 8px; margin-bottom: 12px">
<form
hx-post="/dashboard/api/logging/aggregate"
hx-target="#aggregate-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Aggregate Now
</button>
</form>
<form
hx-post="/dashboard/api/logging/archive"
hx-target="#archive-result"
hx-swap="innerHTML"
style="display: inline"
>
<button type="submit" class="button secondary" style="font-size: 0.85rem">
Archive Old Logs
</button>
</form>
<span id="aggregate-result" style="font-size: 0.85rem; opacity: 0.6"></span>
<span id="archive-result" style="font-size: 0.85rem; opacity: 0.6"></span>
</div>
<div
id="log-table-container"
hx-get="/dashboard/fragment/logs"
hx-trigger="load, every 30s"
hx-swap="outerHTML"
>
<div style="text-align: center; padding: 20px; opacity: 0.5">
Loading logs...
</div>
</div>
</section>
{% endblock %} {% block scripts %}{% endblock %}

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