Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e0789d614 | |||
| 4ddf4017bd | |||
| f49a10f54e | |||
| 9d553dcf1a | |||
| a3e5acd765 | |||
| 49c91e5d85 | |||
| 400bd41e56 | |||
| 1d1ebd385e | |||
| c5ed951b2a | |||
| c77377f67e | |||
| 8ccebf81f5 | |||
| dc38176529 | |||
| 3f29717b64 | |||
| ecc1c1de5d | |||
| 2a118d4600 | |||
| 98f6defe96 | |||
| 2819fd536a | |||
| 73135ee8c2 | |||
| e4a3795793 | |||
| f311315ef8 | |||
| 76f9a80ad6 | |||
| bf25eb7fb5 | |||
| f2a5d6a7ad | |||
| b9d479ae8c | |||
| e6ae62ebb6 | |||
| 170e48d646 | |||
| d5f00f557b | |||
| c0fabcb429 | |||
| bd6cf1d4d0 | |||
| b196863a34 | |||
| 6dd0f97d74 | |||
| 002f797751 | |||
| 4bf1d46208 | |||
| 842eb3ec62 | |||
| 69a0b19e9f | |||
| 121c85def0 | |||
| 2e32b26fad | |||
| ba2a6cd2eb | |||
| 8b115cee75 | |||
| e97f76222c | |||
| 38e1621b2f | |||
| 7d137a1a0e | |||
| 0423a7d34f | |||
| 7ab09669b4 | |||
| 73f36ddcea | |||
| 80c3e8d54b | |||
| 8e6ac39674 | |||
| 0af334bdf9 | |||
| b474752959 | |||
| 1b6b5e5735 | |||
| 1d73ce8070 | |||
| c1c72f46a6 | |||
| 566dd9bbdc | |||
| ed0c0fecb2 |
+17
-5
@@ -3,10 +3,22 @@ __pycache__/
|
|||||||
__OLD__/
|
__OLD__/
|
||||||
.specstory/
|
.specstory/
|
||||||
.history/
|
.history/
|
||||||
.cursorindexingignore
|
|
||||||
data
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.py[cod]
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Local environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Local test data and generated analysis results
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
results/*
|
||||||
|
!results/.gitkeep
|
||||||
|
|
||||||
|
data
|
||||||
|
|
||||||
cvttpy
|
cvttpy
|
||||||
# SpecStory explanation file
|
tmp/
|
||||||
.specstory/.what-is-this.md
|
|
||||||
results/
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- [ ] 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*
|
||||||
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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: ...
|
|
||||||
|
|
||||||
@@ -1,741 +0,0 @@
|
|||||||
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['open_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()
|
|
||||||
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
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
|
|
||||||
print(f"OPEN_TRADES: {row["tstamp"]} {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
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
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]: ...
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
from typing import Any, Dict, Optional, cast
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
# print(diseq_series.shape)
|
|
||||||
self.training_mu_ = float(diseq_series[0].mean())
|
|
||||||
self.training_std_ = float(diseq_series[0].std())
|
|
||||||
|
|
||||||
self.training_df_["dis-equilibrium"] = (
|
|
||||||
self.training_df_[self.colnames()] @ self.vecm_fit_.beta
|
|
||||||
)
|
|
||||||
# Normalize the dis-equilibrium
|
|
||||||
self.training_df_["scaled_dis-equilibrium"] = (
|
|
||||||
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()
|
|
||||||
|
|
||||||
assert vecm_fit is not None
|
|
||||||
|
|
||||||
# URGENT check beta and alpha
|
|
||||||
|
|
||||||
# Check if the model converged properly
|
|
||||||
if not hasattr(vecm_fit, "beta") or vecm_fit.beta is None:
|
|
||||||
print(f"{self}: VECM model failed to converge properly")
|
|
||||||
|
|
||||||
self.vecm_fit_ = vecm_fit
|
|
||||||
pass
|
|
||||||
|
|
||||||
def predict(self) -> pd.DataFrame:
|
|
||||||
self._train_pair()
|
|
||||||
|
|
||||||
assert self.testing_df_ is not None
|
|
||||||
assert self.vecm_fit_ is not None
|
|
||||||
predicted_prices = self.vecm_fit_.predict(steps=len(self.testing_df_))
|
|
||||||
|
|
||||||
# Convert prediction to a DataFrame for readability
|
|
||||||
predicted_df = pd.DataFrame(
|
|
||||||
predicted_prices, columns=pd.Index(self.colnames()), dtype=float
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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"]
|
|
||||||
)
|
|
||||||
|
|
||||||
predicted_df = predicted_df.reset_index(drop=True)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
# Reset index to ensure proper indexing
|
|
||||||
self.pair_predict_result_ = self.pair_predict_result_.reset_index(drop=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,
|
|
||||||
)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
from typing import Any, Dict, Optional, cast
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
from pt_trading.results import BacktestResult
|
|
||||||
from pt_trading.rolling_window_fit import RollingFit
|
|
||||||
from pt_trading.trading_pair import TradingPair
|
|
||||||
import statsmodels.api as sm
|
|
||||||
|
|
||||||
NanoPerMin = 1e9
|
|
||||||
|
|
||||||
|
|
||||||
class ZScoreTradingPair(TradingPair):
|
|
||||||
zscore_model_: Optional[sm.regression.linear_model.RegressionResultsWrapper]
|
|
||||||
pair_predict_result_: Optional[pd.DataFrame]
|
|
||||||
zscore_df_: 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.zscore_model_ = None
|
|
||||||
self.pair_predict_result_ = None
|
|
||||||
self.zscore_df_ = None
|
|
||||||
|
|
||||||
def _fit_zscore(self) -> None:
|
|
||||||
assert self.training_df_ is not None
|
|
||||||
symbol_a_px_series = self.training_df_[self.colnames()].iloc[:, 0]
|
|
||||||
symbol_b_px_series = self.training_df_[self.colnames()].iloc[:, 1]
|
|
||||||
|
|
||||||
symbol_a_px_series, symbol_b_px_series = symbol_a_px_series.align(
|
|
||||||
symbol_b_px_series, axis=0
|
|
||||||
)
|
|
||||||
|
|
||||||
X = sm.add_constant(symbol_b_px_series)
|
|
||||||
self.zscore_model_ = sm.OLS(symbol_a_px_series, X).fit()
|
|
||||||
assert self.zscore_model_ is not None
|
|
||||||
hedge_ratio = self.zscore_model_.params.iloc[1]
|
|
||||||
|
|
||||||
# Calculate spread and Z-score
|
|
||||||
spread = symbol_a_px_series - hedge_ratio * symbol_b_px_series
|
|
||||||
self.zscore_df_ = (spread - spread.mean()) / spread.std()
|
|
||||||
|
|
||||||
def predict(self) -> pd.DataFrame:
|
|
||||||
self._fit_zscore()
|
|
||||||
assert self.zscore_df_ is not None
|
|
||||||
self.training_df_["dis-equilibrium"] = self.zscore_df_
|
|
||||||
self.training_df_["scaled_dis-equilibrium"] = abs(self.zscore_df_)
|
|
||||||
|
|
||||||
assert self.testing_df_ is not None
|
|
||||||
assert self.zscore_df_ is not None
|
|
||||||
predicted_df = self.testing_df_
|
|
||||||
|
|
||||||
predicted_df["disequilibrium"] = self.zscore_df_
|
|
||||||
predicted_df["signed_scaled_disequilibrium"] = self.zscore_df_
|
|
||||||
predicted_df["scaled_disequilibrium"] = abs(self.zscore_df_)
|
|
||||||
|
|
||||||
predicted_df = predicted_df.reset_index(drop=True)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
# Reset index to ensure proper indexing
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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"))
|
|
||||||
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,303 @@
|
|||||||
|
"""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)
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
+14
-199
@@ -1,199 +1,14 @@
|
|||||||
aiohttp>=3.8.4
|
# Interactive analysis
|
||||||
aiosignal>=1.3.1
|
ipykernel>=6.29,<7
|
||||||
async-timeout>=4.0.2
|
ipywidgets>=8.1,<9
|
||||||
attrs>=21.2.0
|
itables>=2.2,<3
|
||||||
beautifulsoup4>=4.10.0
|
jupyter>=1.1,<2
|
||||||
black>=23.3.0
|
jupyter_bokeh>=4.0,<5
|
||||||
flake8>=6.0.0
|
nbformat>=5.10,<6
|
||||||
certifi>=2020.6.20
|
pandas>=2.2,<3
|
||||||
chardet>=4.0.0
|
panel>=1.5,<2
|
||||||
charset-normalizer>=3.1.0
|
plotly>=5.24,<7
|
||||||
click>=8.0.3
|
|
||||||
colorama>=0.4.4
|
# Verification
|
||||||
configobj>=5.0.6
|
nbmake>=1.5,<2
|
||||||
cryptography>=3.4.8
|
pytest>=8,<9
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
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()
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,232 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/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
|
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/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
@@ -1,221 +0,0 @@
|
|||||||
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
@@ -0,0 +1,243 @@
|
|||||||
|
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