Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 809f46fe36 | |||
| 413abafe0f | |||
| 5d46c1e32c | |||
| 889f7ba1c3 | |||
| 1515b2d077 | |||
| b4ae3e715d | |||
| 6f845d32c6 | |||
| a04e8878fb |
+5
-18
@@ -1,24 +1,11 @@
|
||||
# SpecStory explanation file
|
||||
__pycache__/
|
||||
__OLD__/
|
||||
.specstory/
|
||||
.history/
|
||||
.vscode/
|
||||
*.py[cod]
|
||||
.ipynb_checkpoints/
|
||||
.pytest_cache/
|
||||
|
||||
# Local environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Local test data and generated analysis results
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
results/*
|
||||
!results/.gitkeep
|
||||
|
||||
.cursorindexingignore
|
||||
data
|
||||
|
||||
####.vscode/
|
||||
cvttpy
|
||||
tmp/
|
||||
# SpecStory explanation file
|
||||
.specstory/.what-is-this.md
|
||||
results/
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Repository purpose
|
||||
|
||||
This repository analyzes test results with Jupyter notebooks and Python or
|
||||
Bash scripts. Inputs are commonly SQLite databases containing time-series data
|
||||
and JSON columns, but analyses may use other test-result formats.
|
||||
|
||||
Ignore `__SAV__/`. It is unrelated legacy material, is not part of the active
|
||||
project, and must not be read, edited, moved, or used as a source of conventions
|
||||
unless the user explicitly requests it.
|
||||
|
||||
## Active layout
|
||||
|
||||
- `notebooks/`: exploratory and report-oriented Jupyter notebooks.
|
||||
- `scripts/`: reusable Python and Bash analysis utilities.
|
||||
- `data/`: local input data. Contents are ignored except for `.gitkeep`.
|
||||
- `results/`: generated tables, figures, exports, and reports. Contents are
|
||||
ignored except for `.gitkeep`.
|
||||
- `requirements.txt`: Python dependencies needed to reproduce repository work.
|
||||
|
||||
Keep reusable logic in `scripts/` and use notebooks to orchestrate analysis,
|
||||
explain decisions, and present results. Do not create a separate `analysis/`
|
||||
tree.
|
||||
|
||||
## Python environment
|
||||
|
||||
The intended virtual environment is `~/.pyenv/python3.12-venv`.
|
||||
|
||||
```bash
|
||||
source ~/.pyenv/python3.12-venv/bin/activate
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Agents may install packages in this environment when needed. Whenever a package
|
||||
is installed for repository work, update `requirements.txt` in the same change
|
||||
with a suitable direct dependency declaration. Use `python -m pip`, not bare
|
||||
`pip`, in documented commands.
|
||||
|
||||
Do not create an in-repository virtual environment unless the user asks for
|
||||
one.
|
||||
|
||||
## Data handling
|
||||
|
||||
- Treat files in `data/` as local, potentially large, and potentially
|
||||
sensitive.
|
||||
- Do not commit SQLite databases, raw test results, or generated results.
|
||||
- Do not modify source data in place. Write transformed data and exports under
|
||||
`results/`.
|
||||
- Use parameterized SQL for values. Do not construct SQL by interpolating
|
||||
untrusted data.
|
||||
- Parse JSON columns defensively and preserve missing, malformed, and unexpected
|
||||
values unless the analysis explicitly defines another policy.
|
||||
- State assumptions about timestamps, time zones, ordering, units, and duplicate
|
||||
observations in the notebook or script that relies on them.
|
||||
- Avoid loading entire databases into memory when a filtered query or chunked
|
||||
read is practical.
|
||||
|
||||
## Notebook conventions
|
||||
|
||||
- A notebook must run from a fresh kernel, top to bottom, without relying on
|
||||
hidden interactive state.
|
||||
- Set random seeds where nondeterminism affects results.
|
||||
- Keep data paths relative to the repository root and avoid machine-specific
|
||||
absolute paths.
|
||||
- Move logic that is reused or substantial enough to test into `scripts/`.
|
||||
- Clear cell outputs before committing notebooks. Never commit embedded source
|
||||
data, credentials, or bulky generated output.
|
||||
- Keep concise Markdown context near analyses: purpose, input assumptions,
|
||||
method, and interpretation.
|
||||
|
||||
## Scripts
|
||||
|
||||
- Python scripts should expose reusable functions and use a guarded CLI entry
|
||||
point when executable.
|
||||
- Bash scripts must start with `#!/usr/bin/env bash` and use
|
||||
`set -euo pipefail`.
|
||||
- Prefer explicit CLI arguments over hard-coded paths or parameters.
|
||||
- Fail with actionable error messages when required data, tables, columns, or
|
||||
configuration are missing.
|
||||
|
||||
## Verification
|
||||
|
||||
Verification should be proportional to the change. At minimum:
|
||||
|
||||
- Run `pytest` for Python script changes.
|
||||
- Add or update tests for reusable parsing, transformation, query, and
|
||||
calculation logic.
|
||||
- Execute changed notebooks from a fresh kernel with `nbmake`.
|
||||
- Run changed Bash scripts against a safe fixture or exercise their
|
||||
non-destructive validation/help path.
|
||||
- Clear notebook outputs after execution and before committing.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
python -m pytest --nbmake notebooks
|
||||
jupyter nbconvert --ClearOutputPreprocessor.enabled=True --inplace path/to/notebook.ipynb
|
||||
```
|
||||
|
||||
If verification cannot be run, report exactly what was skipped and why.
|
||||
|
||||
## Release rules
|
||||
|
||||
- Update `CHANGELOG.md` for every release with the release version, release
|
||||
date, Git tag, and a concise summary of notable changes.
|
||||
- Keep an `Unreleased` section at the top of `CHANGELOG.md` for changes that
|
||||
have not been included in a tagged release yet.
|
||||
- Move relevant entries from `Unreleased` into the dated release section when
|
||||
creating a release, and leave `Unreleased` present for future changes.
|
||||
- Use release headers in `YYYY-MM-DD vMAJOR.MINOR.PATCH` form.
|
||||
- Use version numbers in `MAJOR.MINOR.PATCH` form. Start this repository at
|
||||
`0.0.1`.
|
||||
- Use Git tags in `vMAJOR.MINOR.PATCH` form, matching the changelog version
|
||||
exactly. For example, version `0.0.1` must be tagged as `v0.0.1`.
|
||||
- Create the Git tag only after the changelog and any release-related version
|
||||
changes are complete.
|
||||
- When the user requests creating a release, treat that as explicit permission
|
||||
to commit the release changes, create the matching Git tag, and push both the
|
||||
branch and tag.
|
||||
- Do not push release commits or tags unless the user explicitly requests it.
|
||||
|
||||
## Mandatory background review
|
||||
|
||||
Changes to Python scripts, Bash scripts, or notebook code cells require approval
|
||||
from a separate background reviewer agent before the implementing agent may
|
||||
declare the work complete.
|
||||
|
||||
The implementing agent must:
|
||||
|
||||
1. Finish the implementation and run the relevant verification.
|
||||
2. Ask a separate background agent to review the diff for correctness,
|
||||
reproducibility, data safety, and test coverage.
|
||||
3. Address every material finding, rerun affected checks, and request follow-up
|
||||
review when the fix materially changes the code.
|
||||
4. Report the reviewer outcome in the final response.
|
||||
|
||||
The reviewer must inspect the actual diff and relevant surrounding files; a
|
||||
self-review does not satisfy this requirement. Documentation-only,
|
||||
configuration-only, dependency-only, and ignore-rule-only changes do not
|
||||
require background approval unless they also alter Python, Bash, or notebook
|
||||
code cells.
|
||||
|
||||
If no background reviewer is available, complete all other work but do not
|
||||
claim reviewer approval. End the handoff with the exact status:
|
||||
|
||||
`review pending`
|
||||
|
||||
## Change discipline
|
||||
|
||||
- Preserve user changes and avoid unrelated cleanup.
|
||||
- Do not edit or commit generated files from `data/` or `results/`.
|
||||
- Do not push or commit unless the user explicitly requests it. The `master`
|
||||
branch being unprotected does not imply permission to push directly.
|
||||
- Keep changes focused and explain any new assumptions or dependencies.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
No unreleased changes yet.
|
||||
|
||||
## 2026-07-30 v1.0.4
|
||||
|
||||
- Updated notebook and Panel analysis for the SP Quant result database schema,
|
||||
including explicit `trading_instructions` columns for action, assets,
|
||||
scaled disequilibrium, and beta.
|
||||
- Changed selected-pair market charts to read from the `market` table and kept
|
||||
legacy packed instruction JSON support for older result databases.
|
||||
- Added `scaled_disequilibrium` and `beta` to selected-pair theoretical
|
||||
execution displays.
|
||||
- Improved VS Code notebook usability with the `jupyter_bokeh` dependency,
|
||||
direct Plotly figure rendering, and a dropdown Analyze control for individual
|
||||
pair selection.
|
||||
- Made the Panel app use the dark theme by default and reduced the sidebar
|
||||
width from 430 px to 215 px with responsive sidebar controls.
|
||||
- Expanded tests and notebook verification coverage for the new database schema
|
||||
and Panel layout defaults.
|
||||
|
||||
## 2026-07-29 v1.0.3
|
||||
|
||||
- Removed invalid fixed sizing mode from Panel Tabulator grids to avoid Bokeh
|
||||
layout warnings while preserving compact table layout.
|
||||
- Changed the Panel Calculate action to refresh the result-file list before
|
||||
loading data and removed the standalone Panel Refresh button.
|
||||
|
||||
## 2026-07-29 v1.0.2
|
||||
|
||||
- Added a Panel application for single-day SPBT result analysis with result-file
|
||||
selection, minimum TARGET-change input, pair TheoRet table, pair selector,
|
||||
selected-pair execution table, and market/trade chart.
|
||||
- Added a launcher script for the Panel application.
|
||||
- Changed notebook and Panel pair analysis to use per-row Analyze actions from
|
||||
the Pair TheoRet grid, deferring selected-pair calculations until clicked.
|
||||
- Adjusted Panel sizing so key controls use compact widths and Pair TheoRet uses
|
||||
content width with vertical scrolling instead of full-width paginated layout.
|
||||
- Added a FastListTemplate shell to the Panel application for sidebar controls
|
||||
and configurable app color accents.
|
||||
- Made Plotly chart panes use all available horizontal space.
|
||||
|
||||
## 2026-07-28 v1.0.1
|
||||
|
||||
- Added the `spbt_day` notebook for interactive single-day backtest result
|
||||
analysis, including SQLite result file selection from the local data
|
||||
directory.
|
||||
- Added selector-pair loading and dense ranking by `mr_score.final`, preserving
|
||||
rows with invalid score JSON for inspection.
|
||||
- Added theoretical return calculation for ranked pairs from
|
||||
`trading_instructions`, including reusable helper functions and tests.
|
||||
- Added a Plotly histogram for visual analysis of total theoretical return by
|
||||
pair.
|
||||
- Moved notebook support code into reusable `scripts/spbt_day.py` helpers.
|
||||
- Adjusted notebook table outputs to show all relevant rows and reduce
|
||||
redundant intermediate displays.
|
||||
- Added an alphabetically sorted pair selector for individual pair analysis.
|
||||
- Added selected-pair theoretical execution tables and aligned TheoRet
|
||||
calculations with target-delta trade generation.
|
||||
- Added per-asset `strength` values to selected-pair theoretical execution
|
||||
tables.
|
||||
- Corrected theoretical execution size to use
|
||||
`10000 * strength / reference_price`.
|
||||
- Removed `:USD` quote suffixes from displayed pair names in notebook tables,
|
||||
chart hovers, and the pair selector dropdown while preserving full internal
|
||||
pair keys for calculations.
|
||||
- Added `num_trades` to pair TheoRet summaries, counting asset-level theoretical
|
||||
trades from effective `TARGET` and `CLOSE` instructions.
|
||||
- Added sortable interactive grids for the pair TheoRet and selected-pair
|
||||
theoretical execution tables.
|
||||
- Styled interactive dataframe grids with black text on white backgrounds for
|
||||
readability across notebook themes.
|
||||
- Added a selected-pair Plotly chart that overlays theoretical BUY/SELL
|
||||
executions on relative 1-minute market close data for both instruments.
|
||||
- Anchored the selected-pair market chart at trading-day midnight and normalized
|
||||
relative prices to each instrument's close at that timestamp.
|
||||
- Added a `min_pctg_change` threshold for ranked pair TheoRet calculations to
|
||||
skip small target-strength changes after a position is acquired.
|
||||
- Added a notebook input field for the minimum TARGET strength-change threshold.
|
||||
|
||||
## 2026-07-25 v0.0.9
|
||||
|
||||
- Added contributing guidance and Python dependency declarations.
|
||||
- Added placeholder files for active project directories.
|
||||
- Updated ignore rules for local data, generated results, caches, and local
|
||||
environments.
|
||||
- Documented unreleased changelog handling and release push behavior.
|
||||
|
||||
## 2026-07-25 v0.0.1
|
||||
|
||||
- Established the initial repository structure and project guidance.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Setup
|
||||
|
||||
Use the shared Python 3.12 virtual environment:
|
||||
|
||||
```bash
|
||||
source ~/.pyenv/python3.12-venv/bin/activate
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
If you install another package for repository work, add its direct dependency
|
||||
to `requirements.txt`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- Put notebooks in `notebooks/`.
|
||||
- Put reusable Python and Bash utilities in `scripts/`.
|
||||
- Put local input files in `data/`.
|
||||
- Put generated artifacts in `results/`.
|
||||
|
||||
The contents of `data/` and `results/` are ignored. Do not force-add test
|
||||
databases, raw test results, generated exports, or notebook outputs.
|
||||
|
||||
`__SAV__/` is unrelated legacy material and is outside the active project.
|
||||
|
||||
## Working with notebooks
|
||||
|
||||
Notebooks must execute from top to bottom in a fresh kernel. Use relative paths,
|
||||
document data assumptions, and move reusable logic into tested scripts.
|
||||
|
||||
Before handing off a change:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
python -m pytest --nbmake notebooks
|
||||
jupyter nbconvert --ClearOutputPreprocessor.enabled=True --inplace path/to/notebook.ipynb
|
||||
```
|
||||
|
||||
Run only the checks relevant to the files present in the repository, and report
|
||||
anything that could not be run.
|
||||
|
||||
## Review requirement
|
||||
|
||||
Python scripts, Bash scripts, and notebook code-cell changes require review and
|
||||
approval by a separate background agent. Address material findings and rerun
|
||||
affected checks before completion. If a reviewer is unavailable, the change may
|
||||
be handed off only with the status `review pending`.
|
||||
|
||||
Documentation, dependency declarations, and ignore rules do not require this
|
||||
background review when no Python, Bash, or notebook code cells changed.
|
||||
|
||||
The `master` branch is not protected. That does not remove the review
|
||||
requirement or authorize an agent to commit or push without an explicit request.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Enhanced Pairs Trading Backtest Usage Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced `pt_backtest.py` script now supports multi-day and multi-instrument backtesting with SQLite database output. This guide explains how to use the new features.
|
||||
|
||||
## New Features
|
||||
|
||||
### 1. Multi-Day Data Processing
|
||||
- Process multiple data files in a single run
|
||||
- Support for wildcard patterns in configuration files
|
||||
- CLI override for data file specification
|
||||
|
||||
|
||||
### 2. Dynamic Instrument Selection
|
||||
- Auto-detection of instruments from database
|
||||
- CLI override for instrument specification
|
||||
- No need to manually update configuration files
|
||||
|
||||
### 3. SQLite Database Output
|
||||
- Automated storage of backtest results
|
||||
- Structured data format for analysis
|
||||
- Optional database output (can be disabled)
|
||||
|
||||
## Command Line Arguments
|
||||
|
||||
### Required Arguments
|
||||
- `--config`: Path to configuration file
|
||||
- `--result_db`: Path to SQLite database for results (use "NONE" to disable)
|
||||
|
||||
### Optional Arguments
|
||||
- `--datafiles`: Comma-separated list of data files (overrides config)
|
||||
- `--instruments`: Comma-separated list of instruments (overrides auto-detection)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage (Auto-detect instruments, use config datafiles)
|
||||
```bash
|
||||
python src/pt_backtest.py --config configuration/crypto.cfg --result_db results.db
|
||||
```
|
||||
|
||||
### Specify Instruments via CLI
|
||||
```bash
|
||||
python src/pt_backtest.py \
|
||||
--config configuration/crypto.cfg \
|
||||
--result_db results.db \
|
||||
--instruments "BTC-USDT,ETH-USDT,ADA-USDT"
|
||||
```
|
||||
|
||||
### Override Data Files via CLI
|
||||
```bash
|
||||
python src/pt_backtest.py \
|
||||
--config configuration/crypto.cfg \
|
||||
--result_db results.db \
|
||||
--datafiles "20250528.mktdata.ohlcv.db,20250529.mktdata.ohlcv.db"
|
||||
```
|
||||
|
||||
### Complete Override (Custom instruments and data files)
|
||||
```bash
|
||||
python src/pt_backtest.py \
|
||||
--config configuration/crypto.cfg \
|
||||
--result_db results.db \
|
||||
--instruments "BTC-USDT,ETH-USDT" \
|
||||
--datafiles "20250528.mktdata.ohlcv.db,20250529.mktdata.ohlcv.db"
|
||||
```
|
||||
|
||||
### Disable Database Output
|
||||
```bash
|
||||
python src/pt_backtest.py \
|
||||
--config configuration/crypto.cfg \
|
||||
--result_db NONE
|
||||
```
|
||||
|
||||
## Configuration File Updates
|
||||
|
||||
### Wildcard Support in Data Files
|
||||
The configuration file now supports wildcards in the `datafiles` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"datafiles": [
|
||||
"2025*.mktdata.ohlcv.db",
|
||||
"specific_file.db",
|
||||
"202405*.mktdata.ohlcv.db"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Patterns
|
||||
You can specify multiple wildcard patterns:
|
||||
|
||||
```json
|
||||
{
|
||||
"datafiles": [
|
||||
"202405*.mktdata.ohlcv.db",
|
||||
"202406*.mktdata.ohlcv.db",
|
||||
"special_data.db"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The script creates a `pt_bt_results` table with the following schema:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| date | DATE | Trading date extracted from filename |
|
||||
| pair | TEXT | Trading pair name (e.g., "BTC-USDT & ETH-USDT") |
|
||||
| symbol | TEXT | Individual symbol (e.g., "BTC-USDT") |
|
||||
| open_time | DATETIME | Trade opening time |
|
||||
| open_side | TEXT | Opening side (BUY/SELL) |
|
||||
| open_price | REAL | Opening price |
|
||||
| open_quantity | INTEGER | Opening quantity |
|
||||
| open_disequilibrium | REAL | Disequilibrium at opening |
|
||||
| close_time | DATETIME | Trade closing time |
|
||||
| close_side | TEXT | Closing side (BUY/SELL) |
|
||||
| close_price | REAL | Closing price |
|
||||
| close_quantity | INTEGER | Closing quantity |
|
||||
| close_disequilibrium | REAL | Disequilibrium at closing |
|
||||
| symbol_return | REAL | Individual symbol return (%) |
|
||||
| pair_return | REAL | Combined pair return (%) |
|
||||
|
||||
## Auto-Detection Logic
|
||||
|
||||
### Instrument Auto-Detection
|
||||
When `--instruments` is not specified, the script:
|
||||
1. Connects to each data file
|
||||
2. Queries distinct `instrument_id` values from the configured table
|
||||
3. Removes the configured prefix (`instrument_id_pfx`)
|
||||
4. Uses the resulting symbols for pair generation
|
||||
|
||||
### Data File Resolution
|
||||
The script resolves data files in this order:
|
||||
1. If `--datafiles` is specified, use those files
|
||||
2. Otherwise, process each pattern in config `datafiles`:
|
||||
- Expand wildcards using `glob.glob()`
|
||||
- Resolve relative paths using `data_directory`
|
||||
- Remove duplicates and sort
|
||||
|
||||
## Output
|
||||
|
||||
### Console Output
|
||||
- Lists all data files to be processed
|
||||
- Shows auto-detected or specified instruments
|
||||
- Displays trade signals for each file
|
||||
- Prints returns by day and pair
|
||||
- Shows grand totals and outstanding positions
|
||||
|
||||
### Database Output
|
||||
- Creates database and table automatically
|
||||
- Stores detailed trade information
|
||||
- Includes calculated returns
|
||||
- One record per symbol per trade
|
||||
|
||||
## Error Handling
|
||||
|
||||
The script includes comprehensive error handling:
|
||||
- Invalid data files are skipped with warnings
|
||||
- Database connection errors are reported
|
||||
- Auto-detection failures fall back gracefully
|
||||
- Processing errors are logged with stack traces
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Wildcard expansion happens once at startup
|
||||
- Database connections are opened/closed per operation
|
||||
- Large numbers of files are processed sequentially
|
||||
- Memory usage scales with the number of instruments and data points
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No instruments found**: Check that the database contains data for the specified exchange_id
|
||||
2. **No data files found**: Verify wildcard patterns and data_directory path
|
||||
3. **Database errors**: Ensure write permissions for the result database path
|
||||
4. **Memory issues**: Consider processing fewer files at once or reducing instrument count
|
||||
|
||||
### Debug Tips
|
||||
|
||||
- Use `--result_db NONE` to disable database output during testing
|
||||
- Start with a small set of instruments using `--instruments`
|
||||
- Test with explicit file lists using `--datafiles` before using wildcards
|
||||
- Check console output for detailed processing information
|
||||
@@ -0,0 +1,132 @@
|
||||
# Pairs Trading Backtest
|
||||
|
||||
This document provides a guide to understanding, configuring, and running the pairs trading backtest system.
|
||||
|
||||
## Overview
|
||||
|
||||
The system is designed to backtest pairs trading strategies on historical market data.
|
||||
It allows users to select different strategies, configure parameters, and analyze the
|
||||
performance of these strategies.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Trading Pair
|
||||
A trading pair consists of two financial instruments (e.g., stocks or cryptocurrencies)
|
||||
whose prices are believed to have a long-term statistical relationship (cointegration).
|
||||
The strategy aims to profit from temporary deviations from this relationship.
|
||||
|
||||
### Strategy
|
||||
The system supports different strategies for identifying and exploiting trading opportunities. Each strategy has its own set of configurable parameters.
|
||||
|
||||
### Trading Signals
|
||||
Trading signals indicate when to open or close a position based on the configured strategy
|
||||
and parameters. These signals are typically generated when the "dis-equilibrium" (the
|
||||
deviation from the long-term relationship) crosses certain thresholds.
|
||||
|
||||
## Running a Backtest
|
||||
|
||||
### 1. Configuration
|
||||
|
||||
The primary configuration for the backtest is managed in the `src/pt_backtest.py` file. Here, you will define which dataset to use (cryptocurrencies or equities) and which strategy to employ.
|
||||
|
||||
#### Choosing a Dataset:
|
||||
You can switch between `CRYPTO_CONFIG` and `EQT_CONFIG` by uncommenting the desired configuration block:
|
||||
|
||||
```python
|
||||
# CONFIG = CRYPTO_CONFIG # For cryptocurrency data
|
||||
CONFIG = EQT_CONFIG # For equity data
|
||||
```
|
||||
|
||||
Each configuration dictionary specifies:
|
||||
- `data_directory`: Path to the data files.
|
||||
- `datafiles`: A list of database files to process. You can comment/uncomment specific files to include/exclude them from the backtest.
|
||||
- `db_table_name`: The name of the table within the SQLite database.
|
||||
- `instruments`: A list of symbols to consider for forming trading pairs.
|
||||
- `trading_hours`: Defines the session start and end times, crucial for equity markets.
|
||||
- `stat_model_price`: The column in the data to be used as the price (e.g., "close").
|
||||
- `dis-equilibrium_open_trshld`: The threshold (in standard deviations) of the dis-equilibrium for opening a trade.
|
||||
- `dis-equilibrium_close_trshld`: The threshold (in standard deviations) of the dis-equilibrium for closing an open trade.
|
||||
- `training_minutes`: The length of the rolling window (in minutes) used to train the model (e.g., calculate cointegration, mean, and standard deviation of the dis-equilibrium).
|
||||
- `funding_per_pair`: The amount of capital allocated to each trading pair.
|
||||
|
||||
#### Choosing a Strategy:
|
||||
The system currently offers two main strategies: `StaticFitStrategy` and `SlidingFitStrategy`. You select a strategy by instantiating it:
|
||||
|
||||
```python
|
||||
# STRATEGY = StaticFitStrategy()
|
||||
STRATEGY = SlidingFitStrategy()
|
||||
```
|
||||
|
||||
- **`StaticFitStrategy`**: This strategy fits the cointegration model once at the beginning
|
||||
of each trading day (or for the entire dataset if run on a single file without a rolling
|
||||
window logic in the strategy itself). The parameters (mean, standard deviation of
|
||||
dis-equilibrium) derived from this initial fit are used for generating trading signals
|
||||
throughout the day.
|
||||
- **Pros**: Simpler, computationally less intensive.
|
||||
- **Cons**: May not adapt well to changing market conditions during the day.
|
||||
|
||||
- **`SlidingFitStrategy`**: This strategy uses a rolling window approach. The cointegration model and its parameters are re-estimated at regular intervals (defined by `training_minutes` and how the strategy implements the sliding window). This allows the strategy to adapt to evolving market dynamics.
|
||||
- **Pros**: More adaptive to changing market conditions.
|
||||
- **Cons**: Computationally more intensive. The `training_minutes` parameter is crucial here as it defines the look-back period for each re-estimation.
|
||||
|
||||
### 2. Parameters for Trading Signals
|
||||
|
||||
The key parameters that determine trading signals are primarily found within the `CONFIG` dictionaries:
|
||||
|
||||
- **`dis-equilibrium_open_trshld`**: This is the number of standard deviations the current dis-equilibrium must move away from its mean (calculated during the training period) to trigger an opening signal.
|
||||
- A *higher* value means the strategy will wait for a more significant deviation before entering a trade, leading to fewer but potentially more robust signals.
|
||||
- A *lower* value means the strategy will enter trades on smaller deviations, leading to more frequent signals but potentially more false positives.
|
||||
|
||||
- **`dis-equilibrium_close_trshld`**: This is the number of standard deviations the current dis-equilibrium must revert towards its mean (from its peak deviation) to trigger a closing signal.
|
||||
- A *higher* value (closer to the `dis-equilibrium_open_trshld`) means the strategy will close trades more quickly as the dis-equilibrium starts to revert.
|
||||
- A *lower* value (closer to zero) means the strategy will hold onto trades longer, waiting for the dis-equilibrium to revert more significantly towards the mean.
|
||||
|
||||
- **`training_minutes`**:
|
||||
- For `StaticFitStrategy`, this determines the initial period of data used to establish the cointegration relationship and calculate the baseline dis-equilibrium statistics for the entire trading day (or dataset portion being processed).
|
||||
- For `SlidingFitStrategy`, this defines the length of the rolling window. The model is refit using data from the most recent `training_minutes` period. A shorter window makes the strategy more responsive to recent price action but might be more prone to noise. A longer window provides a more stable model but might be slower to adapt to new trends.
|
||||
|
||||
### 3. Running the Script
|
||||
|
||||
Once the configuration is set, you can run the backtest from your terminal:
|
||||
|
||||
```bash
|
||||
python src/pt_backtest.py
|
||||
```
|
||||
|
||||
The script will process each datafile specified in the `CONFIG`, create all possible unique pairs from the `instruments` list, and apply the chosen strategy.
|
||||
|
||||
### 4. Interpreting Results
|
||||
|
||||
The script will output:
|
||||
- Progress messages for each datafile being processed.
|
||||
- A summary of trades taken.
|
||||
- Grand totals of performance metrics (PnL, etc.).
|
||||
- A list of any outstanding positions at the end of the backtest.
|
||||
|
||||
The core logic for a pair involves:
|
||||
1. **Data Preparation**: For each pair, relevant price series are extracted.
|
||||
2. **Training Phase** (for `SlidingFitStrategy`, this happens repeatedly; for `StaticFitStrategy`, typically once per day/file):
|
||||
* The `get_datasets()` method in `TradingPair` splits data into training and testing sets.
|
||||
* `check_cointegration()` uses the Johansen test to see if the pair's price series are cointegrated within the current training window. If not, the pair is often skipped for that window.
|
||||
* If cointegrated, `fit_VECM()` estimates a Vector Error Correction Model (VECM). The `beta` coefficients from this model define the cointegrating relationship (the "spread" or "dis-equilibrium series").
|
||||
* `training_mu_` (mean) and `training_std_` (standard deviation) of this dis-equilibrium series are calculated. These are crucial for scaling the dis-equilibrium and setting trade thresholds.
|
||||
3. **Prediction/Trading Phase**:
|
||||
* The strategy iterates through the "testing" data points.
|
||||
* For each point, the current dis-equilibrium is calculated using the `beta` from the VECM.
|
||||
* This dis-equilibrium is then scaled: `(current_disequilibrium - training_mu_) / training_std_`.
|
||||
* This scaled value is compared against `dis-equilibrium_open_trshld` and `dis-equilibrium_close_trshld` to generate buy/sell/close signals.
|
||||
|
||||
## Customizing and Extending
|
||||
|
||||
- **Adding New Strategies**: Create a new class that inherits from a base strategy class (if one exists) or implements a similar interface to `StaticFitStrategy` or `SlidingFitStrategy`. The core method to implement would be `run_pair()`.
|
||||
- **Modifying Data Loading**: The `tools/data_loader.py` can be modified to support different data formats or sources.
|
||||
- **Changing Cointegration/Model Parameters**: The `TradingPair` class houses the VECM fitting and cointegration checks. You can adjust parameters like `k_ar_diff` in `coint_johansen` or the `VECM` model itself.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- **Data Quality**: Ensure your market data is clean, accurate, and properly formatted. Gaps or errors in data can significantly impact backtest results.
|
||||
- **Transaction Costs**: The current backtest might not explicitly model transaction costs (brokerage fees, slippage). These can have a significant impact on the profitability of high-frequency strategies. Consider adding a cost model to `BacktestResult` or within the strategy execution.
|
||||
- **Look-ahead Bias**: Be extremely careful to avoid look-ahead bias. Ensure that decisions at any point in time are made using only information that would have been available at that time. The use of `training_df_` and `testing_df_` in `TradingPair` is designed to help prevent this.
|
||||
- **Overfitting**: When optimizing parameters (`dis-equilibrium_open_trshld`, `training_minutes`, etc.), be mindful of overfitting to the historical data. A strategy that performs exceptionally well on past data may not perform well in the future. Use out-of-sample testing or walk-forward optimization for more robust validation.
|
||||
|
||||
This tutorial should provide a solid foundation for working with the pairs trading backtest system. Experiment with different configurations and strategies to find what works best for your chosen markets and instruments.
|
||||
@@ -1,9 +0,0 @@
|
||||
- [ ] Add disequilibrium chart
|
||||
- [ ] Add scatter chart for `mr-rank <--> realized pnl`
|
||||
|
||||
# DONE
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- [x] Change notebook and panel (stat_pairs_backtest) to use sp_quant's database tables `trading_instructions` and `market`, to have *disequilibrium* and *beta*
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"security_type": "EQUITY",
|
||||
"data_directory": "./data/equity",
|
||||
"datafiles": [
|
||||
"20250618.mktdata.ohlcv.db",
|
||||
],
|
||||
"db_table_name": "md_1min_bars",
|
||||
"exchange_id": "ALPACA",
|
||||
"instrument_id_pfx": "STOCK-",
|
||||
"trading_hours": {
|
||||
"begin_session": "9:30:00",
|
||||
"end_session": "16:00:00",
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"price_column": "close",
|
||||
"min_required_points": 30,
|
||||
"zero_threshold": 1e-10,
|
||||
"dis-equilibrium_open_trshld": 2.0,
|
||||
"dis-equilibrium_close_trshld": 1.0,
|
||||
"training_minutes": 120,
|
||||
"funding_per_pair": 2000.0,
|
||||
# "fit_method_class": "pt_trading.sliding_fit.SlidingFit",
|
||||
"fit_method_class": "pt_trading.static_fit.StaticFit",
|
||||
"exclude_instruments": ["CAN"],
|
||||
"close_outstanding_positions": false
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"security_type": "EQUITY",
|
||||
"data_directory": "./data/equity",
|
||||
"datafiles": [
|
||||
"20250602.mktdata.ohlcv.db",
|
||||
],
|
||||
"db_table_name": "md_1min_bars",
|
||||
"exchange_id": "ALPACA",
|
||||
"instrument_id_pfx": "STOCK-",
|
||||
"trading_hours": {
|
||||
"begin_session": "9:30:00",
|
||||
"end_session": "16:00:00",
|
||||
"timezone": "America/New_York"
|
||||
},
|
||||
"price_column": "close",
|
||||
"min_required_points": 30,
|
||||
"zero_threshold": 1e-10,
|
||||
"dis-equilibrium_open_trshld": 2.0,
|
||||
"dis-equilibrium_close_trshld": 1.0,
|
||||
"training_minutes": 120,
|
||||
"funding_per_pair": 2000.0,
|
||||
"fit_method_class": "pt_trading.fit_methods.StaticFit",
|
||||
"exclude_instruments": ["CAN"]
|
||||
}
|
||||
# "fit_method_class": "pt_trading.fit_methods.SlidingFit",
|
||||
# "fit_method_class": "pt_trading.fit_methods.StaticFit",
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"market_data_loading": {
|
||||
"CRYPTO": {
|
||||
"data_directory": "./data/crypto",
|
||||
"db_table_name": "md_1min_bars",
|
||||
"instrument_id_pfx": "PAIR-",
|
||||
},
|
||||
"EQUITY": {
|
||||
"data_directory": "./data/equity",
|
||||
"db_table_name": "md_1min_bars",
|
||||
"instrument_id_pfx": "STOCK-",
|
||||
}
|
||||
},
|
||||
|
||||
# ====== Funding ======
|
||||
"funding_per_pair": 2000.0,
|
||||
|
||||
# ====== Trading Parameters ======
|
||||
"stat_model_price": "close", # "vwap"
|
||||
"execution_price": {
|
||||
"column": "vwap",
|
||||
"shift": 1,
|
||||
},
|
||||
"dis-equilibrium_open_trshld": 2.0,
|
||||
"dis-equilibrium_close_trshld": 1.0,
|
||||
"training_minutes": 120,
|
||||
"fit_method_class": "pt_trading.vecm_rolling_fit.VECMRollingFit",
|
||||
|
||||
# ====== Stop Conditions ======
|
||||
"stop_close_conditions": {
|
||||
"profit": 2.0,
|
||||
"loss": -0.5
|
||||
}
|
||||
|
||||
# ====== End of Session Closeout ======
|
||||
"close_outstanding_positions": true,
|
||||
# "close_outstanding_positions": false,
|
||||
"trading_hours": {
|
||||
"timezone": "America/New_York",
|
||||
"begin_session": "9:30:00",
|
||||
"end_session": "18:30:00",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"market_data_loading": {
|
||||
"CRYPTO": {
|
||||
"data_directory": "./data/crypto",
|
||||
"db_table_name": "md_1min_bars",
|
||||
"instrument_id_pfx": "PAIR-",
|
||||
},
|
||||
"EQUITY": {
|
||||
"data_directory": "./data/equity",
|
||||
"db_table_name": "md_1min_bars",
|
||||
"instrument_id_pfx": "STOCK-",
|
||||
}
|
||||
},
|
||||
|
||||
# ====== Funding ======
|
||||
"funding_per_pair": 2000.0,
|
||||
# ====== Trading Parameters ======
|
||||
"stat_model_price": "close",
|
||||
"execution_price": {
|
||||
"column": "vwap",
|
||||
"shift": 1,
|
||||
},
|
||||
"dis-equilibrium_open_trshld": 2.0,
|
||||
"dis-equilibrium_close_trshld": 0.5,
|
||||
"training_minutes": 120,
|
||||
"fit_method_class": "pt_trading.z-score_rolling_fit.ZScoreRollingFit",
|
||||
|
||||
# ====== Stop Conditions ======
|
||||
"stop_close_conditions": {
|
||||
"profit": 2.0,
|
||||
"loss": -0.5
|
||||
}
|
||||
|
||||
# ====== End of Session Closeout ======
|
||||
"close_outstanding_positions": true,
|
||||
# "close_outstanding_positions": false,
|
||||
"trading_hours": {
|
||||
"timezone": "America/New_York",
|
||||
"begin_session": "9:30:00",
|
||||
"end_session": "18:30:00",
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
07.11.2025
|
||||
pairs_trading/configuration <---- directory for config
|
||||
equity_lg.cfg <-------- copy of equity.cfg
|
||||
How to run a Program: TRIANGLEsquare ----> triangle EQUITY backtest
|
||||
Results are in > results (timestamp table for all runs)
|
||||
table "...timestamp... .pt_backtest_results.equity.db"
|
||||
going to table using sqlite
|
||||
> sqlite3 '/home/coder/results/20250721_175750.pt_backtest_results.equity.db'
|
||||
|
||||
sqlite> .databases
|
||||
main: /home/coder/results/20250717_180122.pt_backtest_results.equity.db r/w
|
||||
sqlite> .tables
|
||||
config outstanding_positions pt_bt_results
|
||||
|
||||
sqlite> PRAGMA table_info('pt_bt_results');
|
||||
0|date|DATE|0||0
|
||||
1|pair|TEXT|0||0
|
||||
2|symbol|TEXT|0||0
|
||||
3|open_time|DATETIME|0||0
|
||||
4|open_side|TEXT|0||0
|
||||
5|open_price|REAL|0||0
|
||||
6|open_quantity|INTEGER|0||0
|
||||
7|open_disequilibrium|REAL|0||0
|
||||
8|close_time|DATETIME|0||0
|
||||
9|close_side|TEXT|0||0
|
||||
10|close_price|REAL|0||0
|
||||
11|close_quantity|INTEGER|0||0
|
||||
12|close_disequilibrium|REAL|0||0
|
||||
13|symbol_return|REAL|0||0
|
||||
14|pair_return|REAL|0||0
|
||||
|
||||
select count(*) as cnt from pt_bt_results;
|
||||
8
|
||||
|
||||
select * from pt_bt_results;
|
||||
|
||||
select
|
||||
date, close_time, pair, symbol, symbol_return, pair_return
|
||||
from pt_bt_results ;
|
||||
|
||||
select date, sum(symbol_return) as daily_return
|
||||
from pt_bt_results where date = '2025-06-18' group by date;
|
||||
|
||||
.quit
|
||||
|
||||
sqlite3 '/home/coder/results/20250717_172435.pt_backtest_results.equity.db'
|
||||
|
||||
sqlite> select date, sum(symbol_return) as daily_return
|
||||
from pt_bt_results group by date;
|
||||
|
||||
2025-06-02|1.29845390060828
|
||||
...
|
||||
2025-06-18|-43.5084977104115 <========== ????? ==========>
|
||||
2025-06-20|11.8605547517183
|
||||
|
||||
|
||||
select
|
||||
date, close_time, pair, symbol, symbol_return, pair_return
|
||||
from pt_bt_results ;
|
||||
|
||||
select date, close_time, pair, symbol, symbol_return, pair_return
|
||||
from pt_bt_results where date = '2025-06-18';
|
||||
|
||||
|
||||
./scripts/load_equity_pair_intraday.sh -A NVDA -B QQQ -d 20250701 -T ./intraday_md
|
||||
|
||||
to inspect exactly what sources, formats, and processing steps you can open the script with:
|
||||
head -n 50 ./scripts/load_equity_pair_intraday.sh
|
||||
|
||||
|
||||
|
||||
✓ Data file found: /home/coder/pairs_trading/data/crypto/20250605.mktdata.ohlcv.db
|
||||
|
||||
sqlite3 '/home/coder/results/20250722_201930.pt_backtest_results.crypto.db'
|
||||
|
||||
sqlite3 '/home/coder/results/xxxxxxxx_yyyyyy.pt_backtest_results.pseudo.db'
|
||||
|
||||
11111111
|
||||
=== At your terminal, run these commands:
|
||||
sqlite3 '/home/coder/results/20250722_201930.pt_backtest_results.crypto.db'
|
||||
=== Then inside the SQLite prompt:
|
||||
.mode csv
|
||||
.headers on
|
||||
.output results_20250722.csv
|
||||
SELECT * FROM pt_bt_results;
|
||||
.output stdout
|
||||
.quit
|
||||
|
||||
cd /home/coder/
|
||||
|
||||
# === mode csv formats output as CSV
|
||||
# === headers on includes column names
|
||||
# === output my_table.csv directs output to that file
|
||||
# === Run your SELECT query, then revert output
|
||||
# === Open my_table.csv in Excel directly
|
||||
|
||||
# ======== Using scp (Secure Copy)
|
||||
# === On your local machine, open a terminal and run:
|
||||
scp cvtt@953f6e8df266:/home/coder/results_20250722.csv ~/Downloads/
|
||||
|
||||
|
||||
# ===== convert cvs pandas dataframe ====== -->
|
||||
import pandas as pd
|
||||
# Replace with the actual path to your CSV file
|
||||
file_path = '/home/coder/results_20250722.csv'
|
||||
# Read the CSV file into a DataFrame
|
||||
df = pd.read_csv(file_path)
|
||||
# Show the first few rows
|
||||
print(df.head())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
from ast import Sub
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Coroutine, Dict, List, Optional
|
||||
|
||||
from numpy.strings import str_len
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
MessageTypeT = str
|
||||
SubscriptionIdT = str
|
||||
MessageT = Dict
|
||||
UrlT = str
|
||||
CallbackT = Callable[[MessageTypeT, SubscriptionIdT, MessageT], Coroutine[None, str, None]]
|
||||
|
||||
@dataclass
|
||||
class CvttPricesSubscription:
|
||||
id_: str
|
||||
exchange_config_name_: str
|
||||
instrument_id_: str
|
||||
interval_sec_: int
|
||||
history_depth_sec_: int
|
||||
is_subscribed_: bool
|
||||
is_historical_: bool
|
||||
callback_: CallbackT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
exchange_config_name: str,
|
||||
instrument_id: str,
|
||||
interval_sec: int,
|
||||
history_depth_sec: int,
|
||||
callback: CallbackT,
|
||||
):
|
||||
self.exchange_config_name_ = exchange_config_name
|
||||
self.instrument_id_ = instrument_id
|
||||
self.interval_sec_ = interval_sec
|
||||
self.history_depth_sec_ = history_depth_sec
|
||||
self.callback_ = callback
|
||||
self.id_ = str(uuid.uuid4())
|
||||
self.is_subscribed_ = False
|
||||
self.is_historical_ = history_depth_sec > 0
|
||||
|
||||
|
||||
class CvttPricerWebSockClient:
|
||||
# Class members with type hints
|
||||
ws_url_: UrlT
|
||||
websocket_: Optional[ClientConnection]
|
||||
subscriptions_: Dict[SubscriptionIdT, CvttPricesSubscription]
|
||||
is_connected_: bool
|
||||
logger_: logging.Logger
|
||||
|
||||
def __init__(self, url: str):
|
||||
self.ws_url_ = url
|
||||
self.websocket_ = None
|
||||
self.is_connected_ = False
|
||||
self.subscriptions_ = {}
|
||||
self.logger_ = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
async def subscribe(
|
||||
self, subscription: CvttPricesSubscription
|
||||
) -> str: # returns subscription id
|
||||
|
||||
if not self.is_connected_:
|
||||
try:
|
||||
self.logger_.info(f"Connecting to {self.ws_url_}")
|
||||
self.websocket_ = await websockets.connect(self.ws_url_)
|
||||
self.is_connected_ = True
|
||||
except Exception as e:
|
||||
self.logger_.error(f"Unable to connect to {self.ws_url_}: {str(e)}")
|
||||
raise e
|
||||
|
||||
subscr_msg = {
|
||||
"type": "subscr",
|
||||
"id": subscription.id_,
|
||||
"subscr_type": "MD_AGGREGATE",
|
||||
"exchange_config_name": subscription.exchange_config_name_,
|
||||
"instrument_id": subscription.instrument_id_,
|
||||
"interval_sec": subscription.interval_sec_,
|
||||
}
|
||||
if subscription.is_historical_:
|
||||
subscr_msg["history_depth_sec"] = subscription.history_depth_sec_
|
||||
|
||||
assert self.websocket_ is not None
|
||||
await self.websocket_.send(json.dumps(subscr_msg))
|
||||
|
||||
response = await self.websocket_.recv()
|
||||
response_data = json.loads(response)
|
||||
if not await self.handle_subscription_response(subscription, response_data):
|
||||
await self.websocket_.close()
|
||||
self.is_connected_ = False
|
||||
raise Exception(f"Subscription failed: {str(response)}")
|
||||
|
||||
self.subscriptions_[subscription.id_] = subscription
|
||||
return subscription.id_
|
||||
|
||||
async def handle_subscription_response(
|
||||
self, subscription: CvttPricesSubscription, response: dict
|
||||
) -> bool:
|
||||
if response.get("type") != "subscr" or response.get("id") != subscription.id_:
|
||||
return False
|
||||
|
||||
if response.get("status") == "success":
|
||||
self.logger_.info(f"Subscription successful: {json.dumps(response)}")
|
||||
return True
|
||||
elif response.get("status") == "error":
|
||||
self.logger_.error(f"Subscription failed: {response.get('reason')}")
|
||||
return False
|
||||
return False
|
||||
|
||||
async def run(self) -> None:
|
||||
assert self.websocket_
|
||||
try:
|
||||
while self.is_connected_:
|
||||
try:
|
||||
message = await self.websocket_.recv()
|
||||
message_str = (
|
||||
message.decode("utf-8")
|
||||
if isinstance(message, bytes)
|
||||
else message
|
||||
)
|
||||
await self.process_message(json.loads(message_str))
|
||||
except websockets.ConnectionClosed:
|
||||
self.logger_.warning("Connection closed")
|
||||
self.is_connected_ = False
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger_.error(f"Error occurred: {str(e)}")
|
||||
self.is_connected_ = False
|
||||
await asyncio.sleep(5) # Wait before reconnecting
|
||||
|
||||
async def process_message(self, message: Dict) -> None:
|
||||
message_type = message.get("type")
|
||||
if message_type in ["md_aggregate", "historical_md_aggregate"]:
|
||||
subscription_id = message.get("subscr_id")
|
||||
if subscription_id not in self.subscriptions_:
|
||||
self.logger_.warning(f"Unknown subscription id: {subscription_id}")
|
||||
return
|
||||
|
||||
subscription = self.subscriptions_[subscription_id]
|
||||
await subscription.callback_(message_type, subscription_id, message)
|
||||
else:
|
||||
self.logger_.warning(f"Unknown message type: {message.get('type')}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async def on_message(message_type: MessageTypeT, subscr_id: SubscriptionIdT, message: Dict, instrument_id: str) -> None:
|
||||
print(f"{message_type=} {subscr_id=} {instrument_id}")
|
||||
if message_type == "md_aggregate":
|
||||
aggr = message.get("md_aggregate", [])
|
||||
print(f"[{aggr['tstmp'][:19]}] *** RLTM *** {message}")
|
||||
elif message_type == "historical_md_aggregate":
|
||||
for aggr in message.get("historical_data", []):
|
||||
print(f"[{aggr['tstmp'][:19]}] *** HIST *** {aggr}")
|
||||
else:
|
||||
print(f"Unknown message type: {message_type}")
|
||||
|
||||
pricer_client = CvttPricerWebSockClient(
|
||||
"ws://localhost:12346/ws"
|
||||
)
|
||||
await pricer_client.subscribe(CvttPricesSubscription(
|
||||
exchange_config_name="COINBASE_AT",
|
||||
instrument_id="PAIR-BTC-USD",
|
||||
interval_sec=60,
|
||||
history_depth_sec=60*60*24,
|
||||
callback=partial(on_message, instrument_id="PAIR-BTC-USD")
|
||||
))
|
||||
await pricer_client.subscribe(CvttPricesSubscription(
|
||||
exchange_config_name="COINBASE_AT",
|
||||
instrument_id="PAIR-ETH-USD",
|
||||
interval_sec=60,
|
||||
history_depth_sec=60*60*24,
|
||||
callback=partial(on_message, instrument_id="PAIR-ETH-USD")
|
||||
))
|
||||
|
||||
await pricer_client.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, cast
|
||||
|
||||
import pandas as pd
|
||||
from pt_trading.results import BacktestResult
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
NanoPerMin = 1e9
|
||||
|
||||
|
||||
class PairsTradingFitMethod(ABC):
|
||||
TRADES_COLUMNS = [
|
||||
"time",
|
||||
"symbol",
|
||||
"side",
|
||||
"action",
|
||||
"price",
|
||||
"disequilibrium",
|
||||
"scaled_disequilibrium",
|
||||
"signed_scaled_disequilibrium",
|
||||
"pair",
|
||||
]
|
||||
@staticmethod
|
||||
def create(config: Dict) -> PairsTradingFitMethod:
|
||||
import importlib
|
||||
fit_method_class_name = config.get("fit_method_class", None)
|
||||
assert fit_method_class_name is not None
|
||||
module_name, class_name = fit_method_class_name.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
fit_method = getattr(module, class_name)()
|
||||
return cast(PairsTradingFitMethod, fit_method)
|
||||
|
||||
@abstractmethod
|
||||
def run_pair(
|
||||
self, pair: TradingPair, bt_result: BacktestResult
|
||||
) -> Optional[pd.DataFrame]: ...
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def create_trading_pair(
|
||||
self,
|
||||
config: Dict,
|
||||
market_data: pd.DataFrame,
|
||||
symbol_a: str,
|
||||
symbol_b: str,
|
||||
) -> TradingPair: ...
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
|
||||
# Recommended replacement adapters and converters for Python 3.12+
|
||||
# From: https://docs.python.org/3/library/sqlite3.html#sqlite3-adapter-converter-recipes
|
||||
def adapt_date_iso(val: date) -> str:
|
||||
"""Adapt datetime.date to ISO 8601 date."""
|
||||
return val.isoformat()
|
||||
|
||||
|
||||
def adapt_datetime_iso(val: datetime) -> str:
|
||||
"""Adapt datetime.datetime to timezone-naive ISO 8601 date."""
|
||||
return val.isoformat()
|
||||
|
||||
|
||||
def convert_date(val: bytes) -> date:
|
||||
"""Convert ISO 8601 date to datetime.date object."""
|
||||
return datetime.fromisoformat(val.decode()).date()
|
||||
|
||||
|
||||
def convert_datetime(val: bytes) -> datetime:
|
||||
"""Convert ISO 8601 datetime to datetime.datetime object."""
|
||||
return datetime.fromisoformat(val.decode())
|
||||
|
||||
|
||||
# Register the adapters and converters
|
||||
sqlite3.register_adapter(date, adapt_date_iso)
|
||||
sqlite3.register_adapter(datetime, adapt_datetime_iso)
|
||||
sqlite3.register_converter("date", convert_date)
|
||||
sqlite3.register_converter("datetime", convert_datetime)
|
||||
|
||||
|
||||
def create_result_database(db_path: str) -> None:
|
||||
"""
|
||||
Create the SQLite database and required tables if they don't exist.
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if db_dir and not os.path.exists(db_dir):
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
print(f"Created directory: {db_dir}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the pt_bt_results table for completed trades
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pt_bt_results (
|
||||
date DATE,
|
||||
pair TEXT,
|
||||
symbol TEXT,
|
||||
open_time DATETIME,
|
||||
open_side TEXT,
|
||||
open_price REAL,
|
||||
open_quantity INTEGER,
|
||||
open_disequilibrium REAL,
|
||||
close_time DATETIME,
|
||||
close_side TEXT,
|
||||
close_price REAL,
|
||||
close_quantity INTEGER,
|
||||
close_disequilibrium REAL,
|
||||
symbol_return REAL,
|
||||
pair_return REAL,
|
||||
close_condition TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("DELETE FROM pt_bt_results;")
|
||||
|
||||
# Create the outstanding_positions table for open positions
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS outstanding_positions (
|
||||
date DATE,
|
||||
pair TEXT,
|
||||
symbol TEXT,
|
||||
position_quantity REAL,
|
||||
last_price REAL,
|
||||
unrealized_return REAL,
|
||||
open_price REAL,
|
||||
open_side TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("DELETE FROM outstanding_positions;")
|
||||
|
||||
# Create the config table for storing configuration JSON for reference
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_timestamp DATETIME,
|
||||
config_file_path TEXT,
|
||||
config_json TEXT,
|
||||
fit_method_class TEXT,
|
||||
datafiles TEXT,
|
||||
instruments TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("DELETE FROM config;")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating result database: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def store_config_in_database(
|
||||
db_path: str,
|
||||
config_file_path: str,
|
||||
config: Dict,
|
||||
fit_method_class: str,
|
||||
datafiles: List[Tuple[str, str]],
|
||||
instruments: List[Dict[str, str]],
|
||||
) -> None:
|
||||
"""
|
||||
Store configuration information in the database for reference.
|
||||
"""
|
||||
import json
|
||||
|
||||
if db_path.upper() == "NONE":
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Convert config to JSON string
|
||||
config_json = json.dumps(config, indent=2, default=str)
|
||||
|
||||
# Convert lists to comma-separated strings for storage
|
||||
datafiles_str = ", ".join([f"{datafile}" for _, datafile in datafiles])
|
||||
instruments_str = ", ".join(
|
||||
[
|
||||
f"{inst['symbol']}:{inst['instrument_type']}:{inst['exchange_id']}"
|
||||
for inst in instruments
|
||||
]
|
||||
)
|
||||
|
||||
# Insert configuration record
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO config (
|
||||
run_timestamp, config_file_path, config_json, fit_method_class, datafiles, instruments
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
datetime.now(),
|
||||
config_file_path,
|
||||
config_json,
|
||||
fit_method_class,
|
||||
datafiles_str,
|
||||
instruments_str,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"Configuration stored in database")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error storing configuration in database: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def convert_timestamp(timestamp: Any) -> Optional[datetime]:
|
||||
"""Convert pandas Timestamp to Python datetime object for SQLite compatibility."""
|
||||
if timestamp is None:
|
||||
return None
|
||||
if isinstance(timestamp, pd.Timestamp):
|
||||
return timestamp.to_pydatetime()
|
||||
elif isinstance(timestamp, datetime):
|
||||
return timestamp
|
||||
elif isinstance(timestamp, date):
|
||||
return datetime.combine(timestamp, datetime.min.time())
|
||||
elif isinstance(timestamp, str):
|
||||
return datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S")
|
||||
elif isinstance(timestamp, int):
|
||||
return datetime.fromtimestamp(timestamp)
|
||||
else:
|
||||
raise ValueError(f"Unsupported timestamp type: {type(timestamp)}")
|
||||
|
||||
|
||||
|
||||
class BacktestResult:
|
||||
"""
|
||||
Class to handle backtest results, trades tracking, PnL calculations, and reporting.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.trades: Dict[str, Dict[str, Any]] = {}
|
||||
self.total_realized_pnl = 0.0
|
||||
self.outstanding_positions: List[Dict[str, Any]] = []
|
||||
self.pairs_trades_: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
def add_trade(
|
||||
self,
|
||||
pair_nm: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
action: str,
|
||||
price: Any,
|
||||
disequilibrium: Optional[float] = None,
|
||||
scaled_disequilibrium: Optional[float] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Add a trade to the results tracking."""
|
||||
pair_nm = str(pair_nm)
|
||||
|
||||
if pair_nm not in self.trades:
|
||||
self.trades[pair_nm] = {symbol: []}
|
||||
if symbol not in self.trades[pair_nm]:
|
||||
self.trades[pair_nm][symbol] = []
|
||||
self.trades[pair_nm][symbol].append(
|
||||
{
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"action": action,
|
||||
"price": price,
|
||||
"disequilibrium": disequilibrium,
|
||||
"scaled_disequilibrium": scaled_disequilibrium,
|
||||
"timestamp": timestamp,
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
def add_outstanding_position(self, position: Dict[str, Any]) -> None:
|
||||
"""Add an outstanding position to tracking."""
|
||||
self.outstanding_positions.append(position)
|
||||
|
||||
def add_realized_pnl(self, realized_pnl: float) -> None:
|
||||
"""Add realized PnL to the total."""
|
||||
self.total_realized_pnl += realized_pnl
|
||||
|
||||
def get_total_realized_pnl(self) -> float:
|
||||
"""Get total realized PnL."""
|
||||
return self.total_realized_pnl
|
||||
|
||||
def get_outstanding_positions(self) -> List[Dict[str, Any]]:
|
||||
"""Get all outstanding positions."""
|
||||
return self.outstanding_positions
|
||||
|
||||
def get_trades(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all trades."""
|
||||
return self.trades
|
||||
|
||||
def clear_trades(self) -> None:
|
||||
"""Clear all trades (used when processing new files)."""
|
||||
self.trades.clear()
|
||||
|
||||
def collect_single_day_results(self, pairs_trades: List[pd.DataFrame]) -> None:
|
||||
"""Collect and process single day trading results."""
|
||||
result = pd.concat(pairs_trades, ignore_index=True)
|
||||
result["time"] = pd.to_datetime(result["time"])
|
||||
result = result.set_index("time").sort_index()
|
||||
|
||||
print("\n -------------- Suggested Trades ")
|
||||
print(result)
|
||||
|
||||
for row in result.itertuples():
|
||||
side = row.side
|
||||
action = row.action
|
||||
symbol = row.symbol
|
||||
price = row.price
|
||||
disequilibrium = getattr(row, "disequilibrium", None)
|
||||
scaled_disequilibrium = getattr(row, "scaled_disequilibrium", None)
|
||||
if hasattr(row, "time"):
|
||||
timestamp = getattr(row, "time")
|
||||
else:
|
||||
timestamp = convert_timestamp(row.Index)
|
||||
status = row.status
|
||||
self.add_trade(
|
||||
pair_nm=str(row.pair),
|
||||
symbol=str(symbol),
|
||||
side=str(side),
|
||||
action=str(action),
|
||||
price=float(str(price)),
|
||||
disequilibrium=disequilibrium,
|
||||
scaled_disequilibrium=scaled_disequilibrium,
|
||||
timestamp=timestamp,
|
||||
status=str(status) if status is not None else "?",
|
||||
)
|
||||
|
||||
def print_single_day_results(self) -> None:
|
||||
"""Print single day results summary."""
|
||||
for pair, symbols in self.trades.items():
|
||||
print(f"\n--- {pair} ---")
|
||||
for symbol, trades in symbols.items():
|
||||
for trade_data in trades:
|
||||
if len(trade_data) >= 2:
|
||||
side, price = trade_data[:2]
|
||||
print(f"{symbol} {side} at ${price}")
|
||||
|
||||
def print_results_summary(self, all_results: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Print summary of all processed files."""
|
||||
print("\n====== Summary of All Processed Files ======")
|
||||
for filename, data in all_results.items():
|
||||
trade_count = sum(
|
||||
len(trades)
|
||||
for symbol_trades in data["trades"].values()
|
||||
for trades in symbol_trades.values()
|
||||
)
|
||||
print(f"{filename}: {trade_count} trades")
|
||||
|
||||
def calculate_returns(self, all_results: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Calculate and print returns by day and pair."""
|
||||
def _symbol_return(trade1_side: str, trade1_px: float, trade2_side: str, trade2_px: float) -> float:
|
||||
if trade1_side == "BUY" and trade2_side == "SELL":
|
||||
return (trade2_px - trade1_px) / trade1_px * 100
|
||||
elif trade1_side == "SELL" and trade2_side == "BUY":
|
||||
return (trade1_px - trade2_px) / trade1_px * 100
|
||||
else:
|
||||
return 0
|
||||
|
||||
print("\n====== Returns By Day and Pair ======")
|
||||
|
||||
trades = []
|
||||
for filename, data in all_results.items():
|
||||
pairs = list(data["trades"].keys())
|
||||
for pair in pairs:
|
||||
self.pairs_trades_[pair] = []
|
||||
trades_dict = data["trades"][pair]
|
||||
for symbol in trades_dict.keys():
|
||||
trades.extend(trades_dict[symbol])
|
||||
trades = sorted(trades, key=lambda x: (x["timestamp"], x["symbol"]))
|
||||
|
||||
print(f"\n--- {filename} ---")
|
||||
|
||||
self.outstanding_positions = data["outstanding_positions"]
|
||||
|
||||
day_return = 0.0
|
||||
for idx in range(0, len(trades), 4):
|
||||
symbol_a = trades[idx]["symbol"]
|
||||
trade_a_1 = trades[idx]
|
||||
trade_a_2 = trades[idx + 2]
|
||||
|
||||
symbol_b = trades[idx + 1]["symbol"]
|
||||
trade_b_1 = trades[idx + 1]
|
||||
trade_b_2 = trades[idx + 3]
|
||||
|
||||
symbol_return = 0
|
||||
assert (
|
||||
trade_a_1["timestamp"] < trade_a_2["timestamp"]
|
||||
), f"Trade 1: {trade_a_1['timestamp']} is not less than Trade 2: {trade_a_2['timestamp']}"
|
||||
assert (
|
||||
trade_a_1["action"] == "OPEN" and trade_a_2["action"] == "CLOSE"
|
||||
), f"Trade 1: {trade_a_1['action']} and Trade 2: {trade_a_2['action']} are the same"
|
||||
|
||||
# Calculate return based on action combination
|
||||
trade_return = 0
|
||||
symbol_a_return = _symbol_return(trade_a_1["side"], trade_a_1["price"], trade_a_2["side"], trade_a_2["price"])
|
||||
symbol_b_return = _symbol_return(trade_b_1["side"], trade_b_1["price"], trade_b_2["side"], trade_b_2["price"])
|
||||
|
||||
pair_return = symbol_a_return + symbol_b_return
|
||||
|
||||
self.pairs_trades_[pair].append(
|
||||
{
|
||||
"symbol": symbol_a,
|
||||
"open_side": trade_a_1["side"],
|
||||
"open_action": trade_a_1["action"],
|
||||
"open_price": trade_a_1["price"],
|
||||
"close_side": trade_a_2["side"],
|
||||
"close_action": trade_a_2["action"],
|
||||
"close_price": trade_a_2["price"],
|
||||
"symbol_return": symbol_a_return,
|
||||
"open_disequilibrium": trade_a_1["disequilibrium"],
|
||||
"open_scaled_disequilibrium": trade_a_1["scaled_disequilibrium"],
|
||||
"close_disequilibrium": trade_a_2["disequilibrium"],
|
||||
"close_scaled_disequilibrium": trade_a_2["scaled_disequilibrium"],
|
||||
"open_time": trade_a_1["timestamp"],
|
||||
"close_time": trade_a_2["timestamp"],
|
||||
"shares": self.config["funding_per_pair"] / 2 / trade_a_1["price"],
|
||||
"is_completed": True,
|
||||
"close_condition": trade_a_2["status"],
|
||||
"pair_return": pair_return
|
||||
}
|
||||
)
|
||||
self.pairs_trades_[pair].append(
|
||||
{
|
||||
"symbol": symbol_b,
|
||||
"open_side": trade_b_1["side"],
|
||||
"open_action": trade_b_1["action"],
|
||||
"open_price": trade_b_1["price"],
|
||||
"close_side": trade_b_2["side"],
|
||||
"close_action": trade_b_2["action"],
|
||||
"close_price": trade_b_2["price"],
|
||||
"symbol_return": symbol_b_return,
|
||||
"open_disequilibrium": trade_b_1["disequilibrium"],
|
||||
"open_scaled_disequilibrium": trade_b_1["scaled_disequilibrium"],
|
||||
"close_disequilibrium": trade_b_2["disequilibrium"],
|
||||
"close_scaled_disequilibrium": trade_b_2["scaled_disequilibrium"],
|
||||
"open_time": trade_b_1["timestamp"],
|
||||
"close_time": trade_b_2["timestamp"],
|
||||
"shares": self.config["funding_per_pair"] / 2 / trade_b_1["price"],
|
||||
"is_completed": True,
|
||||
"close_condition": trade_b_2["status"],
|
||||
"pair_return": pair_return
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Print pair returns with disequilibrium information
|
||||
day_return = 0.0
|
||||
if pair in self.pairs_trades_:
|
||||
|
||||
print(f"{pair}:")
|
||||
pair_return = 0.0
|
||||
for trd in self.pairs_trades_[pair]:
|
||||
disequil_info = ""
|
||||
if (
|
||||
trd["open_scaled_disequilibrium"] is not None
|
||||
and trd["open_scaled_disequilibrium"] is not None
|
||||
):
|
||||
disequil_info = (
|
||||
f' | Open Dis-eq: {trd["open_scaled_disequilibrium"]:.2f},'
|
||||
f' Close Dis-eq: {trd["close_scaled_disequilibrium"]:.2f}'
|
||||
)
|
||||
|
||||
print(
|
||||
f' {trd["open_time"].time()}-{trd["close_time"].time()} {trd["symbol"]}: '
|
||||
f' {trd["open_side"]} @ ${trd["open_price"]:.2f},'
|
||||
f' {trd["close_side"]} @ ${trd["close_price"]:.2f},'
|
||||
f' Return: {trd["symbol_return"]:.2f}%{disequil_info}'
|
||||
)
|
||||
pair_return += trd["symbol_return"]
|
||||
|
||||
print(f" Pair Total Return: {pair_return:.2f}%")
|
||||
day_return += pair_return
|
||||
|
||||
# Print day total return and add to global realized PnL
|
||||
if day_return != 0:
|
||||
print(f" Day Total Return: {day_return:.2f}%")
|
||||
self.add_realized_pnl(day_return)
|
||||
|
||||
def print_outstanding_positions(self) -> None:
|
||||
"""Print all outstanding positions with share quantities and current values."""
|
||||
if not self.get_outstanding_positions():
|
||||
print("\n====== NO OUTSTANDING POSITIONS ======")
|
||||
return
|
||||
|
||||
print(f"\n====== OUTSTANDING POSITIONS ======")
|
||||
print(
|
||||
f"{'Pair':<15}"
|
||||
f" {'Symbol':<10}"
|
||||
f" {'Side':<4}"
|
||||
f" {'Shares':<10}"
|
||||
f" {'Open $':<8}"
|
||||
f" {'Current $':<10}"
|
||||
f" {'Value $':<12}"
|
||||
f" {'Disequilibrium':<15}"
|
||||
)
|
||||
print("-" * 100)
|
||||
|
||||
total_value = 0.0
|
||||
|
||||
for pos in self.get_outstanding_positions():
|
||||
# Print position A
|
||||
print(
|
||||
f"{pos['pair']:<15}"
|
||||
f" {pos['symbol_a']:<10}"
|
||||
f" {pos['side_a']:<4}"
|
||||
f" {pos['shares_a']:<10.2f}"
|
||||
f" {pos['open_px_a']:<8.2f}"
|
||||
f" {pos['current_px_a']:<10.2f}"
|
||||
f" {pos['current_value_a']:<12.2f}"
|
||||
f" {'':<15}"
|
||||
)
|
||||
|
||||
# Print position B
|
||||
print(
|
||||
f"{'':<15}"
|
||||
f" {pos['symbol_b']:<10}"
|
||||
f" {pos['side_b']:<4}"
|
||||
f" {pos['shares_b']:<10.2f}"
|
||||
f" {pos['open_px_b']:<8.2f}"
|
||||
f" {pos['current_px_b']:<10.2f}"
|
||||
f" {pos['current_value_b']:<12.2f}"
|
||||
)
|
||||
|
||||
# Print pair totals with disequilibrium info
|
||||
print(
|
||||
f"{'':<15}"
|
||||
f" {'PAIR TOTAL':<10}"
|
||||
f" {'':<4}"
|
||||
f" {'':<10}"
|
||||
f" {'':<8}"
|
||||
f" {'':<10}"
|
||||
f" {pos['total_current_value']:<12.2f}"
|
||||
)
|
||||
|
||||
# Print disequilibrium details
|
||||
print(
|
||||
f"{'':<15}"
|
||||
f" {'DISEQUIL':<10}"
|
||||
f" {'':<4}"
|
||||
f" {'':<10}"
|
||||
f" {'':<8}"
|
||||
f" {'':<10}"
|
||||
f" Raw: {pos['current_disequilibrium']:<6.4f}"
|
||||
f" Scaled: {pos['current_scaled_disequilibrium']:<6.4f}"
|
||||
)
|
||||
|
||||
print("-" * 100)
|
||||
|
||||
total_value += pos["total_current_value"]
|
||||
|
||||
print(f"{'TOTAL OUTSTANDING VALUE':<80} ${total_value:<12.2f}")
|
||||
|
||||
def print_grand_totals(self) -> None:
|
||||
"""Print grand totals across all pairs."""
|
||||
print(f"\n====== GRAND TOTALS ACROSS ALL PAIRS ======")
|
||||
print(f"Total Realized PnL: {self.get_total_realized_pnl():.2f}%")
|
||||
|
||||
def handle_outstanding_position(
|
||||
self,
|
||||
pair: TradingPair,
|
||||
pair_result_df: pd.DataFrame,
|
||||
last_row_index: int,
|
||||
open_side_a: str,
|
||||
open_side_b: str,
|
||||
open_px_a: float,
|
||||
open_px_b: float,
|
||||
open_tstamp: datetime,
|
||||
) -> Tuple[float, float, float]:
|
||||
"""
|
||||
Handle calculation and tracking of outstanding positions when no close signal is found.
|
||||
|
||||
Args:
|
||||
pair: TradingPair object
|
||||
pair_result_df: DataFrame with pair results
|
||||
last_row_index: Index of the last row in the data
|
||||
open_side_a, open_side_b: Trading sides for symbols A and B
|
||||
open_px_a, open_px_b: Opening prices for symbols A and B
|
||||
open_tstamp: Opening timestamp
|
||||
"""
|
||||
if pair_result_df is None or pair_result_df.empty:
|
||||
return 0, 0, 0
|
||||
|
||||
last_row = pair_result_df.loc[last_row_index]
|
||||
last_tstamp = last_row["tstamp"]
|
||||
colname_a, colname_b = pair.exec_prices_colnames()
|
||||
last_px_a = last_row[colname_a]
|
||||
last_px_b = last_row[colname_b]
|
||||
|
||||
# Calculate share quantities based on funding per pair
|
||||
# Split funding equally between the two positions
|
||||
funding_per_position = self.config["funding_per_pair"] / 2
|
||||
shares_a = funding_per_position / open_px_a
|
||||
shares_b = funding_per_position / open_px_b
|
||||
|
||||
# Calculate current position values (shares * current price)
|
||||
current_value_a = shares_a * last_px_a * (-1 if open_side_a == "SELL" else 1)
|
||||
current_value_b = shares_b * last_px_b * (-1 if open_side_b == "SELL" else 1)
|
||||
total_current_value = current_value_a + current_value_b
|
||||
|
||||
# Get disequilibrium information
|
||||
current_disequilibrium = last_row["disequilibrium"]
|
||||
current_scaled_disequilibrium = last_row["scaled_disequilibrium"]
|
||||
|
||||
# Store outstanding positions
|
||||
self.add_outstanding_position(
|
||||
{
|
||||
"pair": str(pair),
|
||||
"symbol_a": pair.symbol_a_,
|
||||
"symbol_b": pair.symbol_b_,
|
||||
"side_a": open_side_a,
|
||||
"side_b": open_side_b,
|
||||
"shares_a": shares_a,
|
||||
"shares_b": shares_b,
|
||||
"open_px_a": open_px_a,
|
||||
"open_px_b": open_px_b,
|
||||
"current_px_a": last_px_a,
|
||||
"current_px_b": last_px_b,
|
||||
"current_value_a": current_value_a,
|
||||
"current_value_b": current_value_b,
|
||||
"total_current_value": total_current_value,
|
||||
"open_time": open_tstamp,
|
||||
"last_time": last_tstamp,
|
||||
"current_abs_term": current_scaled_disequilibrium,
|
||||
"current_disequilibrium": current_disequilibrium,
|
||||
"current_scaled_disequilibrium": current_scaled_disequilibrium,
|
||||
}
|
||||
)
|
||||
|
||||
# Print position details
|
||||
print(f"{pair}: NO CLOSE SIGNAL FOUND - Position held until end of session")
|
||||
print(f" Open: {open_tstamp} | Last: {last_tstamp}")
|
||||
print(
|
||||
f" {pair.symbol_a_}: {open_side_a} {shares_a:.2f} shares @ ${open_px_a:.2f} -> ${last_px_a:.2f} | Value: ${current_value_a:.2f}"
|
||||
)
|
||||
print(
|
||||
f" {pair.symbol_b_}: {open_side_b} {shares_b:.2f} shares @ ${open_px_b:.2f} -> ${last_px_b:.2f} | Value: ${current_value_b:.2f}"
|
||||
)
|
||||
print(f" Total Value: ${total_current_value:.2f}")
|
||||
print(
|
||||
f" Disequilibrium: {current_disequilibrium:.4f} | Scaled: {current_scaled_disequilibrium:.4f}"
|
||||
)
|
||||
|
||||
return current_value_a, current_value_b, total_current_value
|
||||
|
||||
def store_results_in_database(
|
||||
self, db_path: str, day: str
|
||||
) -> None:
|
||||
"""
|
||||
Store backtest results in the SQLite database.
|
||||
"""
|
||||
if db_path.upper() == "NONE":
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract date from datafile name (assuming format like 20250528.mktdata.ohlcv.db)
|
||||
date_str = day
|
||||
|
||||
# Convert to proper date format
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, "%Y%m%d").date()
|
||||
except ValueError:
|
||||
# If date parsing fails, use current date
|
||||
date_obj = datetime.now().date()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Process each trade from bt_result
|
||||
trades = self.get_trades()
|
||||
|
||||
for pair_name, _ in trades.items():
|
||||
|
||||
# Second pass: insert completed trade records into database
|
||||
for trade_pair in sorted(self.pairs_trades_[pair_name], key=lambda x: x["open_time"]):
|
||||
# Only store completed trades in pt_bt_results table
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO pt_bt_results (
|
||||
date, pair, symbol, open_time, open_side, open_price,
|
||||
open_quantity, open_disequilibrium, close_time, close_side,
|
||||
close_price, close_quantity, close_disequilibrium,
|
||||
symbol_return, pair_return, close_condition
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
date_obj,
|
||||
pair_name,
|
||||
trade_pair["symbol"],
|
||||
trade_pair["open_time"],
|
||||
trade_pair["open_side"],
|
||||
trade_pair["open_price"],
|
||||
trade_pair["shares"],
|
||||
trade_pair["open_scaled_disequilibrium"],
|
||||
trade_pair["close_time"],
|
||||
trade_pair["close_side"],
|
||||
trade_pair["close_price"],
|
||||
trade_pair["shares"],
|
||||
trade_pair["close_scaled_disequilibrium"],
|
||||
trade_pair["symbol_return"],
|
||||
trade_pair["pair_return"],
|
||||
trade_pair["close_condition"]
|
||||
),
|
||||
)
|
||||
|
||||
# Store outstanding positions in separate table
|
||||
outstanding_positions = self.get_outstanding_positions()
|
||||
for pos in outstanding_positions:
|
||||
# Calculate position quantity (negative for SELL positions)
|
||||
position_qty_a = (
|
||||
pos["shares_a"] if pos["side_a"] == "BUY" else -pos["shares_a"]
|
||||
)
|
||||
position_qty_b = (
|
||||
pos["shares_b"] if pos["side_b"] == "BUY" else -pos["shares_b"]
|
||||
)
|
||||
|
||||
# Calculate unrealized returns
|
||||
# For symbol A: (current_price - open_price) / open_price * 100 * position_direction
|
||||
unrealized_return_a = (
|
||||
(pos["current_px_a"] - pos["open_px_a"]) / pos["open_px_a"] * 100
|
||||
) * (1 if pos["side_a"] == "BUY" else -1)
|
||||
unrealized_return_b = (
|
||||
(pos["current_px_b"] - pos["open_px_b"]) / pos["open_px_b"] * 100
|
||||
) * (1 if pos["side_b"] == "BUY" else -1)
|
||||
|
||||
# Store outstanding position for symbol A
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO outstanding_positions (
|
||||
date, pair, symbol, position_quantity, last_price, unrealized_return, open_price, open_side
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
date_obj,
|
||||
pos["pair"],
|
||||
pos["symbol_a"],
|
||||
position_qty_a,
|
||||
pos["current_px_a"],
|
||||
unrealized_return_a,
|
||||
pos["open_px_a"],
|
||||
pos["side_a"],
|
||||
),
|
||||
)
|
||||
|
||||
# Store outstanding position for symbol B
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO outstanding_positions (
|
||||
date, pair, symbol, position_quantity, last_price, unrealized_return, open_price, open_side
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
date_obj,
|
||||
pos["pair"],
|
||||
pos["symbol_b"],
|
||||
position_qty_b,
|
||||
pos["current_px_b"],
|
||||
unrealized_return_b,
|
||||
pos["open_px_b"],
|
||||
pos["side_b"],
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error storing results in database: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import pandas as pd # type: ignore[import]
|
||||
from pt_trading.fit_method import PairsTradingFitMethod
|
||||
from pt_trading.results import BacktestResult
|
||||
from pt_trading.trading_pair import PairState, TradingPair
|
||||
from statsmodels.tsa.vector_ar.vecm import VECM, VECMResults
|
||||
|
||||
NanoPerMin = 1e9
|
||||
|
||||
|
||||
class RollingFit(PairsTradingFitMethod):
|
||||
"""
|
||||
N O T E:
|
||||
=========
|
||||
- This class remains to be abstract
|
||||
- The following methods are to be implemented in the subclass:
|
||||
- create_trading_pair()
|
||||
=========
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def run_pair(
|
||||
self, pair: TradingPair, bt_result: BacktestResult
|
||||
) -> Optional[pd.DataFrame]:
|
||||
print(f"***{pair}*** STARTING....")
|
||||
config = pair.config_
|
||||
|
||||
curr_training_start_idx = pair.get_begin_index()
|
||||
end_index = pair.get_end_index()
|
||||
|
||||
pair.user_data_["state"] = PairState.INITIAL
|
||||
# Initialize trades DataFrame with proper dtypes to avoid concatenation warnings
|
||||
pair.user_data_["trades"] = pd.DataFrame(columns=self.TRADES_COLUMNS).astype(
|
||||
{
|
||||
"time": "datetime64[ns]",
|
||||
"symbol": "string",
|
||||
"side": "string",
|
||||
"action": "string",
|
||||
"price": "float64",
|
||||
"disequilibrium": "float64",
|
||||
"scaled_disequilibrium": "float64",
|
||||
"pair": "object",
|
||||
}
|
||||
)
|
||||
|
||||
training_minutes = config["training_minutes"]
|
||||
curr_predicted_row_idx = 0
|
||||
while True:
|
||||
print(curr_training_start_idx, end="\r")
|
||||
pair.get_datasets(
|
||||
training_minutes=training_minutes,
|
||||
training_start_index=curr_training_start_idx,
|
||||
testing_size=1,
|
||||
)
|
||||
|
||||
if len(pair.training_df_) < training_minutes:
|
||||
print(
|
||||
f"{pair}: current offset={curr_training_start_idx}"
|
||||
f" * Training data length={len(pair.training_df_)} < {training_minutes}"
|
||||
" * Not enough training data. Completing the job."
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
# ================================ PREDICTION ================================
|
||||
self.pair_predict_result_ = pair.predict()
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"{pair}: TrainingPrediction failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
# break
|
||||
|
||||
curr_training_start_idx += 1
|
||||
if curr_training_start_idx > end_index:
|
||||
break
|
||||
curr_predicted_row_idx += 1
|
||||
|
||||
self._create_trading_signals(pair, config, bt_result)
|
||||
print(f"***{pair}*** FINISHED *** Num Trades:{len(pair.user_data_['trades'])}")
|
||||
|
||||
return pair.get_trades()
|
||||
|
||||
def _create_trading_signals(
|
||||
self, pair: TradingPair, config: Dict, bt_result: BacktestResult
|
||||
) -> None:
|
||||
|
||||
predicted_df = self.pair_predict_result_
|
||||
assert predicted_df is not None
|
||||
|
||||
open_threshold = config["dis-equilibrium_open_trshld"]
|
||||
close_threshold = config["dis-equilibrium_close_trshld"]
|
||||
for curr_predicted_row_idx in range(len(predicted_df)):
|
||||
pred_row = predicted_df.iloc[curr_predicted_row_idx]
|
||||
scaled_disequilibrium = pred_row["scaled_disequilibrium"]
|
||||
|
||||
if pair.user_data_["state"] in [
|
||||
PairState.INITIAL,
|
||||
PairState.CLOSE,
|
||||
PairState.CLOSE_POSITION,
|
||||
PairState.CLOSE_STOP_LOSS,
|
||||
PairState.CLOSE_STOP_PROFIT,
|
||||
]:
|
||||
if scaled_disequilibrium >= open_threshold:
|
||||
open_trades = self._get_open_trades(
|
||||
pair, row=pred_row, open_threshold=open_threshold
|
||||
)
|
||||
if open_trades is not None:
|
||||
open_trades["status"] = PairState.OPEN.name
|
||||
print(f"OPEN TRADES:\n{open_trades}")
|
||||
pair.add_trades(open_trades)
|
||||
pair.user_data_["state"] = PairState.OPEN
|
||||
pair.on_open_trades(open_trades)
|
||||
|
||||
elif pair.user_data_["state"] == PairState.OPEN:
|
||||
if scaled_disequilibrium <= close_threshold:
|
||||
close_trades = self._get_close_trades(
|
||||
pair, row=pred_row, close_threshold=close_threshold
|
||||
)
|
||||
if close_trades is not None:
|
||||
close_trades["status"] = PairState.CLOSE.name
|
||||
print(f"CLOSE TRADES:\n{close_trades}")
|
||||
pair.add_trades(close_trades)
|
||||
pair.user_data_["state"] = PairState.CLOSE
|
||||
pair.on_close_trades(close_trades)
|
||||
elif pair.to_stop_close_conditions(predicted_row=pred_row):
|
||||
close_trades = self._get_close_trades(
|
||||
pair, row=pred_row, close_threshold=close_threshold
|
||||
)
|
||||
if close_trades is not None:
|
||||
close_trades["status"] = pair.user_data_[
|
||||
"stop_close_state"
|
||||
].name
|
||||
print(f"STOP CLOSE TRADES:\n{close_trades}")
|
||||
pair.add_trades(close_trades)
|
||||
pair.user_data_["state"] = pair.user_data_["stop_close_state"]
|
||||
pair.on_close_trades(close_trades)
|
||||
|
||||
# Outstanding positions
|
||||
if pair.user_data_["state"] == PairState.OPEN:
|
||||
print(f"{pair}: *** Position is NOT CLOSED. ***")
|
||||
# outstanding positions
|
||||
if config["close_outstanding_positions"]:
|
||||
close_position_row = pd.Series(pair.market_data_.iloc[-2])
|
||||
close_position_row["disequilibrium"] = 0.0
|
||||
close_position_row["scaled_disequilibrium"] = 0.0
|
||||
close_position_row["signed_scaled_disequilibrium"] = 0.0
|
||||
|
||||
close_position_trades = self._get_close_trades(
|
||||
pair=pair, row=close_position_row, close_threshold=close_threshold
|
||||
)
|
||||
if close_position_trades is not None:
|
||||
close_position_trades["status"] = PairState.CLOSE_POSITION.name
|
||||
print(f"CLOSE_POSITION TRADES:\n{close_position_trades}")
|
||||
pair.add_trades(close_position_trades)
|
||||
pair.user_data_["state"] = PairState.CLOSE_POSITION
|
||||
pair.on_close_trades(close_position_trades)
|
||||
else:
|
||||
if predicted_df is not None:
|
||||
bt_result.handle_outstanding_position(
|
||||
pair=pair,
|
||||
pair_result_df=predicted_df,
|
||||
last_row_index=0,
|
||||
open_side_a=pair.user_data_["open_side_a"],
|
||||
open_side_b=pair.user_data_["open_side_b"],
|
||||
open_px_a=pair.user_data_["open_px_a"],
|
||||
open_px_b=pair.user_data_["open_px_b"],
|
||||
open_tstamp=pair.user_data_["open_tstamp"],
|
||||
)
|
||||
|
||||
def _get_open_trades(
|
||||
self, pair: TradingPair, row: pd.Series, open_threshold: float
|
||||
) -> Optional[pd.DataFrame]:
|
||||
colname_a, colname_b = pair.exec_prices_colnames()
|
||||
|
||||
open_row = row
|
||||
|
||||
open_tstamp = open_row["tstamp"]
|
||||
open_disequilibrium = open_row["disequilibrium"]
|
||||
open_scaled_disequilibrium = open_row["scaled_disequilibrium"]
|
||||
signed_scaled_disequilibrium = open_row["signed_scaled_disequilibrium"]
|
||||
open_px_a = open_row[f"{colname_a}"]
|
||||
open_px_b = open_row[f"{colname_b}"]
|
||||
|
||||
# creating the trades
|
||||
# use outer single quotes so we can reference DataFrame keys with double quotes inside
|
||||
print(f'OPEN_TRADES: {open_tstamp} open_scaled_disequilibrium={open_scaled_disequilibrium}')
|
||||
if open_disequilibrium > 0:
|
||||
open_side_a = "SELL"
|
||||
open_side_b = "BUY"
|
||||
close_side_a = "BUY"
|
||||
close_side_b = "SELL"
|
||||
else:
|
||||
open_side_a = "BUY"
|
||||
open_side_b = "SELL"
|
||||
close_side_a = "SELL"
|
||||
close_side_b = "BUY"
|
||||
|
||||
# save closing sides
|
||||
pair.user_data_["open_side_a"] = open_side_a
|
||||
pair.user_data_["open_side_b"] = open_side_b
|
||||
pair.user_data_["open_px_a"] = open_px_a
|
||||
pair.user_data_["open_px_b"] = open_px_b
|
||||
|
||||
pair.user_data_["open_tstamp"] = open_tstamp
|
||||
|
||||
pair.user_data_["close_side_a"] = close_side_a
|
||||
pair.user_data_["close_side_b"] = close_side_b
|
||||
|
||||
# create opening trades
|
||||
trd_signal_tuples = [
|
||||
(
|
||||
open_tstamp,
|
||||
pair.symbol_a_,
|
||||
open_side_a,
|
||||
"OPEN",
|
||||
open_px_a,
|
||||
open_disequilibrium,
|
||||
open_scaled_disequilibrium,
|
||||
signed_scaled_disequilibrium,
|
||||
pair,
|
||||
),
|
||||
(
|
||||
open_tstamp,
|
||||
pair.symbol_b_,
|
||||
open_side_b,
|
||||
"OPEN",
|
||||
open_px_b,
|
||||
open_disequilibrium,
|
||||
open_scaled_disequilibrium,
|
||||
signed_scaled_disequilibrium,
|
||||
pair,
|
||||
),
|
||||
]
|
||||
# Create DataFrame with explicit dtypes to avoid concatenation warnings
|
||||
df = pd.DataFrame(trd_signal_tuples, columns=self.TRADES_COLUMNS)
|
||||
# Ensure consistent dtypes
|
||||
return df.astype(
|
||||
{
|
||||
"time": "datetime64[ns]",
|
||||
"action": "string",
|
||||
"symbol": "string",
|
||||
"price": "float64",
|
||||
"disequilibrium": "float64",
|
||||
"scaled_disequilibrium": "float64",
|
||||
"signed_scaled_disequilibrium": "float64",
|
||||
"pair": "object",
|
||||
}
|
||||
)
|
||||
|
||||
def _get_close_trades(
|
||||
self, pair: TradingPair, row: pd.Series, close_threshold: float
|
||||
) -> Optional[pd.DataFrame]:
|
||||
colname_a, colname_b = pair.exec_prices_colnames()
|
||||
|
||||
close_row = row
|
||||
close_tstamp = close_row["tstamp"]
|
||||
close_disequilibrium = close_row["disequilibrium"]
|
||||
close_scaled_disequilibrium = close_row["scaled_disequilibrium"]
|
||||
signed_scaled_disequilibrium = close_row["signed_scaled_disequilibrium"]
|
||||
close_px_a = close_row[f"{colname_a}"]
|
||||
close_px_b = close_row[f"{colname_b}"]
|
||||
|
||||
close_side_a = pair.user_data_["close_side_a"]
|
||||
close_side_b = pair.user_data_["close_side_b"]
|
||||
|
||||
trd_signal_tuples = [
|
||||
(
|
||||
close_tstamp,
|
||||
pair.symbol_a_,
|
||||
close_side_a,
|
||||
"CLOSE",
|
||||
close_px_a,
|
||||
close_disequilibrium,
|
||||
close_scaled_disequilibrium,
|
||||
signed_scaled_disequilibrium,
|
||||
pair,
|
||||
),
|
||||
(
|
||||
close_tstamp,
|
||||
pair.symbol_b_,
|
||||
close_side_b,
|
||||
"CLOSE",
|
||||
close_px_b,
|
||||
close_disequilibrium,
|
||||
close_scaled_disequilibrium,
|
||||
signed_scaled_disequilibrium,
|
||||
pair,
|
||||
),
|
||||
]
|
||||
|
||||
# Add tuples to data frame with explicit dtypes to avoid concatenation warnings
|
||||
df = pd.DataFrame(
|
||||
trd_signal_tuples,
|
||||
columns=self.TRADES_COLUMNS,
|
||||
)
|
||||
# Ensure consistent dtypes
|
||||
return df.astype(
|
||||
{
|
||||
"time": "datetime64[ns]",
|
||||
"action": "string",
|
||||
"symbol": "string",
|
||||
"price": "float64",
|
||||
"disequilibrium": "float64",
|
||||
"scaled_disequilibrium": "float64",
|
||||
"signed_scaled_disequilibrium": "float64",
|
||||
"pair": "object",
|
||||
}
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
curr_training_start_idx = 0
|
||||
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd # type:ignore
|
||||
|
||||
|
||||
class PairState(Enum):
|
||||
INITIAL = 1
|
||||
OPEN = 2
|
||||
CLOSE = 3
|
||||
CLOSE_POSITION = 4
|
||||
CLOSE_STOP_LOSS = 5
|
||||
CLOSE_STOP_PROFIT = 6
|
||||
|
||||
class CointegrationData:
|
||||
EG_PVALUE_THRESHOLD = 0.05
|
||||
|
||||
tstamp_: pd.Timestamp
|
||||
pair_: str
|
||||
eg_pvalue_: float
|
||||
johansen_lr1_: float
|
||||
johansen_cvt_: float
|
||||
eg_is_cointegrated_: bool
|
||||
johansen_is_cointegrated_: bool
|
||||
|
||||
def __init__(self, pair: TradingPair):
|
||||
training_df = pair.training_df_
|
||||
|
||||
assert training_df is not None
|
||||
from statsmodels.tsa.vector_ar.vecm import coint_johansen
|
||||
|
||||
df = training_df[pair.colnames()].reset_index(drop=True)
|
||||
|
||||
# Run Johansen cointegration test
|
||||
result = coint_johansen(df, det_order=0, k_ar_diff=1)
|
||||
self.johansen_lr1_ = result.lr1[0]
|
||||
self.johansen_cvt_ = result.cvt[0, 1]
|
||||
self.johansen_is_cointegrated_ = self.johansen_lr1_ > self.johansen_cvt_
|
||||
|
||||
# Run Engle-Granger cointegration test
|
||||
from statsmodels.tsa.stattools import coint # type: ignore
|
||||
|
||||
col1, col2 = pair.colnames()
|
||||
assert training_df is not None
|
||||
series1 = training_df[col1].reset_index(drop=True)
|
||||
series2 = training_df[col2].reset_index(drop=True)
|
||||
|
||||
self.eg_pvalue_ = float(coint(series1, series2)[1])
|
||||
self.eg_is_cointegrated_ = bool(self.eg_pvalue_ < self.EG_PVALUE_THRESHOLD)
|
||||
|
||||
self.tstamp_ = training_df.index[-1]
|
||||
self.pair_ = pair.name()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"tstamp": self.tstamp_,
|
||||
"pair": self.pair_,
|
||||
"eg_pvalue": self.eg_pvalue_,
|
||||
"johansen_lr1": self.johansen_lr1_,
|
||||
"johansen_cvt": self.johansen_cvt_,
|
||||
"eg_is_cointegrated": self.eg_is_cointegrated_,
|
||||
"johansen_is_cointegrated": self.johansen_is_cointegrated_,
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CointegrationData(tstamp={self.tstamp_}, pair={self.pair_}, eg_pvalue={self.eg_pvalue_}, johansen_lr1={self.johansen_lr1_}, johansen_cvt={self.johansen_cvt_}, eg_is_cointegrated={self.eg_is_cointegrated_}, johansen_is_cointegrated={self.johansen_is_cointegrated_})"
|
||||
|
||||
|
||||
class TradingPair(ABC):
|
||||
market_data_: pd.DataFrame
|
||||
symbol_a_: str
|
||||
symbol_b_: str
|
||||
stat_model_price_: str
|
||||
|
||||
training_mu_: float
|
||||
training_std_: float
|
||||
|
||||
training_df_: pd.DataFrame
|
||||
testing_df_: pd.DataFrame
|
||||
|
||||
user_data_: Dict[str, Any]
|
||||
|
||||
# predicted_df_: Optional[pd.DataFrame]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
market_data: pd.DataFrame,
|
||||
symbol_a: str,
|
||||
symbol_b: str,
|
||||
):
|
||||
self.symbol_a_ = symbol_a
|
||||
self.symbol_b_ = symbol_b
|
||||
self.stat_model_price_ = config["stat_model_price"]
|
||||
self.user_data_ = {}
|
||||
self.predicted_df_ = None
|
||||
self.config_ = config
|
||||
|
||||
self._set_market_data(market_data)
|
||||
|
||||
def _set_market_data(self, market_data: pd.DataFrame) -> None:
|
||||
self.market_data_ = pd.DataFrame(
|
||||
self._transform_dataframe(market_data)[["tstamp"] + self.colnames()]
|
||||
)
|
||||
|
||||
self.market_data_ = self.market_data_.dropna().reset_index(drop=True)
|
||||
self.market_data_["tstamp"] = pd.to_datetime(self.market_data_["tstamp"])
|
||||
self.market_data_ = self.market_data_.sort_values("tstamp")
|
||||
self._set_execution_price_data()
|
||||
pass
|
||||
|
||||
def _set_execution_price_data(self) -> None:
|
||||
if "execution_price" not in self.config_:
|
||||
self.market_data_[f"exec_price_{self.symbol_a_}"] = self.market_data_[f"{self.stat_model_price_}_{self.symbol_a_}"]
|
||||
self.market_data_[f"exec_price_{self.symbol_b_}"] = self.market_data_[f"{self.stat_model_price_}_{self.symbol_b_}"]
|
||||
return
|
||||
execution_price_column = self.config_["execution_price"]["column"]
|
||||
execution_price_shift = self.config_["execution_price"]["shift"]
|
||||
self.market_data_[f"exec_price_{self.symbol_a_}"] = self.market_data_[f"{self.stat_model_price_}_{self.symbol_a_}"].shift(-execution_price_shift)
|
||||
self.market_data_[f"exec_price_{self.symbol_b_}"] = self.market_data_[f"{self.stat_model_price_}_{self.symbol_b_}"].shift(-execution_price_shift)
|
||||
self.market_data_ = self.market_data_.dropna().reset_index(drop=True)
|
||||
|
||||
|
||||
|
||||
|
||||
def get_begin_index(self) -> int:
|
||||
if "trading_hours" not in self.config_:
|
||||
return 0
|
||||
assert "timezone" in self.config_["trading_hours"]
|
||||
assert "begin_session" in self.config_["trading_hours"]
|
||||
start_time = (
|
||||
pd.to_datetime(self.config_["trading_hours"]["begin_session"])
|
||||
.tz_localize(self.config_["trading_hours"]["timezone"])
|
||||
.time()
|
||||
)
|
||||
mask = self.market_data_["tstamp"].dt.time >= start_time
|
||||
return int(self.market_data_.index[mask].min())
|
||||
|
||||
def get_end_index(self) -> int:
|
||||
if "trading_hours" not in self.config_:
|
||||
return 0
|
||||
assert "timezone" in self.config_["trading_hours"]
|
||||
assert "end_session" in self.config_["trading_hours"]
|
||||
end_time = (
|
||||
pd.to_datetime(self.config_["trading_hours"]["end_session"])
|
||||
.tz_localize(self.config_["trading_hours"]["timezone"])
|
||||
.time()
|
||||
)
|
||||
mask = self.market_data_["tstamp"].dt.time <= end_time
|
||||
return int(self.market_data_.index[mask].max())
|
||||
|
||||
def _transform_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
# Select only the columns we need
|
||||
df_selected: pd.DataFrame = pd.DataFrame(
|
||||
df[["tstamp", "symbol", self.stat_model_price_]]
|
||||
)
|
||||
|
||||
# Start with unique timestamps
|
||||
result_df: pd.DataFrame = (
|
||||
pd.DataFrame(df_selected["tstamp"]).drop_duplicates().reset_index(drop=True)
|
||||
)
|
||||
|
||||
# For each unique symbol, add a corresponding close price column
|
||||
|
||||
symbols = df_selected["symbol"].unique()
|
||||
for symbol in symbols:
|
||||
# Filter rows for this symbol
|
||||
df_symbol = df_selected[df_selected["symbol"] == symbol].reset_index(
|
||||
drop=True
|
||||
)
|
||||
|
||||
# Create column name like "close-COIN"
|
||||
new_price_column = f"{self.stat_model_price_}_{symbol}"
|
||||
|
||||
# Create temporary dataframe with timestamp and price
|
||||
temp_df = pd.DataFrame(
|
||||
{
|
||||
"tstamp": df_symbol["tstamp"],
|
||||
new_price_column: df_symbol[self.stat_model_price_],
|
||||
}
|
||||
)
|
||||
|
||||
# Join with our result dataframe
|
||||
result_df = pd.merge(result_df, temp_df, on="tstamp", how="left")
|
||||
result_df = result_df.reset_index(
|
||||
drop=True
|
||||
) # do not dropna() since irrelevant symbol would affect dataset
|
||||
|
||||
return result_df.dropna()
|
||||
|
||||
def get_datasets(
|
||||
self,
|
||||
training_minutes: int,
|
||||
training_start_index: int = 0,
|
||||
testing_size: Optional[int] = None,
|
||||
) -> None:
|
||||
|
||||
testing_start_index = training_start_index + training_minutes
|
||||
self.training_df_ = self.market_data_.iloc[
|
||||
training_start_index:testing_start_index, :training_minutes
|
||||
].copy()
|
||||
assert self.training_df_ is not None
|
||||
self.training_df_ = self.training_df_.dropna().reset_index(drop=True)
|
||||
|
||||
testing_start_index = training_start_index + training_minutes
|
||||
if testing_size is None:
|
||||
self.testing_df_ = self.market_data_.iloc[testing_start_index:, :].copy()
|
||||
else:
|
||||
self.testing_df_ = self.market_data_.iloc[
|
||||
testing_start_index : testing_start_index + testing_size, :
|
||||
].copy()
|
||||
assert self.testing_df_ is not None
|
||||
self.testing_df_ = self.testing_df_.dropna().reset_index(drop=True)
|
||||
|
||||
def colnames(self) -> List[str]:
|
||||
return [
|
||||
f"{self.stat_model_price_}_{self.symbol_a_}",
|
||||
f"{self.stat_model_price_}_{self.symbol_b_}",
|
||||
]
|
||||
|
||||
def exec_prices_colnames(self) -> List[str]:
|
||||
return [
|
||||
f"exec_price_{self.symbol_a_}",
|
||||
f"exec_price_{self.symbol_b_}",
|
||||
]
|
||||
|
||||
def add_trades(self, trades: pd.DataFrame) -> None:
|
||||
if self.user_data_["trades"] is None or len(self.user_data_["trades"]) == 0:
|
||||
# If trades is empty or None, just assign the new trades directly
|
||||
self.user_data_["trades"] = trades.copy()
|
||||
else:
|
||||
# Ensure both DataFrames have the same columns and dtypes before concatenation
|
||||
existing_trades = self.user_data_["trades"]
|
||||
|
||||
# If existing trades is empty, just assign the new trades
|
||||
if len(existing_trades) == 0:
|
||||
self.user_data_["trades"] = trades.copy()
|
||||
else:
|
||||
# Ensure both DataFrames have the same columns
|
||||
if set(existing_trades.columns) != set(trades.columns):
|
||||
# Add missing columns to trades with appropriate default values
|
||||
for col in existing_trades.columns:
|
||||
if col not in trades.columns:
|
||||
if col == "time":
|
||||
trades[col] = pd.Timestamp.now()
|
||||
elif col in ["action", "symbol"]:
|
||||
trades[col] = ""
|
||||
elif col in [
|
||||
"price",
|
||||
"disequilibrium",
|
||||
"scaled_disequilibrium",
|
||||
]:
|
||||
trades[col] = 0.0
|
||||
elif col == "pair":
|
||||
trades[col] = None
|
||||
else:
|
||||
trades[col] = None
|
||||
|
||||
# Concatenate with explicit dtypes to avoid warnings
|
||||
self.user_data_["trades"] = pd.concat(
|
||||
[existing_trades, trades], ignore_index=True, copy=False
|
||||
)
|
||||
|
||||
def get_trades(self) -> pd.DataFrame:
|
||||
return (
|
||||
self.user_data_["trades"] if "trades" in self.user_data_ else pd.DataFrame()
|
||||
)
|
||||
|
||||
def cointegration_check(self) -> Optional[pd.DataFrame]:
|
||||
print(f"***{self}*** STARTING....")
|
||||
config = self.config_
|
||||
|
||||
curr_training_start_idx = 0
|
||||
|
||||
COINTEGRATION_DATA_COLUMNS = {
|
||||
"tstamp": "datetime64[ns]",
|
||||
"pair": "string",
|
||||
"eg_pvalue": "float64",
|
||||
"johansen_lr1": "float64",
|
||||
"johansen_cvt": "float64",
|
||||
"eg_is_cointegrated": "bool",
|
||||
"johansen_is_cointegrated": "bool",
|
||||
}
|
||||
# Initialize trades DataFrame with proper dtypes to avoid concatenation warnings
|
||||
result: pd.DataFrame = pd.DataFrame(
|
||||
columns=[col for col in COINTEGRATION_DATA_COLUMNS.keys()]
|
||||
) # .astype(COINTEGRATION_DATA_COLUMNS)
|
||||
|
||||
training_minutes = config["training_minutes"]
|
||||
while True:
|
||||
print(curr_training_start_idx, end="\r")
|
||||
self.get_datasets(
|
||||
training_minutes=training_minutes,
|
||||
training_start_index=curr_training_start_idx,
|
||||
testing_size=1,
|
||||
)
|
||||
|
||||
if len(self.training_df_) < training_minutes:
|
||||
print(
|
||||
f"{self}: current offset={curr_training_start_idx}"
|
||||
f" * Training data length={len(self.training_df_)} < {training_minutes}"
|
||||
" * Not enough training data. Completing the job."
|
||||
)
|
||||
break
|
||||
new_row = pd.Series(CointegrationData(self).to_dict())
|
||||
result.loc[len(result)] = new_row
|
||||
curr_training_start_idx += 1
|
||||
return result
|
||||
|
||||
def to_stop_close_conditions(self, predicted_row: pd.Series) -> bool:
|
||||
config = self.config_
|
||||
if (
|
||||
"stop_close_conditions" not in config
|
||||
or config["stop_close_conditions"] is None
|
||||
):
|
||||
return False
|
||||
if "profit" in config["stop_close_conditions"]:
|
||||
current_return = self._current_return(predicted_row)
|
||||
#
|
||||
# print(f"time={predicted_row['tstamp']} current_return={current_return}")
|
||||
#
|
||||
if current_return >= config["stop_close_conditions"]["profit"]:
|
||||
print(f"STOP PROFIT: {current_return}")
|
||||
self.user_data_["stop_close_state"] = PairState.CLOSE_STOP_PROFIT
|
||||
return True
|
||||
if "loss" in config["stop_close_conditions"]:
|
||||
if current_return <= config["stop_close_conditions"]["loss"]:
|
||||
print(f"STOP LOSS: {current_return}")
|
||||
self.user_data_["stop_close_state"] = PairState.CLOSE_STOP_LOSS
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_open_trades(self, trades: pd.DataFrame) -> None:
|
||||
if "close_trades" in self.user_data_:
|
||||
del self.user_data_["close_trades"]
|
||||
self.user_data_["open_trades"] = trades
|
||||
|
||||
def on_close_trades(self, trades: pd.DataFrame) -> None:
|
||||
del self.user_data_["open_trades"]
|
||||
self.user_data_["close_trades"] = trades
|
||||
|
||||
def _current_return(self, predicted_row: pd.Series) -> float:
|
||||
if "open_trades" in self.user_data_:
|
||||
open_trades = self.user_data_["open_trades"]
|
||||
if len(open_trades) == 0:
|
||||
return 0.0
|
||||
|
||||
def _single_instrument_return(symbol: str) -> float:
|
||||
instrument_open_trades = open_trades[open_trades["symbol"] == symbol]
|
||||
instrument_open_price = instrument_open_trades["price"].iloc[0]
|
||||
|
||||
sign = -1 if instrument_open_trades["side"].iloc[0] == "SELL" else 1
|
||||
instrument_price = predicted_row[f"{self.stat_model_price_}_{symbol}"]
|
||||
instrument_return = (
|
||||
sign
|
||||
* (instrument_price - instrument_open_price)
|
||||
/ instrument_open_price
|
||||
)
|
||||
return float(instrument_return) * 100.0
|
||||
|
||||
instrument_a_return = _single_instrument_return(self.symbol_a_)
|
||||
instrument_b_return = _single_instrument_return(self.symbol_b_)
|
||||
return instrument_a_return + instrument_b_return
|
||||
return 0.0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name()
|
||||
|
||||
def name(self) -> str:
|
||||
return f"{self.symbol_a_} & {self.symbol_b_}"
|
||||
# return f"{self.symbol_a_} & {self.symbol_b_}"
|
||||
|
||||
@abstractmethod
|
||||
def predict(self) -> pd.DataFrame: ...
|
||||
|
||||
# @abstractmethod
|
||||
# def predicted_df(self) -> Optional[pd.DataFrame]: ...
|
||||
@@ -0,0 +1,193 @@
|
||||
# original script moved to vecm_rolling_fit_01.py
|
||||
|
||||
# 09.09.25 Added GARCH model - predicting volatility
|
||||
|
||||
# Rule of thumb:
|
||||
# alpha + beta ≈ 1 → strong volatility clustering, persistence.
|
||||
# If much lower → volatility mean reverts quickly.
|
||||
# If > 1 → model is unstable / non-stationary (bad).
|
||||
|
||||
# the VECM disequilibrium (mean reversion signal) and
|
||||
# the GARCH volatility forecast (risk measure).
|
||||
# combine them → e.g., only enter trades when:
|
||||
|
||||
# high_volatility = 1 → persistence > 0.95 or volatility > 2 (rule of thumb: unstable / risky regime).
|
||||
# high_volatility = 0 → stable regime.
|
||||
|
||||
|
||||
# VECM disequilibrium z-score > threshold and
|
||||
# GARCH-forecasted volatility is not too high (avoid noise-driven signals).
|
||||
# This creates a volatility-adjusted pairs trading strategy, more robust than plain VECM
|
||||
|
||||
# now pair_predict_result_ DataFrame includes:
|
||||
# disequilibrium, scaled_disequilibrium, z-scores, garch_alpha, garch_beta, garch_persistence (α+β rule-of-thumb)
|
||||
# garch_vol_forecast (1-step volatility forecast)
|
||||
|
||||
# Would you like me to also add a warning flag column
|
||||
# (e.g., "high_volatility" = 1 if persistence > 0.95 or vol_forecast > threshold)
|
||||
# so you can easily detect unstable regimes?
|
||||
|
||||
# VECM/GARCH
|
||||
# vecm_rolling_fit.py:
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Any, Dict, Optional
|
||||
from pt_trading.results import BacktestResult
|
||||
from pt_trading.rolling_window_fit import RollingFit
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
from statsmodels.tsa.vector_ar.vecm import VECM, VECMResults
|
||||
from arch import arch_model
|
||||
|
||||
NanoPerMin = 1e9
|
||||
|
||||
class VECMTradingPair(TradingPair):
|
||||
vecm_fit_: Optional[VECMResults]
|
||||
pair_predict_result_: Optional[pd.DataFrame]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
market_data: pd.DataFrame,
|
||||
symbol_a: str,
|
||||
symbol_b: str,
|
||||
):
|
||||
super().__init__(config, market_data, symbol_a, symbol_b)
|
||||
self.vecm_fit_ = None
|
||||
self.pair_predict_result_ = None
|
||||
self.garch_fit_ = None
|
||||
self.sigma_spread_forecast_ = None
|
||||
self.garch_alpha_ = None
|
||||
self.garch_beta_ = None
|
||||
self.garch_persistence_ = None
|
||||
self.high_volatility_flag_ = None
|
||||
|
||||
def _train_pair(self) -> None:
|
||||
self._fit_VECM()
|
||||
assert self.vecm_fit_ is not None
|
||||
|
||||
diseq_series = self.training_df_[self.colnames()] @ self.vecm_fit_.beta
|
||||
self.training_mu_ = float(diseq_series[0].mean())
|
||||
self.training_std_ = float(diseq_series[0].std())
|
||||
|
||||
self.training_df_["disequilibrium"] = diseq_series
|
||||
self.training_df_["scaled_disequilibrium"] = (
|
||||
diseq_series - self.training_mu_
|
||||
) / self.training_std_
|
||||
|
||||
def _fit_VECM(self) -> None:
|
||||
assert self.training_df_ is not None
|
||||
vecm_df = self.training_df_[self.colnames()].reset_index(drop=True)
|
||||
vecm_model = VECM(vecm_df, coint_rank=1)
|
||||
vecm_fit = vecm_model.fit()
|
||||
self.vecm_fit_ = vecm_fit
|
||||
|
||||
# Error Correction Term (spread)
|
||||
ect_series = (vecm_df @ vecm_fit.beta).iloc[:, 0]
|
||||
|
||||
# Difference the spread for stationarity
|
||||
dz = ect_series.diff().dropna()
|
||||
|
||||
if len(dz) < 30:
|
||||
print("Not enough data for GARCH fitting.")
|
||||
return
|
||||
|
||||
# Rescale if variance too small
|
||||
if dz.std() < 0.1:
|
||||
dz = dz * 1000
|
||||
# print("Scale check:", dz.std())
|
||||
|
||||
try:
|
||||
garch = arch_model(dz, vol="GARCH", p=1, q=1, mean="Zero", dist="normal")
|
||||
garch_fit = garch.fit(disp="off")
|
||||
self.garch_fit_ = garch_fit
|
||||
|
||||
# Extract parameters
|
||||
params = garch_fit.params
|
||||
self.garch_alpha_ = params.get("alpha[1]", np.nan)
|
||||
self.garch_beta_ = params.get("beta[1]", np.nan)
|
||||
self.garch_persistence_ = self.garch_alpha_ + self.garch_beta_
|
||||
|
||||
# print (f"GARCH α: {self.garch_alpha_:.4f}, β: {self.garch_beta_:.4f}, "
|
||||
# f"α+β (persistence): {self.garch_persistence_:.4f}")
|
||||
|
||||
# One-step-ahead volatility forecast
|
||||
forecast = garch_fit.forecast(horizon=1)
|
||||
sigma_next = np.sqrt(forecast.variance.iloc[-1, 0])
|
||||
self.sigma_spread_forecast_ = float(sigma_next)
|
||||
# print("GARCH sigma forecast:", self.sigma_spread_forecast_)
|
||||
|
||||
# Rule of thumb: persistence close to 1 or large volatility forecast
|
||||
self.high_volatility_flag_ = int(
|
||||
(self.garch_persistence_ is not None and self.garch_persistence_ > 0.95)
|
||||
or (self.sigma_spread_forecast_ is not None and self.sigma_spread_forecast_ > 2)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"GARCH fit failed: {e}")
|
||||
self.garch_fit_ = None
|
||||
self.sigma_spread_forecast_ = None
|
||||
self.high_volatility_flag_ = None
|
||||
|
||||
def predict(self) -> pd.DataFrame:
|
||||
self._train_pair()
|
||||
assert self.testing_df_ is not None
|
||||
assert self.vecm_fit_ is not None
|
||||
|
||||
# VECM predictions
|
||||
predicted_prices = self.vecm_fit_.predict(steps=len(self.testing_df_))
|
||||
predicted_df = pd.merge(
|
||||
self.testing_df_.reset_index(drop=True),
|
||||
pd.DataFrame(predicted_prices, columns=pd.Index(self.colnames()), dtype=float),
|
||||
left_index=True,
|
||||
right_index=True,
|
||||
suffixes=("", "_pred"),
|
||||
).dropna()
|
||||
|
||||
# Disequilibrium and z-scores
|
||||
predicted_df["disequilibrium"] = (
|
||||
predicted_df[self.colnames()] @ self.vecm_fit_.beta
|
||||
)
|
||||
predicted_df["signed_scaled_disequilibrium"] = (
|
||||
predicted_df["disequilibrium"] - self.training_mu_
|
||||
) / self.training_std_
|
||||
predicted_df["scaled_disequilibrium"] = abs(
|
||||
predicted_df["signed_scaled_disequilibrium"]
|
||||
)
|
||||
|
||||
# Add GARCH parameters + volatility forecast
|
||||
predicted_df["garch_alpha"] = self.garch_alpha_
|
||||
predicted_df["garch_beta"] = self.garch_beta_
|
||||
predicted_df["garch_persistence"] = self.garch_persistence_
|
||||
predicted_df["garch_vol_forecast"] = self.sigma_spread_forecast_
|
||||
predicted_df["high_volatility"] = self.high_volatility_flag_
|
||||
|
||||
# Save results
|
||||
if self.pair_predict_result_ is None:
|
||||
self.pair_predict_result_ = predicted_df
|
||||
else:
|
||||
self.pair_predict_result_ = pd.concat(
|
||||
[self.pair_predict_result_, predicted_df], ignore_index=True
|
||||
)
|
||||
|
||||
return self.pair_predict_result_
|
||||
|
||||
|
||||
class VECMRollingFit(RollingFit):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def create_trading_pair(
|
||||
self,
|
||||
config: Dict,
|
||||
market_data: pd.DataFrame,
|
||||
symbol_a: str,
|
||||
symbol_b: str,
|
||||
) -> TradingPair:
|
||||
return VECMTradingPair(
|
||||
config=config,
|
||||
market_data=market_data,
|
||||
symbol_a = symbol_a,
|
||||
symbol_b = symbol_b,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
import statsmodels.api as sm
|
||||
|
||||
from pt_trading.rolling_window_fit import RollingFit
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
NanoPerMin = 1e9
|
||||
|
||||
|
||||
class ZScoreTradingPair(TradingPair):
|
||||
"""TradingPair implementation that fits a hedge ratio with OLS and
|
||||
computes a standardized spread (z-score).
|
||||
|
||||
The class stores training spread mean/std and hedge ratio so the model
|
||||
can be applied to testing data consistently.
|
||||
"""
|
||||
|
||||
zscore_model_: Optional[sm.regression.linear_model.RegressionResultsWrapper]
|
||||
pair_predict_result_: Optional[pd.DataFrame]
|
||||
zscore_df_: Optional[pd.Series]
|
||||
hedge_ratio_: Optional[float]
|
||||
spread_mean_: Optional[float]
|
||||
spread_std_: Optional[float]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
market_data: pd.DataFrame,
|
||||
symbol_a: str,
|
||||
symbol_b: str,
|
||||
):
|
||||
super().__init__(config, market_data, symbol_a, symbol_b)
|
||||
self.zscore_model_ = None
|
||||
self.pair_predict_result_ = None
|
||||
self.zscore_df_ = None
|
||||
self.hedge_ratio_ = None
|
||||
self.spread_mean_ = None
|
||||
self.spread_std_ = None
|
||||
|
||||
def _fit_zscore(self) -> None:
|
||||
"""Fit OLS on the training window and compute training z-score."""
|
||||
assert self.training_df_ is not None
|
||||
|
||||
# Extract price series for the two symbols from the training frame.
|
||||
px_df = self.training_df_[self.colnames()]
|
||||
symbol_a_px = px_df.iloc[:, 0]
|
||||
symbol_b_px = px_df.iloc[:, 1]
|
||||
|
||||
# Align indexes and fit OLS: symbol_a ~ const + symbol_b
|
||||
symbol_a_px, symbol_b_px = symbol_a_px.align(symbol_b_px, join="inner")
|
||||
X = sm.add_constant(symbol_b_px)
|
||||
self.zscore_model_ = sm.OLS(symbol_a_px, X).fit()
|
||||
|
||||
# Hedge ratio is the slope on symbol_b
|
||||
params = self.zscore_model_.params
|
||||
self.hedge_ratio_ = float(params.iloc[1]) if len(params) > 1 else 0.0
|
||||
|
||||
# Training spread and its standardized z-score
|
||||
spread = symbol_a_px - self.hedge_ratio_ * symbol_b_px
|
||||
self.spread_mean_ = float(spread.mean())
|
||||
self.spread_std_ = float(spread.std(ddof=0)) if spread.std(ddof=0) != 0 else 1.0
|
||||
self.zscore_df_ = (spread - self.spread_mean_) / self.spread_std_
|
||||
|
||||
def predict(self) -> pd.DataFrame:
|
||||
"""Apply fitted hedge ratio to the testing frame and return a
|
||||
dataframe with canonical columns:
|
||||
- disequilibrium: signed z-score
|
||||
- scaled_disequilibrium: absolute z-score
|
||||
- signed_scaled_disequilibrium: same as disequilibrium (keeps sign)
|
||||
"""
|
||||
# Fit on training window
|
||||
self._fit_zscore()
|
||||
assert self.zscore_df_ is not None
|
||||
assert self.hedge_ratio_ is not None
|
||||
assert self.spread_mean_ is not None and self.spread_std_ is not None
|
||||
|
||||
# Keep training columns for inspection
|
||||
self.training_df_["disequilibrium"] = self.zscore_df_
|
||||
self.training_df_["scaled_disequilibrium"] = self.zscore_df_.abs()
|
||||
|
||||
# Apply model to testing frame
|
||||
assert self.testing_df_ is not None
|
||||
test_df = self.testing_df_.copy()
|
||||
px_test = test_df[self.colnames()]
|
||||
a_test = px_test.iloc[:, 0]
|
||||
b_test = px_test.iloc[:, 1]
|
||||
a_test, b_test = a_test.align(b_test, join="inner")
|
||||
|
||||
# Compute test spread and standardize using training mean/std
|
||||
test_spread = a_test - self.hedge_ratio_ * b_test
|
||||
test_zscore = (test_spread - self.spread_mean_) / self.spread_std_
|
||||
|
||||
# Attach canonical columns
|
||||
# Align back to test_df index if needed
|
||||
test_zscore = test_zscore.reindex(test_df.index)
|
||||
test_df["disequilibrium"] = test_zscore
|
||||
test_df["signed_scaled_disequilibrium"] = test_zscore
|
||||
test_df["scaled_disequilibrium"] = test_zscore.abs()
|
||||
|
||||
# Reset index and accumulate results across windows
|
||||
test_df = test_df.reset_index(drop=True)
|
||||
if self.pair_predict_result_ is None:
|
||||
self.pair_predict_result_ = test_df
|
||||
else:
|
||||
self.pair_predict_result_ = pd.concat(
|
||||
[self.pair_predict_result_, test_df], ignore_index=True
|
||||
)
|
||||
|
||||
self.pair_predict_result_ = self.pair_predict_result_.reset_index(drop=True)
|
||||
return self.pair_predict_result_.dropna()
|
||||
|
||||
|
||||
class ZScoreRollingFit(RollingFit):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def create_trading_pair(
|
||||
self, config: Dict, market_data: pd.DataFrame, symbol_a: str, symbol_b: str
|
||||
) -> TradingPair:
|
||||
return ZScoreTradingPair(
|
||||
config=config, market_data=market_data, symbol_a=symbol_a, symbol_b=symbol_b
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import hjson
|
||||
from typing import Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def load_config(config_path: str) -> Dict:
|
||||
with open(config_path, "r") as f:
|
||||
config = hjson.load(f)
|
||||
return dict(config)
|
||||
|
||||
|
||||
def expand_filename(filename: str) -> str:
|
||||
# expand %T
|
||||
res = filename.replace("%T", datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
# expand %D
|
||||
return res.replace("%D", datetime.now().strftime("%Y%m%d"))
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Dict, List, cast
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load_sqlite_to_dataframe(db_path:str, query:str) -> pd.DataFrame:
|
||||
df: pd.DataFrame = pd.DataFrame()
|
||||
import os
|
||||
if not os.path.exists(db_path):
|
||||
print(f"WARNING: database file {db_path} does not exist")
|
||||
return df
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
df = pd.read_sql_query(query, conn)
|
||||
return df
|
||||
except sqlite3.Error as excpt:
|
||||
print(f"SQLite error: {excpt}")
|
||||
raise
|
||||
except Exception as excpt:
|
||||
print(f"Error: {excpt}")
|
||||
raise Exception() from excpt
|
||||
finally:
|
||||
if "conn" in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
def convert_time_to_UTC(value: str, timezone: str, extra_minutes: int = 0) -> str:
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Parse it to naive datetime object
|
||||
local_dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
local_dt = local_dt + timedelta(minutes=extra_minutes)
|
||||
|
||||
zinfo = ZoneInfo(timezone)
|
||||
result: datetime = local_dt.replace(tzinfo=zinfo).astimezone(ZoneInfo("UTC"))
|
||||
|
||||
return result.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def load_market_data(
|
||||
datafile: str,
|
||||
instruments: List[Dict[str, str]],
|
||||
db_table_name: str,
|
||||
trading_hours: Dict = {},
|
||||
extra_minutes: int = 0,
|
||||
) -> pd.DataFrame:
|
||||
|
||||
insts = [
|
||||
'"' + instrument["instrument_id_pfx"] + instrument["symbol"] + '"'
|
||||
for instrument in instruments
|
||||
]
|
||||
instrument_ids = list(set(insts))
|
||||
exchange_ids = list(
|
||||
set(['"' + instrument["exchange_id"] + '"' for instrument in instruments])
|
||||
)
|
||||
|
||||
query = "select"
|
||||
query += " tstamp"
|
||||
query += ", tstamp_ns as time_ns"
|
||||
|
||||
query += f", substr(instrument_id, instr(instrument_id, '-') + 1) as symbol"
|
||||
query += ", open"
|
||||
query += ", high"
|
||||
query += ", low"
|
||||
query += ", close"
|
||||
query += ", volume"
|
||||
query += ", num_trades"
|
||||
query += ", vwap"
|
||||
|
||||
query += f" from {db_table_name}"
|
||||
query += f" where exchange_id in ({','.join(exchange_ids)})"
|
||||
query += f" and instrument_id in ({','.join(instrument_ids)})"
|
||||
|
||||
df = load_sqlite_to_dataframe(db_path=datafile, query=query)
|
||||
|
||||
# Trading Hours
|
||||
if len(df) > 0 and len(trading_hours) > 0:
|
||||
date_str = df["tstamp"][0][0:10]
|
||||
|
||||
start_time = convert_time_to_UTC(
|
||||
f"{date_str} {trading_hours['begin_session']}", trading_hours["timezone"]
|
||||
)
|
||||
end_time = convert_time_to_UTC(
|
||||
f"{date_str} {trading_hours['end_session']}", trading_hours["timezone"], extra_minutes=extra_minutes # to get execution price
|
||||
)
|
||||
|
||||
# Perform boolean selection
|
||||
df = df[(df["tstamp"] >= start_time) & (df["tstamp"] <= end_time)]
|
||||
df["tstamp"] = pd.to_datetime(df["tstamp"])
|
||||
|
||||
return cast(pd.DataFrame, df)
|
||||
|
||||
|
||||
# def get_available_instruments_from_db(datafile: str, config: Dict) -> List[str]:
|
||||
# """
|
||||
# Auto-detect available instruments from the database by querying distinct instrument_id values.
|
||||
# Returns instruments without the configured prefix.
|
||||
# """
|
||||
# try:
|
||||
# conn = sqlite3.connect(datafile)
|
||||
|
||||
# # Build exclusion list with full instrument_ids
|
||||
# exclude_instruments = config.get("exclude_instruments", [])
|
||||
# prefix = config.get("instrument_id_pfx", "")
|
||||
# exclude_instrument_ids = [f"{prefix}{inst}" for inst in exclude_instruments]
|
||||
|
||||
# # Query to get distinct instrument_ids
|
||||
# query = f"""
|
||||
# SELECT DISTINCT instrument_id
|
||||
# FROM {config['db_table_name']}
|
||||
# WHERE exchange_id = ?
|
||||
# """
|
||||
|
||||
# # Add exclusion clause if there are instruments to exclude
|
||||
# if exclude_instrument_ids:
|
||||
# placeholders = ",".join(["?" for _ in exclude_instrument_ids])
|
||||
# query += f" AND instrument_id NOT IN ({placeholders})"
|
||||
# cursor = conn.execute(
|
||||
# query, (config["exchange_id"],) + tuple(exclude_instrument_ids)
|
||||
# )
|
||||
# else:
|
||||
# cursor = conn.execute(query, (config["exchange_id"],))
|
||||
# instrument_ids = [row[0] for row in cursor.fetchall()]
|
||||
# conn.close()
|
||||
|
||||
# # Remove the configured prefix to get instrument symbols
|
||||
# instruments = []
|
||||
# for instrument_id in instrument_ids:
|
||||
# if instrument_id.startswith(prefix):
|
||||
# symbol = instrument_id[len(prefix) :]
|
||||
# instruments.append(symbol)
|
||||
# else:
|
||||
# instruments.append(instrument_id)
|
||||
|
||||
# return sorted(instruments)
|
||||
|
||||
# except Exception as e:
|
||||
# print(f"Error auto-detecting instruments from {datafile}: {str(e)}")
|
||||
# return []
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# df1 = load_sqlite_to_dataframe(sys.argv[1], table_name="md_1min_bars")
|
||||
|
||||
# print(df1)
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database inspector utility for pairs trading results database.
|
||||
Provides functionality to view all tables and their contents.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
|
||||
def list_tables(db_path: str) -> List[str]:
|
||||
"""List all tables in the database."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return tables
|
||||
|
||||
def view_table_schema(db_path: str, table_name: str) -> None:
|
||||
"""View the schema of a specific table."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
print(f"\nTable: {table_name}")
|
||||
print("-" * 50)
|
||||
print("Column Name".ljust(20) + "Type".ljust(15) + "Not Null".ljust(10) + "Default")
|
||||
print("-" * 50)
|
||||
|
||||
for col in columns:
|
||||
cid, name, type_, not_null, default_value, pk = col
|
||||
print(f"{name}".ljust(20) + f"{type_}".ljust(15) + f"{bool(not_null)}".ljust(10) + f"{default_value or ''}")
|
||||
|
||||
conn.close()
|
||||
|
||||
def view_config_table(db_path: str, limit: int = 10) -> None:
|
||||
"""View entries from the config table."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(f"""
|
||||
SELECT id, run_timestamp, config_file_path, fit_method_class,
|
||||
datafiles, instruments, config_json
|
||||
FROM config
|
||||
ORDER BY run_timestamp DESC
|
||||
LIMIT {limit}
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
print("No configuration entries found.")
|
||||
return
|
||||
|
||||
print(f"\nMost recent {len(rows)} configuration entries:")
|
||||
print("=" * 80)
|
||||
|
||||
for row in rows:
|
||||
id, run_timestamp, config_file_path, fit_method_class, datafiles, instruments, config_json = row
|
||||
|
||||
print(f"ID: {id} | {run_timestamp}")
|
||||
print(f"Config: {config_file_path} | Strategy: {fit_method_class}")
|
||||
print(f"Files: {datafiles}")
|
||||
print(f"Instruments: {instruments}")
|
||||
print("-" * 80)
|
||||
|
||||
conn.close()
|
||||
|
||||
def view_results_summary(db_path: str) -> None:
|
||||
"""View summary of trading results."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get results summary
|
||||
cursor.execute("""
|
||||
SELECT date, COUNT(*) as trade_count,
|
||||
ROUND(SUM(symbol_return), 2) as total_return
|
||||
FROM pt_bt_results
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
|
||||
results = cursor.fetchall()
|
||||
|
||||
if not results:
|
||||
print("No trading results found.")
|
||||
return
|
||||
|
||||
print(f"\nTrading Results Summary:")
|
||||
print("-" * 50)
|
||||
print("Date".ljust(15) + "Trades".ljust(10) + "Total Return %")
|
||||
print("-" * 50)
|
||||
|
||||
for date, trade_count, total_return in results:
|
||||
print(f"{date}".ljust(15) + f"{trade_count}".ljust(10) + f"{total_return}")
|
||||
|
||||
# Get outstanding positions summary
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as position_count,
|
||||
ROUND(SUM(unrealized_return), 2) as total_unrealized
|
||||
FROM outstanding_positions
|
||||
""")
|
||||
|
||||
outstanding = cursor.fetchone()
|
||||
if outstanding and outstanding[0] > 0:
|
||||
print(f"\nOutstanding Positions: {outstanding[0]} positions")
|
||||
print(f"Total Unrealized Return: {outstanding[1]}%")
|
||||
|
||||
conn.close()
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python db_inspector.py <database_path> [command]")
|
||||
print("Commands:")
|
||||
print(" tables - List all tables")
|
||||
print(" schema - Show schema for all tables")
|
||||
print(" config - View configuration entries")
|
||||
print(" results - View trading results summary")
|
||||
print(" all - Show everything (default)")
|
||||
print("\nExample: python db_inspector.py results/equity.db config")
|
||||
sys.exit(1)
|
||||
|
||||
db_path = sys.argv[1]
|
||||
command = sys.argv[2] if len(sys.argv) > 2 else "all"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database file not found: {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
if command in ["tables", "all"]:
|
||||
tables = list_tables(db_path)
|
||||
print(f"Tables in database: {', '.join(tables)}")
|
||||
|
||||
if command in ["schema", "all"]:
|
||||
tables = list_tables(db_path)
|
||||
for table in tables:
|
||||
view_table_schema(db_path, table)
|
||||
|
||||
if command in ["config", "all"]:
|
||||
if "config" in list_tables(db_path):
|
||||
view_config_table(db_path)
|
||||
else:
|
||||
print("Config table not found.")
|
||||
|
||||
if command in ["results", "all"]:
|
||||
if "pt_bt_results" in list_tables(db_path):
|
||||
view_results_summary(db_path)
|
||||
else:
|
||||
print("Results table not found.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error inspecting database: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,303 +0,0 @@
|
||||
"""Panel application for single-day SPBT result analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import panel as pn
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = APP_DIR.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from scripts import spbt_day
|
||||
|
||||
|
||||
pn.extension("tabulator", "plotly")
|
||||
|
||||
|
||||
PAIR_THEO_RET_SORT_COLUMNS = ["total_pnl", "pair_name"]
|
||||
PAIR_THEO_RET_DISPLAY_DROP_COLUMNS = ["total_pnl"]
|
||||
APP_TITLE = "SPBT Day Analysis"
|
||||
APP_ACCENT_COLOR = "#226c67"
|
||||
APP_HEADER_COLOR = "#184c47"
|
||||
APP_SIDEBAR_WIDTH = 215
|
||||
APP_SIDEBAR_CONTROL_WIDTH = 200
|
||||
|
||||
|
||||
class SpbtDayPanelApp:
|
||||
"""Stateful Panel UI for single-day SPBT analysis."""
|
||||
|
||||
def __init__(self, repo_root: Path | None = None) -> None:
|
||||
self.repo_root = (repo_root or spbt_day.find_repo_root(REPO_ROOT)).resolve()
|
||||
self.selector_pair_rankings = pd.DataFrame()
|
||||
self.trading_instructions = pd.DataFrame()
|
||||
self.pair_theo_ret = pd.DataFrame()
|
||||
self.selected_pair_theo_executions = pd.DataFrame()
|
||||
self.selected_pair_name: str | None = None
|
||||
self.min_pctg_change = 0.0
|
||||
|
||||
self.directory_input = pn.widgets.TextInput(
|
||||
label="Directory",
|
||||
value=str(self.repo_root / "data"),
|
||||
sizing_mode="stretch_width",
|
||||
width=None,
|
||||
)
|
||||
self.show_all_files = pn.widgets.Checkbox(label="Show all files", value=False)
|
||||
self.file_select = pn.widgets.Select(
|
||||
label="SQLite result file",
|
||||
options={},
|
||||
sizing_mode="stretch_width",
|
||||
width=None,
|
||||
)
|
||||
self.min_pctg_change_input = pn.widgets.FloatInput(
|
||||
label="Mininal TARGET change (%)",
|
||||
value=0.0,
|
||||
step=1.0,
|
||||
sizing_mode="stretch_width",
|
||||
width=None,
|
||||
)
|
||||
self.calculate_button = pn.widgets.Button(
|
||||
label="Calculate",
|
||||
color="primary",
|
||||
width=110,
|
||||
)
|
||||
|
||||
self.status = pn.pane.Markdown("")
|
||||
self.pair_theo_ret_table = spbt_day.create_pair_theo_ret_analyze_grid(
|
||||
pd.DataFrame(),
|
||||
height=420,
|
||||
)
|
||||
self.total_pnl_histogram = pn.pane.Plotly(
|
||||
None,
|
||||
height=360,
|
||||
sizing_mode="stretch_width",
|
||||
)
|
||||
self.selected_pair_message = pn.pane.Markdown(
|
||||
"Click Analyze in the Pair TheoRet grid to load individual-pair details."
|
||||
)
|
||||
self.selected_pair_executions_table = spbt_day.create_selected_pair_executions_grid(
|
||||
height=320,
|
||||
)
|
||||
self.selected_pair_market_plot = pn.pane.Plotly(
|
||||
None,
|
||||
height=520,
|
||||
sizing_mode="stretch_width",
|
||||
)
|
||||
|
||||
self.calculate_button.on_click(self.calculate)
|
||||
self.directory_input.param.watch(self.refresh_files, "value")
|
||||
self.show_all_files.param.watch(self.refresh_files, "value")
|
||||
self.pair_theo_ret_table.on_click(
|
||||
self.analyze_pair_click,
|
||||
column=spbt_day.ANALYZE_BUTTON_COLUMN,
|
||||
)
|
||||
self.refresh_files()
|
||||
|
||||
def set_status(self, message: str, *, error: bool = False) -> None:
|
||||
"""Update visible status text."""
|
||||
prefix = "**Error:** " if error else ""
|
||||
self.status.object = f"{prefix}{message}" if message else ""
|
||||
|
||||
def selected_database_path(self) -> Path:
|
||||
"""Return the selected result database path."""
|
||||
if not self.file_select.value:
|
||||
raise ValueError("Select a SQLite result file before calculating.")
|
||||
db_path = Path(str(self.file_select.value)).resolve()
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"Selected database does not exist: {db_path}")
|
||||
if not db_path.is_file():
|
||||
raise ValueError(f"Selected database path is not a file: {db_path}")
|
||||
return db_path
|
||||
|
||||
def refresh_files(self, *_events: Any) -> bool:
|
||||
"""Refresh selectable SQLite files from the configured directory."""
|
||||
try:
|
||||
directory = spbt_day.normalize_directory(
|
||||
self.directory_input.value,
|
||||
self.repo_root,
|
||||
)
|
||||
candidates = spbt_day.list_candidate_files(
|
||||
directory,
|
||||
show_all=self.show_all_files.value,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.file_select.options = {}
|
||||
self.file_select.value = None
|
||||
self.set_status(str(exc), error=True)
|
||||
return False
|
||||
|
||||
options = {path.name: str(path) for path in candidates}
|
||||
previous_value = self.file_select.value
|
||||
self.file_select.options = options
|
||||
if previous_value in options.values():
|
||||
self.file_select.value = previous_value
|
||||
elif options:
|
||||
self.file_select.value = next(iter(options.values()))
|
||||
else:
|
||||
self.file_select.value = None
|
||||
|
||||
if options:
|
||||
self.set_status(f"Found {len(options):,} file(s) in {directory}.")
|
||||
else:
|
||||
self.set_status(f"No selectable files found in {directory}.")
|
||||
return True
|
||||
|
||||
def calculate(self, *_events: Any) -> None:
|
||||
"""Load selected data and calculate all-pair TheoRet."""
|
||||
self.calculate_button.loading = True
|
||||
try:
|
||||
if not self.refresh_files():
|
||||
return
|
||||
db_path = self.selected_database_path()
|
||||
self.min_pctg_change = float(self.min_pctg_change_input.value)
|
||||
|
||||
conn = spbt_day.connect_sqlite_read_only(db_path)
|
||||
try:
|
||||
self.selector_pair_rankings = spbt_day.load_selector_pair_rankings(conn)
|
||||
self.trading_instructions = spbt_day.load_trading_instructions(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.pair_theo_ret = (
|
||||
spbt_day.add_total_pnl(
|
||||
spbt_day.calculate_ranked_pairs_theo_ret(
|
||||
self.selector_pair_rankings,
|
||||
self.trading_instructions,
|
||||
min_pctg_change=self.min_pctg_change,
|
||||
)
|
||||
)
|
||||
.sort_values(
|
||||
PAIR_THEO_RET_SORT_COLUMNS,
|
||||
ascending=[True, True],
|
||||
kind="mergesort",
|
||||
)
|
||||
.drop(columns=PAIR_THEO_RET_DISPLAY_DROP_COLUMNS)
|
||||
.reset_index(drop=True)
|
||||
)
|
||||
self.pair_theo_ret_table.value = spbt_day.format_pair_theo_ret_for_analyze_grid(
|
||||
self.pair_theo_ret
|
||||
)
|
||||
self.total_pnl_histogram.object = spbt_day.create_total_pnl_histogram(
|
||||
self.pair_theo_ret
|
||||
)
|
||||
self.clear_selected_pair_analysis()
|
||||
|
||||
self.set_status(
|
||||
f"Calculated {len(self.pair_theo_ret):,} pair row(s) from {db_path.name}."
|
||||
)
|
||||
except Exception as exc:
|
||||
self.set_status(str(exc), error=True)
|
||||
finally:
|
||||
self.calculate_button.loading = False
|
||||
|
||||
def clear_selected_pair_analysis(self) -> None:
|
||||
"""Clear individual-pair outputs until a row Analyze button is clicked."""
|
||||
self.selected_pair_name = None
|
||||
self.selected_pair_theo_executions = pd.DataFrame()
|
||||
self.selected_pair_message.object = (
|
||||
"Click Analyze in the Pair TheoRet grid to load individual-pair details."
|
||||
)
|
||||
self.selected_pair_executions_table.value = pd.DataFrame(
|
||||
columns=spbt_day.SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS
|
||||
)
|
||||
self.selected_pair_market_plot.object = None
|
||||
|
||||
def analyze_pair_click(self, event: Any) -> None:
|
||||
"""Run selected-pair analysis from a Pair TheoRet Analyze button click."""
|
||||
self.update_selected_pair(
|
||||
spbt_day.pair_name_from_analyze_event(self.pair_theo_ret_table, event)
|
||||
)
|
||||
|
||||
def analyze_pair_row(self, row: int) -> None:
|
||||
"""Run selected-pair analysis for a Pair TheoRet table row."""
|
||||
event = type("AnalyzeEvent", (), {"row": row})()
|
||||
self.analyze_pair_click(event)
|
||||
|
||||
def update_selected_pair(self, pair_name: str) -> None:
|
||||
"""Calculate selected-pair executions and market plot."""
|
||||
if self.trading_instructions.empty:
|
||||
self.clear_selected_pair_analysis()
|
||||
return
|
||||
|
||||
self.selected_pair_name = pair_name
|
||||
self.selected_pair_message.object = (
|
||||
f"Selected pair: **{spbt_day.format_pair_name_for_display(pair_name)}**"
|
||||
)
|
||||
self.selected_pair_theo_executions = spbt_day.calculate_pair_theo_executions(
|
||||
pair_name,
|
||||
self.trading_instructions,
|
||||
min_pctg_change=self.min_pctg_change,
|
||||
)
|
||||
self.selected_pair_executions_table.value = (
|
||||
self.selected_pair_theo_executions.reindex(
|
||||
columns=spbt_day.SELECTED_PAIR_EXECUTION_DISPLAY_COLUMNS
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
trading_day_start_ns = spbt_day.infer_trading_day_start_ns(
|
||||
self.trading_instructions
|
||||
)
|
||||
conn = spbt_day.connect_sqlite_read_only(self.selected_database_path())
|
||||
try:
|
||||
selected_pair_market_data = spbt_day.load_pair_market_data(
|
||||
conn,
|
||||
pair_name,
|
||||
trading_day_start_ns=trading_day_start_ns,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
self.selected_pair_market_plot.object = spbt_day.create_pair_trades_market_plot(
|
||||
pair_name,
|
||||
selected_pair_market_data,
|
||||
self.selected_pair_theo_executions,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.selected_pair_market_plot.object = None
|
||||
self.set_status(str(exc), error=True)
|
||||
|
||||
@property
|
||||
def view(self) -> pn.template.FastListTemplate:
|
||||
"""Return the app layout."""
|
||||
controls = pn.Column(
|
||||
"## Inputs",
|
||||
self.directory_input,
|
||||
self.show_all_files,
|
||||
self.file_select,
|
||||
self.min_pctg_change_input,
|
||||
self.calculate_button,
|
||||
self.status,
|
||||
width=APP_SIDEBAR_CONTROL_WIDTH,
|
||||
)
|
||||
main = pn.Column(
|
||||
"## Pair TheoRet",
|
||||
self.pair_theo_ret_table,
|
||||
self.total_pnl_histogram,
|
||||
"## Individual Pair",
|
||||
self.selected_pair_message,
|
||||
"### Theoretical Executions",
|
||||
self.selected_pair_executions_table,
|
||||
"### Trades on Market Data",
|
||||
self.selected_pair_market_plot,
|
||||
)
|
||||
return pn.template.FastListTemplate(
|
||||
title=APP_TITLE,
|
||||
sidebar=[controls],
|
||||
main=[main],
|
||||
sidebar_width=APP_SIDEBAR_WIDTH,
|
||||
accent_base_color=APP_ACCENT_COLOR,
|
||||
header_background=APP_HEADER_COLOR,
|
||||
main_layout=None,
|
||||
theme=pn.template.DarkTheme,
|
||||
)
|
||||
|
||||
|
||||
app_controller = SpbtDayPanelApp()
|
||||
app = app_controller.view
|
||||
app.servable(title=APP_TITLE)
|
||||
@@ -0,0 +1,66 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pairs-trading"
|
||||
version = "0.1.0"
|
||||
description = "Pairs Trading Backtesting Framework"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.flake8]
|
||||
max-line-length = 88
|
||||
extend-ignore = ["E203", "W503"]
|
||||
exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
".venv",
|
||||
".mypy_cache",
|
||||
".tox"
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"numpy.*",
|
||||
"pandas.*",
|
||||
"matplotlib.*",
|
||||
"seaborn.*",
|
||||
"scipy.*",
|
||||
"sklearn.*"
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"include": [
|
||||
"lib"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"**/__pycache__",
|
||||
"**/.*",
|
||||
"results",
|
||||
"data"
|
||||
],
|
||||
"ignore": [],
|
||||
"defineConstant": {},
|
||||
"typeCheckingMode": "basic",
|
||||
"useLibraryCodeForTypes": true,
|
||||
"autoImportCompletions": true,
|
||||
"autoSearchPaths": true,
|
||||
"extraPaths": [
|
||||
"lib"
|
||||
],
|
||||
"stubPath": "./typings",
|
||||
"venvPath": ".",
|
||||
"venv": "python3.12-venv"
|
||||
}
|
||||
+199
-14
@@ -1,14 +1,199 @@
|
||||
# Interactive analysis
|
||||
ipykernel>=6.29,<7
|
||||
ipywidgets>=8.1,<9
|
||||
itables>=2.2,<3
|
||||
jupyter>=1.1,<2
|
||||
jupyter_bokeh>=4.0,<5
|
||||
nbformat>=5.10,<6
|
||||
pandas>=2.2,<3
|
||||
panel>=1.5,<2
|
||||
plotly>=5.24,<7
|
||||
|
||||
# Verification
|
||||
nbmake>=1.5,<2
|
||||
pytest>=8,<9
|
||||
aiohttp>=3.8.4
|
||||
aiosignal>=1.3.1
|
||||
async-timeout>=4.0.2
|
||||
attrs>=21.2.0
|
||||
beautifulsoup4>=4.10.0
|
||||
black>=23.3.0
|
||||
flake8>=6.0.0
|
||||
certifi>=2020.6.20
|
||||
chardet>=4.0.0
|
||||
charset-normalizer>=3.1.0
|
||||
click>=8.0.3
|
||||
colorama>=0.4.4
|
||||
configobj>=5.0.6
|
||||
cryptography>=3.4.8
|
||||
distro>=1.7.0
|
||||
docker>=5.0.3
|
||||
dockerpty>=0.4.1
|
||||
docopt>=0.6.2
|
||||
eyeD3>=0.8.10
|
||||
filelock>=3.6.0
|
||||
frozenlist>=1.3.3
|
||||
grpcio>=1.30.2
|
||||
hjson>=3.0.2
|
||||
html5lib>=1.1
|
||||
httplib2>=0.20.2
|
||||
idna>=3.3
|
||||
ipython>=8.18.1
|
||||
ipywidgets>=8.1.1
|
||||
ifaddr>=0.1.7
|
||||
IMDbPY>=2021.4.18
|
||||
ipykernel>=6.29.5
|
||||
jeepney>=0.7.1
|
||||
jsonschema>=3.2.0
|
||||
jupyter>=1.0.0
|
||||
keyring>=23.5.0
|
||||
launchpadlib>=1.10.16
|
||||
lazr.restfulclient>=0.14.4
|
||||
lazr.uri>=1.0.6
|
||||
lxml>=4.8.0
|
||||
Mako>=1.1.3
|
||||
Markdown>=3.3.6
|
||||
MarkupSafe>=2.0.1
|
||||
matplotlib>=3.10.3
|
||||
more-itertools>=8.10.0
|
||||
multidict>=6.0.4
|
||||
mypy>=0.942
|
||||
mypy-extensions>=0.4.3
|
||||
nbformat>=5.10.2
|
||||
netaddr>=0.8.0
|
||||
######### netifaces>=0.11.0
|
||||
numpy>=1.26.4,<2.3.0
|
||||
oauthlib>=3.2.0
|
||||
packaging>=23.1
|
||||
pandas>=2.2.3
|
||||
pathspec>=0.11.1
|
||||
pexpect>=4.8.0
|
||||
Pillow>=9.0.1
|
||||
platformdirs>=3.2.0
|
||||
plotly>=5.19.0
|
||||
protobuf>=3.12.4
|
||||
psutil>=5.9.0
|
||||
ptyprocess>=0.7.0
|
||||
pycurl>=7.44.1
|
||||
# pyelftools>=0.27
|
||||
Pygments>=2.11.2
|
||||
pyparsing>=2.4.7
|
||||
pyrsistent>=0.18.1
|
||||
python-debian>=0.1.43 #+ubuntu1.1
|
||||
python-dotenv>=0.19.2
|
||||
python-magic>=0.4.24
|
||||
python-xlib>=0.29
|
||||
# pyxdg>=0.27
|
||||
PyYAML>=6.0
|
||||
reportlab>=3.6.8
|
||||
requests>=2.25.1
|
||||
requests-file>=1.5.1
|
||||
scipy<1.13.0
|
||||
seaborn>=0.13.2
|
||||
SecretStorage>=3.3.1
|
||||
setproctitle>=1.2.2
|
||||
six>=1.16.0
|
||||
soupsieve>=2.3.1
|
||||
ssh-import-id>=5.11
|
||||
statsmodels>=0.14.4
|
||||
# texttable>=1.6.4
|
||||
tldextract>=3.1.2
|
||||
tomli>=1.2.2
|
||||
######## typed-ast>=1.4.3
|
||||
# types-aiofiles>=0.1
|
||||
# types-annoy>=1.17
|
||||
# types-appdirs>=1.4
|
||||
# types-atomicwrites>=1.4
|
||||
# types-aws-xray-sdk>=2.8
|
||||
# types-babel>=2.9
|
||||
# types-backports-abc>=0.5
|
||||
# types-backports.ssl-match-hostname>=3.7
|
||||
# types-beautifulsoup4>=4.10
|
||||
# types-bleach>=4.1
|
||||
# types-boto>=2.49
|
||||
# types-braintree>=4.11
|
||||
# types-cachetools>=4.2
|
||||
# types-caldav>=0.8
|
||||
# types-certifi>=2020.4
|
||||
# types-characteristic>=14.3
|
||||
# types-chardet>=4.0
|
||||
# types-click>=7.1
|
||||
# types-click-spinner>=0.1
|
||||
# types-colorama>=0.4
|
||||
# types-commonmark>=0.9
|
||||
# types-contextvars>=0.1
|
||||
# types-croniter>=1.0
|
||||
# types-cryptography>=3.3
|
||||
# types-dataclasses>=0.1
|
||||
# types-dateparser>=1.0
|
||||
# types-DateTimeRange>=0.1
|
||||
# types-decorator>=0.1
|
||||
# types-Deprecated>=1.2
|
||||
# types-docopt>=0.6
|
||||
# types-docutils>=0.17
|
||||
# types-editdistance>=0.5
|
||||
# types-emoji>=1.2
|
||||
# types-entrypoints>=0.3
|
||||
# types-enum34>=1.1
|
||||
# types-filelock>=3.2
|
||||
# types-first>=2.0
|
||||
# types-Flask>=1.1
|
||||
# types-freezegun>=1.1
|
||||
# types-frozendict>=0.1
|
||||
# types-futures>=3.3
|
||||
# types-html5lib>=1.1
|
||||
# types-httplib2>=0.19
|
||||
# types-humanfriendly>=9.2
|
||||
# types-ipaddress>=1.0
|
||||
# types-itsdangerous>=1.1
|
||||
# types-JACK-Client>=0.1
|
||||
# types-Jinja2>=2.11
|
||||
# types-jmespath>=0.10
|
||||
# types-jsonschema>=3.2
|
||||
# types-Markdown>=3.3
|
||||
# types-MarkupSafe>=1.1
|
||||
# types-mock>=4.0
|
||||
# types-mypy-extensions>=0.4
|
||||
# types-mysqlclient>=2.0
|
||||
# types-oauthlib>=3.1
|
||||
# types-orjson>=3.6
|
||||
# types-paramiko>=2.7
|
||||
# types-Pillow>=8.3
|
||||
# types-polib>=1.1
|
||||
# types-prettytable>=2.1
|
||||
# types-protobuf>=3.17
|
||||
# types-psutil>=5.8
|
||||
# types-psycopg2>=2.9
|
||||
# types-pyaudio>=0.2
|
||||
# types-pycurl>=0.1
|
||||
# types-pyfarmhash>=0.2
|
||||
# types-Pygments>=2.9
|
||||
# types-PyMySQL>=1.0
|
||||
# types-pyOpenSSL>=20.0
|
||||
# types-pyRFC3339>=0.1
|
||||
# types-pysftp>=0.2
|
||||
# types-pytest-lazy-fixture>=0.6
|
||||
# types-python-dateutil>=2.8
|
||||
# types-python-gflags>=3.1
|
||||
# types-python-nmap>=0.6
|
||||
# types-python-slugify>=5.0
|
||||
# types-pytz>=2021.1
|
||||
# types-pyvmomi>=7.0
|
||||
# types-PyYAML>=5.4
|
||||
# types-redis>=3.5
|
||||
# types-requests>=2.25
|
||||
# types-retry>=0.9
|
||||
# types-selenium>=3.141
|
||||
# types-Send2Trash>=1.8
|
||||
# types-setuptools>=57.4
|
||||
# types-simplejson>=3.17
|
||||
# types-singledispatch>=3.7
|
||||
# types-six>=1.16
|
||||
# types-slumber>=0.7
|
||||
# types-stripe>=2.59
|
||||
# types-tabulate>=0.8
|
||||
# types-termcolor>=1.1
|
||||
# types-toml>=0.10
|
||||
# types-toposort>=1.6
|
||||
# types-ttkthemes>=3.2
|
||||
# types-typed-ast>=1.4
|
||||
# types-tzlocal>=0.1
|
||||
# types-ujson>=0.1
|
||||
# types-vobject>=0.9
|
||||
# types-waitress>=0.1
|
||||
#types-Werkzeug>=1.0
|
||||
#types-xxhash>=2.0
|
||||
typing-extensions>=3.10.0.2
|
||||
Unidecode>=1.3.3
|
||||
urllib3>=1.26.5
|
||||
wadllib>=1.3.6
|
||||
webencodings>=0.5.1
|
||||
websocket-client>=1.2.3
|
||||
yarl>=1.9.1
|
||||
zipp>=1.0.0
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import argparse
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from tools.config import expand_filename, load_config
|
||||
from tools.data_loader import get_available_instruments_from_db
|
||||
|
||||
from pt_trading.results import (
|
||||
BacktestResult,
|
||||
create_result_database,
|
||||
store_config_in_database,
|
||||
store_results_in_database,
|
||||
)
|
||||
|
||||
from pt_trading.fit_method import PairsTradingFitMethod
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
from research.research_tools import create_pairs, resolve_datafiles
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run pairs trading backtest.")
|
||||
parser.add_argument(
|
||||
"--config", type=str, required=True, help="Path to the configuration file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--datafile",
|
||||
type=str,
|
||||
required=False,
|
||||
help="Market data file to process.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instruments",
|
||||
type=str,
|
||||
required=False,
|
||||
help = "Comma-separated list of instrument symbols (e.g., COIN,GBTC). If not provided, auto-detects from database.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config: Dict = load_config(args.config)
|
||||
|
||||
# Resolve data files (CLI takes priority over config)
|
||||
datafile = resolve_datafiles(config, args.datafile)[0]
|
||||
|
||||
if not datafile:
|
||||
print("No data files found to process.")
|
||||
return
|
||||
|
||||
print(f"Found {datafile} data files to process:")
|
||||
|
||||
# # Create result database if needed
|
||||
# if args.result_db.upper() != "NONE":
|
||||
# args.result_db = expand_filename(args.result_db)
|
||||
# create_result_database(args.result_db)
|
||||
|
||||
# # Initialize a dictionary to store all trade results
|
||||
# all_results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# # Store configuration in database for reference
|
||||
# if args.result_db.upper() != "NONE":
|
||||
# # Get list of all instruments for storage
|
||||
# all_instruments = []
|
||||
# for datafile in datafiles:
|
||||
# if args.instruments:
|
||||
# file_instruments = [
|
||||
# inst.strip() for inst in args.instruments.split(",")
|
||||
# ]
|
||||
# else:
|
||||
# file_instruments = get_available_instruments_from_db(datafile, config)
|
||||
# all_instruments.extend(file_instruments)
|
||||
|
||||
# # Remove duplicates while preserving order
|
||||
# unique_instruments = list(dict.fromkeys(all_instruments))
|
||||
|
||||
# store_config_in_database(
|
||||
# db_path=args.result_db,
|
||||
# config_file_path=args.config,
|
||||
# config=config,
|
||||
# fit_method_class=fit_method_class_name,
|
||||
# datafiles=datafiles,
|
||||
# instruments=unique_instruments,
|
||||
# )
|
||||
|
||||
# Process each data file
|
||||
stat_model_price = config["stat_model_price"]
|
||||
|
||||
print(f"\n====== Processing {os.path.basename(datafile)} ======")
|
||||
|
||||
# Determine instruments to use
|
||||
if args.instruments:
|
||||
# Use CLI-specified instruments
|
||||
instruments = [inst.strip() for inst in args.instruments.split(",")]
|
||||
print(f"Using CLI-specified instruments: {instruments}")
|
||||
else:
|
||||
# Auto-detect instruments from database
|
||||
instruments = get_available_instruments_from_db(datafile, config)
|
||||
print(f"Auto-detected instruments: {instruments}")
|
||||
|
||||
if not instruments:
|
||||
print(f"No instruments found in {datafile}...")
|
||||
return
|
||||
# Process data for this file
|
||||
try:
|
||||
cointegration_data: pd.DataFrame = pd.DataFrame()
|
||||
for pair in create_pairs(datafile, stat_model_price, config, instruments):
|
||||
cointegration_data = pd.concat([cointegration_data, pair.cointegration_check()])
|
||||
|
||||
pd.set_option('display.width', 400)
|
||||
pd.set_option('display.max_colwidth', None)
|
||||
pd.set_option('display.max_columns', None)
|
||||
with pd.option_context('display.max_rows', None, 'display.max_columns', None):
|
||||
print(f"cointegration_data:\n{cointegration_data}")
|
||||
|
||||
except Exception as err:
|
||||
print(f"Error processing {datafile}: {str(err)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"cells": [],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.5"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,232 @@
|
||||
import argparse
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from research.research_tools import create_pairs
|
||||
from tools.config import expand_filename, load_config
|
||||
from pt_trading.results import (
|
||||
BacktestResult,
|
||||
create_result_database,
|
||||
store_config_in_database,
|
||||
)
|
||||
from pt_trading.fit_method import PairsTradingFitMethod
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
DayT = str
|
||||
DataFileNameT = str
|
||||
|
||||
def resolve_datafiles(
|
||||
config: Dict, date_pattern: str, instruments: List[Dict[str, str]]
|
||||
) -> List[Tuple[DayT, DataFileNameT]]:
|
||||
resolved_files: List[Tuple[DayT, DataFileNameT]] = []
|
||||
for inst in instruments:
|
||||
pattern = date_pattern
|
||||
inst_type = inst["instrument_type"]
|
||||
data_dir = config["market_data_loading"][inst_type]["data_directory"]
|
||||
if "*" in pattern or "?" in pattern:
|
||||
# Handle wildcards
|
||||
if not os.path.isabs(pattern):
|
||||
pattern = os.path.join(data_dir, f"{pattern}.mktdata.ohlcv.db")
|
||||
matched_files = glob.glob(pattern)
|
||||
for matched_file in matched_files:
|
||||
import re
|
||||
match = re.search(r"(\d{8})\.mktdata\.ohlcv\.db$", matched_file)
|
||||
assert match is not None
|
||||
day = match.group(1)
|
||||
resolved_files.append((day, matched_file))
|
||||
else:
|
||||
# Handle explicit file path
|
||||
if not os.path.isabs(pattern):
|
||||
pattern = os.path.join(data_dir, f"{pattern}.mktdata.ohlcv.db")
|
||||
resolved_files.append((date_pattern, pattern))
|
||||
return sorted(list(set(resolved_files))) # Remove duplicates and sort
|
||||
|
||||
|
||||
def get_instruments(args: argparse.Namespace, config: Dict) -> List[Dict[str, str]]:
|
||||
|
||||
instruments = [
|
||||
{
|
||||
"symbol": inst.split(":")[0],
|
||||
"instrument_type": inst.split(":")[1],
|
||||
"exchange_id": inst.split(":")[2],
|
||||
"instrument_id_pfx": config["market_data_loading"][inst.split(":")[1]][
|
||||
"instrument_id_pfx"
|
||||
],
|
||||
"db_table_name": config["market_data_loading"][inst.split(":")[1]][
|
||||
"db_table_name"
|
||||
],
|
||||
}
|
||||
for inst in args.instruments.split(",")
|
||||
]
|
||||
return instruments
|
||||
|
||||
|
||||
def run_backtest(
|
||||
config: Dict,
|
||||
datafiles: List[str],
|
||||
fit_method: PairsTradingFitMethod,
|
||||
instruments: List[Dict[str, str]],
|
||||
) -> BacktestResult:
|
||||
"""
|
||||
Run backtest for all pairs using the specified instruments.
|
||||
"""
|
||||
bt_result: BacktestResult = BacktestResult(config=config)
|
||||
# if len(datafiles) < 2:
|
||||
# print(f"WARNING: insufficient data files: {datafiles}")
|
||||
# return bt_result
|
||||
|
||||
if not all([os.path.exists(datafile) for datafile in datafiles]):
|
||||
print(f"WARNING: data file {datafiles} does not exist")
|
||||
return bt_result
|
||||
|
||||
pairs_trades = []
|
||||
|
||||
pairs = create_pairs(
|
||||
datafiles=datafiles,
|
||||
fit_method=fit_method,
|
||||
config=config,
|
||||
instruments=instruments,
|
||||
)
|
||||
for pair in pairs:
|
||||
single_pair_trades = fit_method.run_pair(pair=pair, bt_result=bt_result)
|
||||
if single_pair_trades is not None and len(single_pair_trades) > 0:
|
||||
pairs_trades.append(single_pair_trades)
|
||||
print(f"pairs_trades:\n{pairs_trades}")
|
||||
# Check if result_list has any data before concatenating
|
||||
if len(pairs_trades) == 0:
|
||||
print("No trading signals found for any pairs")
|
||||
return bt_result
|
||||
|
||||
bt_result.collect_single_day_results(pairs_trades)
|
||||
return bt_result
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run pairs trading backtest.")
|
||||
parser.add_argument(
|
||||
"--config", type=str, required=True, help="Path to the configuration file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date_pattern",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Date YYYYMMDD, allows * and ? wildcards",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instruments",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Comma-separated list of instrument symbols (e.g., COIN:EQUITY,GBTC:CRYPTO)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--result_db",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to SQLite database for storing results. Use 'NONE' to disable database output.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config: Dict = load_config(args.config)
|
||||
|
||||
# Dynamically instantiate fit method class
|
||||
fit_method = PairsTradingFitMethod.create(config)
|
||||
|
||||
# Resolve data files (CLI takes priority over config)
|
||||
instruments = get_instruments(args, config)
|
||||
datafiles = resolve_datafiles(config, args.date_pattern, instruments)
|
||||
|
||||
days = list(set([day for day, _ in datafiles]))
|
||||
print(f"Found {len(datafiles)} data files to process:")
|
||||
for df in datafiles:
|
||||
print(f" - {df}")
|
||||
|
||||
# Create result database if needed
|
||||
if args.result_db.upper() != "NONE":
|
||||
args.result_db = expand_filename(args.result_db)
|
||||
create_result_database(args.result_db)
|
||||
|
||||
# Initialize a dictionary to store all trade results
|
||||
all_results: Dict[str, Dict[str, Any]] = {}
|
||||
is_config_stored = False
|
||||
# Process each data file
|
||||
|
||||
for day in sorted(days):
|
||||
md_datafiles = [datafile for md_day, datafile in datafiles if md_day == day]
|
||||
if not all([os.path.exists(datafile) for datafile in md_datafiles]):
|
||||
print(f"WARNING: insufficient data files: {md_datafiles}")
|
||||
continue
|
||||
print(f"\n====== Processing {day} ======")
|
||||
|
||||
if not is_config_stored:
|
||||
store_config_in_database(
|
||||
db_path=args.result_db,
|
||||
config_file_path=args.config,
|
||||
config=config,
|
||||
fit_method_class=config["fit_method_class"],
|
||||
datafiles=datafiles,
|
||||
instruments=instruments,
|
||||
)
|
||||
is_config_stored = True
|
||||
|
||||
# Process data for this file
|
||||
try:
|
||||
fit_method.reset()
|
||||
|
||||
bt_results = run_backtest(
|
||||
config=config,
|
||||
datafiles=md_datafiles,
|
||||
fit_method=fit_method,
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
if bt_results.trades is None or len(bt_results.trades) == 0:
|
||||
print(f"No trades found for {day}")
|
||||
continue
|
||||
|
||||
# Store results with day name as key
|
||||
filename = os.path.basename(day)
|
||||
all_results[filename] = {
|
||||
"trades": bt_results.trades.copy(),
|
||||
"outstanding_positions": bt_results.outstanding_positions.copy(),
|
||||
}
|
||||
|
||||
# Store results in database
|
||||
if args.result_db.upper() != "NONE":
|
||||
bt_results.calculate_returns(
|
||||
{
|
||||
filename: {
|
||||
"trades": bt_results.trades.copy(),
|
||||
"outstanding_positions": bt_results.outstanding_positions.copy(),
|
||||
}
|
||||
}
|
||||
)
|
||||
bt_results.store_results_in_database(db_path=args.result_db, day=day)
|
||||
|
||||
print(f"Successfully processed {filename}")
|
||||
|
||||
except Exception as err:
|
||||
print(f"Error processing {day}: {str(err)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Calculate and print results using a new BacktestResult instance for aggregation
|
||||
if all_results:
|
||||
aggregate_bt_results = BacktestResult(config=config)
|
||||
aggregate_bt_results.calculate_returns(all_results)
|
||||
aggregate_bt_results.print_grand_totals()
|
||||
aggregate_bt_results.print_outstanding_positions()
|
||||
|
||||
if args.result_db.upper() != "NONE":
|
||||
print(f"\nResults stored in database: {args.result_db}")
|
||||
else:
|
||||
print("No results to display.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
import glob
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from pt_trading.fit_method import PairsTradingFitMethod
|
||||
|
||||
def resolve_datafiles(config: Dict, cli_datafiles: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Resolve the list of data files to process.
|
||||
CLI datafiles take priority over config datafiles.
|
||||
Supports wildcards in config but not in CLI.
|
||||
"""
|
||||
if cli_datafiles:
|
||||
# CLI override - comma-separated list, no wildcards
|
||||
datafiles = [f.strip() for f in cli_datafiles.split(",")]
|
||||
# Make paths absolute relative to data directory
|
||||
data_dir = config.get("data_directory", "./data")
|
||||
resolved_files = []
|
||||
for df in datafiles:
|
||||
if not os.path.isabs(df):
|
||||
df = os.path.join(data_dir, df)
|
||||
resolved_files.append(df)
|
||||
return resolved_files
|
||||
|
||||
# Use config datafiles with wildcard support
|
||||
config_datafiles = config.get("datafiles", [])
|
||||
data_dir = config.get("data_directory", "./data")
|
||||
resolved_files = []
|
||||
|
||||
for pattern in config_datafiles:
|
||||
if "*" in pattern or "?" in pattern:
|
||||
# Handle wildcards
|
||||
if not os.path.isabs(pattern):
|
||||
pattern = os.path.join(data_dir, pattern)
|
||||
matched_files = glob.glob(pattern)
|
||||
resolved_files.extend(matched_files)
|
||||
else:
|
||||
# Handle explicit file path
|
||||
if not os.path.isabs(pattern):
|
||||
pattern = os.path.join(data_dir, pattern)
|
||||
resolved_files.append(pattern)
|
||||
|
||||
return sorted(list(set(resolved_files))) # Remove duplicates and sort
|
||||
|
||||
|
||||
def create_pairs(
|
||||
datafiles: List[str],
|
||||
fit_method: PairsTradingFitMethod,
|
||||
config: Dict,
|
||||
instruments: List[Dict[str, str]],
|
||||
) -> List:
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
from tools.data_loader import load_market_data
|
||||
|
||||
all_indexes = range(len(instruments))
|
||||
unique_index_pairs = [(i, j) for i in all_indexes for j in all_indexes if i < j]
|
||||
pairs = []
|
||||
|
||||
# Update config to use the specified instruments
|
||||
config_copy = config.copy()
|
||||
config_copy["instruments"] = instruments
|
||||
|
||||
market_data_df = pd.DataFrame()
|
||||
extra_minutes = 0
|
||||
if "execution_price" in config_copy:
|
||||
extra_minutes = config_copy["execution_price"]["shift"]
|
||||
|
||||
for datafile in datafiles:
|
||||
md_df = load_market_data(
|
||||
datafile = datafile,
|
||||
instruments = instruments,
|
||||
db_table_name = config_copy["market_data_loading"][instruments[0]["instrument_type"]]["db_table_name"],
|
||||
trading_hours=config_copy["trading_hours"],
|
||||
extra_minutes=extra_minutes,
|
||||
)
|
||||
market_data_df = pd.concat([market_data_df, md_df])
|
||||
|
||||
if len(set(market_data_df["symbol"])) != 2: # both symbols must be present for a pair
|
||||
print(f"WARNING: insufficient data in files: {datafiles}")
|
||||
return []
|
||||
|
||||
for a_index, b_index in unique_index_pairs:
|
||||
symbol_a=instruments[a_index]["symbol"]
|
||||
symbol_b=instruments[b_index]["symbol"]
|
||||
pair = fit_method.create_trading_pair(
|
||||
config=config_copy,
|
||||
market_data=market_data_df,
|
||||
symbol_a=symbol_a,
|
||||
symbol_b=symbol_b,
|
||||
)
|
||||
pairs.append(pair)
|
||||
return pairs
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# -------------------------------------
|
||||
# --- Given month, specific dates
|
||||
# -------------------------------------
|
||||
|
||||
# for dt in 20250528 20250529 20250530 20250531; do
|
||||
# rsync -ahvv cvtt@hs01.cvtt.vpn:/works/cvtt/md_archive/crypto/sim/2025/2025-05/${dt}.*.gz ./
|
||||
# done
|
||||
# -------------------------------------
|
||||
|
||||
# -------------------------------------
|
||||
# --- Current month - all files
|
||||
# -------------------------------------
|
||||
cd $(realpath $(dirname $0))/..
|
||||
mkdir -p ./data/crypto
|
||||
pushd ./data/crypto
|
||||
|
||||
Files=$1
|
||||
if [ -z "$Files" ]; then
|
||||
Files="*.gz"
|
||||
fi
|
||||
|
||||
Cmd="rsync -ahvv cvtt@hs01.cvtt.vpn:/works/cvtt/md_archive/crypto/sim/${Files} ./"
|
||||
echo $Cmd
|
||||
eval $Cmd
|
||||
# -------------------------------------
|
||||
|
||||
for srcfname in $(ls *.db.gz); do
|
||||
dt="${srcfname:0:8}"
|
||||
tgtfile=${dt}.mktdata.ohlcv.db
|
||||
echo "${srcfname} -> ${tgtfile}"
|
||||
|
||||
Cmd="gunzip -c $srcfname > temp.db"
|
||||
echo $Cmd
|
||||
eval $Cmd
|
||||
Cmd="rm -f ${tgtfile} && sqlite3 temp.db \".dump md_1min_bars\" | sqlite3 ${tgtfile} && rm ${srcfname}"
|
||||
echo $Cmd
|
||||
eval $Cmd
|
||||
done
|
||||
rm temp.db
|
||||
popd
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [DatePattern]"
|
||||
echo "DatePattern: YYYYMM or YYYYM or YYYYMMD"
|
||||
exit 1
|
||||
}
|
||||
|
||||
DatePattern="${1}"
|
||||
if [ -z "${DatePattern}" ]; then
|
||||
usage
|
||||
fi
|
||||
FilePattern="${DatePattern}*.alpaca_sim_md.db.gz"
|
||||
|
||||
cd $(realpath $(dirname $0))/..
|
||||
mkdir -p ./data/equity
|
||||
pushd ./data/equity
|
||||
|
||||
Cmd="rsync -ahvv cvtt@hs01.cvtt.vpn:/works/cvtt/md_archive/equity/alpaca_md/sim/${FilePattern} ./"
|
||||
echo ${Cmd}
|
||||
eval ${Cmd}
|
||||
# -------------------------------------
|
||||
|
||||
for srcfname in $(ls *.db.gz); do
|
||||
dt="${srcfname:0:8}"
|
||||
tgtfile=${dt}.mktdata.ohlcv.db
|
||||
echo "${srcfname} -> ${tgtfile}"
|
||||
|
||||
Cmd="gunzip -c $srcfname > temp.db && rm $srcfname"
|
||||
echo ${Cmd}
|
||||
eval ${Cmd}
|
||||
Cmd="rm -f ${tgtfile} && sqlite3 temp.db '.dump md_1min_bars' | sqlite3 ${tgtfile}"
|
||||
echo ${Cmd}
|
||||
eval ${Cmd}
|
||||
done
|
||||
rm temp.db
|
||||
popd
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
panel serve panel/spbt_day_panel.py --show "$@"
|
||||
-1369
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import hjson
|
||||
import pandas as pd
|
||||
|
||||
from tools.data_loader import get_available_instruments_from_db, load_market_data
|
||||
from pt_trading.results import (
|
||||
BacktestResult,
|
||||
create_result_database,
|
||||
store_config_in_database,
|
||||
store_results_in_database,
|
||||
)
|
||||
from pt_trading.fit_methods import PairsTradingFitMethod
|
||||
from pt_trading.trading_pair import TradingPair
|
||||
|
||||
|
||||
def run_strategy(
|
||||
config: Dict,
|
||||
datafile: str,
|
||||
fit_method: PairsTradingFitMethod,
|
||||
instruments: List[str],
|
||||
) -> BacktestResult:
|
||||
"""
|
||||
Run backtest for all pairs using the specified instruments.
|
||||
"""
|
||||
bt_result: BacktestResult = BacktestResult(config=config)
|
||||
|
||||
def _create_pairs(config: Dict, instruments: List[str]) -> List[TradingPair]:
|
||||
nonlocal datafile
|
||||
all_indexes = range(len(instruments))
|
||||
unique_index_pairs = [(i, j) for i in all_indexes for j in all_indexes if i < j]
|
||||
pairs = []
|
||||
|
||||
# Update config to use the specified instruments
|
||||
config_copy = config.copy()
|
||||
config_copy["instruments"] = instruments
|
||||
|
||||
market_data_df = load_market_data(
|
||||
datafile=datafile,
|
||||
exchange_id=config_copy["exchange_id"],
|
||||
instruments=config_copy["instruments"],
|
||||
instrument_id_pfx=config_copy["instrument_id_pfx"],
|
||||
db_table_name=config_copy["db_table_name"],
|
||||
trading_hours=config_copy["trading_hours"],
|
||||
)
|
||||
|
||||
for a_index, b_index in unique_index_pairs:
|
||||
pair = fit_method.create_trading_pair(
|
||||
market_data=market_data_df,
|
||||
symbol_a=instruments[a_index],
|
||||
symbol_b=instruments[b_index],
|
||||
)
|
||||
pairs.append(pair)
|
||||
return pairs
|
||||
|
||||
pairs_trades = []
|
||||
for pair in _create_pairs(config, instruments):
|
||||
single_pair_trades = fit_method.run_pair(
|
||||
pair=pair, config=config, bt_result=bt_result
|
||||
)
|
||||
if single_pair_trades is not None and len(single_pair_trades) > 0:
|
||||
pairs_trades.append(single_pair_trades)
|
||||
|
||||
# Check if result_list has any data before concatenating
|
||||
if len(pairs_trades) == 0:
|
||||
print("No trading signals found for any pairs")
|
||||
return bt_result
|
||||
|
||||
result = pd.concat(pairs_trades, ignore_index=True)
|
||||
result["time"] = pd.to_datetime(result["time"])
|
||||
result = result.set_index("time").sort_index()
|
||||
|
||||
bt_result.collect_single_day_results(result)
|
||||
return bt_result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run pairs trading backtest.")
|
||||
parser.add_argument(
|
||||
"--config", type=str, required=True, help="Path to the configuration file."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--datafiles",
|
||||
type=str,
|
||||
required=False,
|
||||
help="Comma-separated list of data files (overrides config). No wildcards supported.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instruments",
|
||||
type=str,
|
||||
required=False,
|
||||
help="Comma-separated list of instrument symbols (e.g., COIN,GBTC). If not provided, auto-detects from database.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--result_db",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to SQLite database for storing results. Use 'NONE' to disable database output.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config: Dict = load_config(args.config)
|
||||
|
||||
# Dynamically instantiate fit method class
|
||||
fit_method_class_name = config.get("fit_method_class", None)
|
||||
assert fit_method_class_name is not None
|
||||
module_name, class_name = fit_method_class_name.rsplit(".", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
fit_method = getattr(module, class_name)()
|
||||
|
||||
# Resolve data files (CLI takes priority over config)
|
||||
datafiles = resolve_datafiles(config, args.datafiles)
|
||||
|
||||
if not datafiles:
|
||||
print("No data files found to process.")
|
||||
return
|
||||
|
||||
print(f"Found {len(datafiles)} data files to process:")
|
||||
for df in datafiles:
|
||||
print(f" - {df}")
|
||||
|
||||
# Create result database if needed
|
||||
if args.result_db.upper() != "NONE":
|
||||
create_result_database(args.result_db)
|
||||
|
||||
# Initialize a dictionary to store all trade results
|
||||
all_results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Store configuration in database for reference
|
||||
if args.result_db.upper() != "NONE":
|
||||
# Get list of all instruments for storage
|
||||
all_instruments = []
|
||||
for datafile in datafiles:
|
||||
if args.instruments:
|
||||
file_instruments = [
|
||||
inst.strip() for inst in args.instruments.split(",")
|
||||
]
|
||||
else:
|
||||
file_instruments = get_available_instruments_from_db(datafile, config)
|
||||
all_instruments.extend(file_instruments)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_instruments = list(dict.fromkeys(all_instruments))
|
||||
|
||||
store_config_in_database(
|
||||
db_path=args.result_db,
|
||||
config_file_path=args.config,
|
||||
config=config,
|
||||
fit_method_class=fit_method_class_name,
|
||||
datafiles=datafiles,
|
||||
instruments=unique_instruments,
|
||||
)
|
||||
|
||||
# Process each data file
|
||||
|
||||
for datafile in datafiles:
|
||||
print(f"\n====== Processing {os.path.basename(datafile)} ======")
|
||||
|
||||
# Determine instruments to use
|
||||
if args.instruments:
|
||||
# Use CLI-specified instruments
|
||||
instruments = [inst.strip() for inst in args.instruments.split(",")]
|
||||
print(f"Using CLI-specified instruments: {instruments}")
|
||||
else:
|
||||
# Auto-detect instruments from database
|
||||
instruments = get_available_instruments_from_db(datafile, config)
|
||||
print(f"Auto-detected instruments: {instruments}")
|
||||
|
||||
if not instruments:
|
||||
print(f"No instruments found for {datafile}, skipping...")
|
||||
continue
|
||||
|
||||
# Process data for this file
|
||||
try:
|
||||
fit_method.reset()
|
||||
|
||||
bt_results = run_strategy(
|
||||
config=config,
|
||||
datafile=datafile,
|
||||
fit_method=fit_method,
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
# Store results with file name as key
|
||||
filename = os.path.basename(datafile)
|
||||
all_results[filename] = {"trades": bt_results.trades.copy()}
|
||||
|
||||
# Store results in database
|
||||
if args.result_db.upper() != "NONE":
|
||||
store_results_in_database(args.result_db, datafile, bt_results)
|
||||
|
||||
print(f"Successfully processed {filename}")
|
||||
|
||||
except Exception as err:
|
||||
print(f"Error processing {datafile}: {str(err)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Calculate and print results using a new BacktestResult instance for aggregation
|
||||
if all_results:
|
||||
aggregate_bt_results = BacktestResult(config=config)
|
||||
aggregate_bt_results.calculate_returns(all_results)
|
||||
aggregate_bt_results.print_grand_totals()
|
||||
aggregate_bt_results.print_outstanding_positions()
|
||||
|
||||
if args.result_db.upper() != "NONE":
|
||||
print(f"\nResults stored in database: {args.result_db}")
|
||||
else:
|
||||
print("No results to display.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,243 +0,0 @@
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load_panel_app_module():
|
||||
module_path = Path("panel/spbt_day_panel.py").resolve()
|
||||
spec = importlib.util.spec_from_file_location("spbt_day_panel_app", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def create_panel_fixture_db(db_path: Path) -> None:
|
||||
trading_day_start_ns = pd.Timestamp("2026-06-17T00:00:00Z").value
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE selector_pairs (
|
||||
time_ns INTEGER,
|
||||
tstamp TEXT,
|
||||
pair_name TEXT,
|
||||
instrument_a TEXT,
|
||||
instrument_b TEXT,
|
||||
mr_score TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE trading_instructions (
|
||||
tstamp TEXT,
|
||||
tstamp_ns INTEGER,
|
||||
type TEXT,
|
||||
book_id TEXT,
|
||||
strategy_id TEXT,
|
||||
action TEXT,
|
||||
quote_asset TEXT,
|
||||
assets TEXT,
|
||||
scaled_disequilibrium REAL,
|
||||
beta REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE market (
|
||||
tstamp TEXT,
|
||||
tstamp_ns INTEGER,
|
||||
exch_acct TEXT,
|
||||
instrument_id TEXT,
|
||||
open REAL,
|
||||
high REAL,
|
||||
low REAL,
|
||||
close REAL,
|
||||
volume REAL,
|
||||
vwap REAL,
|
||||
num_trades INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO selector_pairs VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
10,
|
||||
"2026-06-17T00:00:00Z",
|
||||
"AAA:USD-BBB:USD",
|
||||
"EXCH:PAIR-AAA-USD",
|
||||
"EXCH:PAIR-BBB-USD",
|
||||
'{"final":"0.5"}',
|
||||
),
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO trading_instructions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(
|
||||
"2026-06-17T00:00:00Z",
|
||||
trading_day_start_ns,
|
||||
"TARGET_POSITION",
|
||||
"book",
|
||||
"strategy-AAA:USD-BBB:USD",
|
||||
"TARGET",
|
||||
"USD",
|
||||
'{"AAA":{"reference_price":"100","strength":"0.5"},'
|
||||
'"BBB":{"reference_price":"50","strength":"-0.5"}}',
|
||||
-1.25,
|
||||
0.75,
|
||||
),
|
||||
(
|
||||
"2026-06-17T00:01:00Z",
|
||||
trading_day_start_ns + 60_000_000_000,
|
||||
"CLOSE_POSITION",
|
||||
"book",
|
||||
"strategy-AAA:USD-BBB:USD",
|
||||
"CLOSE",
|
||||
"USD",
|
||||
'{"AAA":{"reference_price":"110"},'
|
||||
'"BBB":{"reference_price":"45"}}',
|
||||
-0.5,
|
||||
0.75,
|
||||
),
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO market VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(
|
||||
"2026-06-17T00:00:00Z",
|
||||
trading_day_start_ns,
|
||||
"EXCH",
|
||||
"PAIR-AAA-USD",
|
||||
100.0,
|
||||
100.0,
|
||||
100.0,
|
||||
100.0,
|
||||
1.0,
|
||||
100.0,
|
||||
1,
|
||||
),
|
||||
(
|
||||
"2026-06-17T00:00:00Z",
|
||||
trading_day_start_ns,
|
||||
"EXCH",
|
||||
"PAIR-BBB-USD",
|
||||
50.0,
|
||||
50.0,
|
||||
50.0,
|
||||
50.0,
|
||||
1.0,
|
||||
50.0,
|
||||
1,
|
||||
),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_pair_analyze_grid_keeps_clean_labels_and_full_pair_values():
|
||||
module = load_panel_app_module()
|
||||
pair_theo_ret = pd.DataFrame(
|
||||
{
|
||||
"pair_name": ["BTC:USD-ETH:USD", "ADA:USD-BTC:USD"],
|
||||
"mr_ranking": [2, 1],
|
||||
"realized_pnl": [0.0, 0.0],
|
||||
"unrealized_pnl": [0.0, 0.0],
|
||||
}
|
||||
)
|
||||
|
||||
formatted = module.spbt_day.format_pair_theo_ret_for_analyze_grid(pair_theo_ret)
|
||||
|
||||
assert formatted["pair_name"].tolist() == ["BTC-ETH", "ADA-BTC"]
|
||||
assert formatted[module.spbt_day.PAIR_NAME_VALUE_COLUMN].tolist() == [
|
||||
"BTC:USD-ETH:USD",
|
||||
"ADA:USD-BTC:USD",
|
||||
]
|
||||
|
||||
|
||||
def test_panel_app_uses_fast_list_template(tmp_path):
|
||||
module = load_panel_app_module()
|
||||
app = module.SpbtDayPanelApp(repo_root=tmp_path)
|
||||
view = app.view
|
||||
|
||||
assert not hasattr(app, "refresh_button")
|
||||
assert isinstance(view, module.pn.template.FastListTemplate)
|
||||
assert view.title == module.APP_TITLE
|
||||
assert view.theme is module.pn.template.DarkTheme
|
||||
assert view.sidebar_width == module.APP_SIDEBAR_WIDTH
|
||||
assert view.accent_base_color == module.APP_ACCENT_COLOR
|
||||
assert view.header_background == module.APP_HEADER_COLOR
|
||||
assert len(view.sidebar) == 1
|
||||
assert len(view.main) == 1
|
||||
|
||||
|
||||
def test_panel_app_calculates_pairs_and_selected_pair_outputs(tmp_path):
|
||||
module = load_panel_app_module()
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
db_path = data_dir / "20260617.spbt_results.db"
|
||||
create_panel_fixture_db(db_path)
|
||||
|
||||
app = module.SpbtDayPanelApp(repo_root=tmp_path)
|
||||
app.directory_input.value = str(data_dir)
|
||||
app.refresh_files()
|
||||
app.min_pctg_change_input.value = 0.0
|
||||
|
||||
app.calculate()
|
||||
|
||||
assert app.file_select.value == str(db_path)
|
||||
assert app.directory_input.sizing_mode == "stretch_width"
|
||||
assert app.directory_input.width is None
|
||||
assert app.file_select.sizing_mode == "stretch_width"
|
||||
assert app.file_select.width is None
|
||||
assert app.min_pctg_change_input.sizing_mode == "stretch_width"
|
||||
assert app.min_pctg_change_input.width is None
|
||||
assert app.calculate_button.width == 110
|
||||
assert app.total_pnl_histogram.sizing_mode == "stretch_width"
|
||||
assert app.selected_pair_market_plot.sizing_mode == "stretch_width"
|
||||
assert app.pair_theo_ret_table.pagination is None
|
||||
assert app.pair_theo_ret_table.layout == "fit_data_table"
|
||||
assert app.pair_theo_ret_table.value["pair_name"].tolist() == ["AAA-BBB"]
|
||||
assert (
|
||||
app.pair_theo_ret_table.value[module.spbt_day.PAIR_NAME_VALUE_COLUMN].tolist()
|
||||
== ["AAA:USD-BBB:USD"]
|
||||
)
|
||||
assert app.selected_pair_name is None
|
||||
assert app.selected_pair_executions_table.value.empty
|
||||
assert app.selected_pair_market_plot.object is None
|
||||
|
||||
app.analyze_pair_row(0)
|
||||
|
||||
assert app.selected_pair_name == "AAA:USD-BBB:USD"
|
||||
assert app.selected_pair_executions_table.value["action"].tolist() == [
|
||||
"TARGET",
|
||||
"TARGET",
|
||||
"CLOSE",
|
||||
"CLOSE",
|
||||
]
|
||||
assert app.selected_pair_market_plot.object is not None
|
||||
|
||||
|
||||
def test_calculate_refreshes_file_list_before_loading(tmp_path):
|
||||
module = load_panel_app_module()
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
app = module.SpbtDayPanelApp(repo_root=tmp_path)
|
||||
app.directory_input.value = str(data_dir)
|
||||
app.refresh_files()
|
||||
assert app.file_select.value is None
|
||||
|
||||
db_path = data_dir / "20260617.spbt_results.db"
|
||||
create_panel_fixture_db(db_path)
|
||||
|
||||
app.calculate()
|
||||
|
||||
assert app.file_select.value == str(db_path)
|
||||
assert app.pair_theo_ret_table.value["pair_name"].tolist() == ["AAA-BBB"]
|
||||
Reference in New Issue
Block a user