quantex 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantex-0.1.0/PKG-INFO +109 -0
- quantex-0.1.0/README.md +90 -0
- quantex-0.1.0/pyproject.toml +41 -0
- quantex-0.1.0/src/quantex/__init__.py +18 -0
- quantex-0.1.0/src/quantex/backtest.py +328 -0
- quantex-0.1.0/src/quantex/engine.py +171 -0
- quantex-0.1.0/src/quantex/execution.py +235 -0
- quantex-0.1.0/src/quantex/indicators/__init__.py +149 -0
- quantex-0.1.0/src/quantex/models.py +344 -0
- quantex-0.1.0/src/quantex/sources.py +252 -0
- quantex-0.1.0/src/quantex/strategy.py +427 -0
quantex-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: quantex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple quant strategy creation and backtesting package.
|
|
5
|
+
Author: Daniel Green
|
|
6
|
+
Author-email: dangreen07@outlook.com
|
|
7
|
+
Requires-Python: >=3.13,<4
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Requires-Dist: fastparquet (>=2024.11.0,<2025.0.0)
|
|
11
|
+
Requires-Dist: matplotlib (>=3.10.3,<4.0.0)
|
|
12
|
+
Requires-Dist: mkdocs-material (>=9.6.15,<10.0.0)
|
|
13
|
+
Requires-Dist: mkdocs-mermaid2-plugin (>=1.2.1,<2.0.0)
|
|
14
|
+
Requires-Dist: mkdocs-to-pdf (>=0.10.1,<0.11.0)
|
|
15
|
+
Requires-Dist: pandas (>=2.3.0,<3.0.0)
|
|
16
|
+
Requires-Dist: pytest-xdist (>=3.8.0,<4.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Quantex
|
|
20
|
+
|
|
21
|
+
A simple quant strategy creation and back-testing package written in Python.
|
|
22
|
+
Quantex aims to provide a lightweight foundation for building trading
|
|
23
|
+
strategies, ingesting historical market data, and evaluating performance –
|
|
24
|
+
all without the heavy overhead of larger, more opinionated quant libraries.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Table of Contents
|
|
29
|
+
1. [Features](#features)
|
|
30
|
+
2. [Project Layout](#project-layout)
|
|
31
|
+
3. [Installation](#installation)
|
|
32
|
+
4. [Running Tests](#running-tests)
|
|
33
|
+
5. [Development](#development)
|
|
34
|
+
6. [Contributing](#contributing)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
* **Data Abstraction** – A generic `DataSource` interface that you can
|
|
40
|
+
subclass to plug in CSVs, Parquet files, live feeds, databases, etc.
|
|
41
|
+
* **Back-testing Support** – A `BacktestingDataSource` base class to drive
|
|
42
|
+
offline simulations.
|
|
43
|
+
* **Strategy Skeleton** – Extendable `Strategy` base class for plug-and-play
|
|
44
|
+
trading logic.
|
|
45
|
+
* **Core Data Models** – Immutable `Bar`, `Tick`, `Order`, `Fill`, plus
|
|
46
|
+
stateful `Position` / `Portfolio` helpers for P&L accounting.
|
|
47
|
+
* **Black + Ruff Pre-commit** – `black` auto-formats and `ruff` lints every
|
|
48
|
+
commit via *pre-commit* hooks, keeping the codebase consistent.
|
|
49
|
+
* **Python 3.13+** – Embraces the latest language features.
|
|
50
|
+
* **Poetry-managed** – Modern dependency management, packaging, and virtual
|
|
51
|
+
environment handling.
|
|
52
|
+
|
|
53
|
+
> **Note:** The public API is still under heavy development and may change
|
|
54
|
+
> until v1.0. Feedback is welcome!
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
Quantex is managed with [Poetry](https://python-poetry.org/). Clone the
|
|
60
|
+
repository and install the dependencies in an isolated virtual environment:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Clone the repo
|
|
64
|
+
$ git clone https://github.com/dangreen07/quantex.git
|
|
65
|
+
$ cd quantex
|
|
66
|
+
|
|
67
|
+
# Install dependencies
|
|
68
|
+
$ poetry install
|
|
69
|
+
$ poetry build
|
|
70
|
+
$ pip install dist/quantex-0.1.0-py3-none-any.whl
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This command will:
|
|
74
|
+
1. Create / activate a local virtual-env (unless Poetry is configured to use a
|
|
75
|
+
global env).
|
|
76
|
+
2. Install package dependencies from `pyproject.toml`.
|
|
77
|
+
3. Install Quantex itself in *editable* mode, so changes you make in `src/` are
|
|
78
|
+
reflected immediately.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Running Tests
|
|
83
|
+
Quantex uses [pytest](https://docs.pytest.org/) for its test suite. After
|
|
84
|
+
installing the dev dependencies (`poetry install` above), simply run:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
poetry run pytest
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You should see tests collect and run successfully. Ensure your editor uses the
|
|
91
|
+
Poetry virtual-env so import paths resolve correctly.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
1. Create a new branch: `git checkout -b feature/<name>`
|
|
97
|
+
2. Write your code & tests.
|
|
98
|
+
3. Install the git hooks once per clone: `poetry run pre-commit install`.
|
|
99
|
+
Hooks will run `black --check` and `ruff` automatically on every commit.
|
|
100
|
+
4. Ensure `poetry run pytest` passes and the pre-commit hooks are clean.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Contributing
|
|
105
|
+
Contributions, bug reports, and feature requests are welcome! Please open an
|
|
106
|
+
issue to discuss what you'd like to work on or submit a pull request directly.
|
|
107
|
+
We follow the "fork → feature branch → pull request" workflow. By
|
|
108
|
+
contributing you agree to license your work under the same terms as Quantex.
|
|
109
|
+
|
quantex-0.1.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Quantex
|
|
2
|
+
|
|
3
|
+
A simple quant strategy creation and back-testing package written in Python.
|
|
4
|
+
Quantex aims to provide a lightweight foundation for building trading
|
|
5
|
+
strategies, ingesting historical market data, and evaluating performance –
|
|
6
|
+
all without the heavy overhead of larger, more opinionated quant libraries.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
1. [Features](#features)
|
|
12
|
+
2. [Project Layout](#project-layout)
|
|
13
|
+
3. [Installation](#installation)
|
|
14
|
+
4. [Running Tests](#running-tests)
|
|
15
|
+
5. [Development](#development)
|
|
16
|
+
6. [Contributing](#contributing)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
* **Data Abstraction** – A generic `DataSource` interface that you can
|
|
22
|
+
subclass to plug in CSVs, Parquet files, live feeds, databases, etc.
|
|
23
|
+
* **Back-testing Support** – A `BacktestingDataSource` base class to drive
|
|
24
|
+
offline simulations.
|
|
25
|
+
* **Strategy Skeleton** – Extendable `Strategy` base class for plug-and-play
|
|
26
|
+
trading logic.
|
|
27
|
+
* **Core Data Models** – Immutable `Bar`, `Tick`, `Order`, `Fill`, plus
|
|
28
|
+
stateful `Position` / `Portfolio` helpers for P&L accounting.
|
|
29
|
+
* **Black + Ruff Pre-commit** – `black` auto-formats and `ruff` lints every
|
|
30
|
+
commit via *pre-commit* hooks, keeping the codebase consistent.
|
|
31
|
+
* **Python 3.13+** – Embraces the latest language features.
|
|
32
|
+
* **Poetry-managed** – Modern dependency management, packaging, and virtual
|
|
33
|
+
environment handling.
|
|
34
|
+
|
|
35
|
+
> **Note:** The public API is still under heavy development and may change
|
|
36
|
+
> until v1.0. Feedback is welcome!
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
Quantex is managed with [Poetry](https://python-poetry.org/). Clone the
|
|
42
|
+
repository and install the dependencies in an isolated virtual environment:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Clone the repo
|
|
46
|
+
$ git clone https://github.com/dangreen07/quantex.git
|
|
47
|
+
$ cd quantex
|
|
48
|
+
|
|
49
|
+
# Install dependencies
|
|
50
|
+
$ poetry install
|
|
51
|
+
$ poetry build
|
|
52
|
+
$ pip install dist/quantex-0.1.0-py3-none-any.whl
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This command will:
|
|
56
|
+
1. Create / activate a local virtual-env (unless Poetry is configured to use a
|
|
57
|
+
global env).
|
|
58
|
+
2. Install package dependencies from `pyproject.toml`.
|
|
59
|
+
3. Install Quantex itself in *editable* mode, so changes you make in `src/` are
|
|
60
|
+
reflected immediately.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Running Tests
|
|
65
|
+
Quantex uses [pytest](https://docs.pytest.org/) for its test suite. After
|
|
66
|
+
installing the dev dependencies (`poetry install` above), simply run:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
poetry run pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
You should see tests collect and run successfully. Ensure your editor uses the
|
|
73
|
+
Poetry virtual-env so import paths resolve correctly.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
1. Create a new branch: `git checkout -b feature/<name>`
|
|
79
|
+
2. Write your code & tests.
|
|
80
|
+
3. Install the git hooks once per clone: `poetry run pre-commit install`.
|
|
81
|
+
Hooks will run `black --check` and `ruff` automatically on every commit.
|
|
82
|
+
4. Ensure `poetry run pytest` passes and the pre-commit hooks are clean.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Contributing
|
|
87
|
+
Contributions, bug reports, and feature requests are welcome! Please open an
|
|
88
|
+
issue to discuss what you'd like to work on or submit a pull request directly.
|
|
89
|
+
We follow the "fork → feature branch → pull request" workflow. By
|
|
90
|
+
contributing you agree to license your work under the same terms as Quantex.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "quantex"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple quant strategy creation and backtesting package."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Daniel Green",email = "dangreen07@outlook.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13,<4"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pandas (>=2.3.0,<3.0.0)",
|
|
12
|
+
"fastparquet (>=2024.11.0,<2025.0.0)",
|
|
13
|
+
"pytest-xdist (>=3.8.0,<4.0.0)",
|
|
14
|
+
"matplotlib (>=3.10.3,<4.0.0)",
|
|
15
|
+
"mkdocs-to-pdf (>=0.10.1,<0.11.0)",
|
|
16
|
+
"mkdocs-material (>=9.6.15,<10.0.0)",
|
|
17
|
+
"mkdocs-mermaid2-plugin (>=1.2.1,<2.0.0)",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.poetry]
|
|
21
|
+
packages = [{include = "quantex", from = "src"}]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[tool.poetry.group.dev.dependencies]
|
|
25
|
+
pytest = "^8.4.1"
|
|
26
|
+
black = "^25.1.0"
|
|
27
|
+
pre-commit = "^4.2.0"
|
|
28
|
+
ruff = "^0.12.2"
|
|
29
|
+
mkdocs = "^1.6.1"
|
|
30
|
+
mkdocs-material = "^9.6.15"
|
|
31
|
+
mkdocstrings = "^0.29.1"
|
|
32
|
+
mkdocstrings-python = "^1.8.0"
|
|
33
|
+
pytest-benchmark = "^4.0.0"
|
|
34
|
+
pytest-cov = "^6.2.1"
|
|
35
|
+
mypy = "^1.16.1"
|
|
36
|
+
pandas-ta = "^0.3.14b0"
|
|
37
|
+
setuptools = "^80.9.0"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
41
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .sources import (
|
|
2
|
+
DataSource as DataSource,
|
|
3
|
+
BacktestingDataSource as BacktestingDataSource,
|
|
4
|
+
)
|
|
5
|
+
from .strategy import Strategy as Strategy
|
|
6
|
+
from .models import (
|
|
7
|
+
Bar as Bar,
|
|
8
|
+
Tick as Tick,
|
|
9
|
+
Order as Order,
|
|
10
|
+
Fill as Fill,
|
|
11
|
+
Position as Position,
|
|
12
|
+
Portfolio as Portfolio,
|
|
13
|
+
Trade as Trade,
|
|
14
|
+
)
|
|
15
|
+
from .engine import EventBus as EventBus
|
|
16
|
+
from .execution import ImmediateFillSimulator as ImmediateFillSimulator
|
|
17
|
+
from .execution import NextBarSimulator as NextBarSimulator
|
|
18
|
+
from .backtest import BacktestRunner as BacktestRunner, BacktestResult as BacktestResult
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Backtest orchestration utilities.
|
|
2
|
+
|
|
3
|
+
This module exposes:
|
|
4
|
+
* ``BacktestResult`` – a dataclass aggregating NAV, orders, fills and metrics.
|
|
5
|
+
* ``BacktestRunner`` – a convenience wrapper that wires together a Strategy,
|
|
6
|
+
one or more DataSource objects, an execution simulator and the internal
|
|
7
|
+
EventBus. End-users typically instantiate ``BacktestRunner`` once per
|
|
8
|
+
test and call `run()` to obtain a `BacktestResult`.
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
import math
|
|
15
|
+
from typing import Mapping, Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from quantex.engine import EventBus
|
|
21
|
+
from quantex.execution import ImmediateFillSimulator, NextBarSimulator
|
|
22
|
+
from quantex.sources import BacktestingDataSource
|
|
23
|
+
from quantex.strategy import Strategy
|
|
24
|
+
from quantex.models import Order, Fill
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Metrics(dict):
|
|
28
|
+
"""Lightweight container that formats metrics nicely when printed."""
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str: # noqa: DunderAll – explicit override
|
|
31
|
+
def _fmt(val: Any) -> str:
|
|
32
|
+
# Uniform float formatting to 4 decimals while keeping ints/others intact
|
|
33
|
+
if isinstance(val, float):
|
|
34
|
+
return f"{val:.4f}"
|
|
35
|
+
return str(val)
|
|
36
|
+
|
|
37
|
+
return "\n".join(f"{k:<20}: {_fmt(v)}" for k, v in sorted(self.items()))
|
|
38
|
+
|
|
39
|
+
__repr__ = __str__
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class BacktestResult:
|
|
44
|
+
"""Contains the results of a backtest.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
nav: A pandas Series representing the Net Asset Value (NAV) over time.
|
|
48
|
+
orders: A list of all orders generated during the backtest.
|
|
49
|
+
fills: A list of all fills executed during the backtest.
|
|
50
|
+
metrics: A dictionary of performance metrics.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
nav: pd.Series
|
|
54
|
+
orders: list[Order]
|
|
55
|
+
fills: list[Fill]
|
|
56
|
+
metrics: Metrics
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Backtest Runner
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BacktestRunner:
|
|
65
|
+
"""User-facing helper that wires Strategy, EventBus, and Simulator."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
strategy: Strategy,
|
|
70
|
+
data_sources: Mapping[str, BacktestingDataSource],
|
|
71
|
+
risk_free_rate: float = 0.0,
|
|
72
|
+
min_holding_period: pd.Timedelta | None = None,
|
|
73
|
+
simulator: ImmediateFillSimulator | NextBarSimulator | None = None,
|
|
74
|
+
):
|
|
75
|
+
"""Initializes the BacktestRunner.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
strategy: The trading strategy to be backtested.
|
|
79
|
+
data_sources: A dictionary of data sources for the backtest.
|
|
80
|
+
risk_free_rate: The risk-free rate to use for the Sharpe ratio.
|
|
81
|
+
periods_per_year: The number of periods per year to use for the Sharpe ratio.
|
|
82
|
+
min_holding_period: Optional minimum holding period for positions.
|
|
83
|
+
simulator: Execution simulator to use. Defaults to NextBarSimulator
|
|
84
|
+
for more realistic order execution timing.
|
|
85
|
+
"""
|
|
86
|
+
self.strategy = strategy
|
|
87
|
+
self.data_sources = data_sources
|
|
88
|
+
if simulator is None:
|
|
89
|
+
self.simulator = NextBarSimulator(
|
|
90
|
+
self.strategy.portfolio,
|
|
91
|
+
min_holding_period=min_holding_period,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
self.simulator = simulator
|
|
95
|
+
self.event_bus = EventBus(strategy, data_sources, self.simulator)
|
|
96
|
+
self.risk_free_rate = risk_free_rate
|
|
97
|
+
self.periods_per_year = BacktestRunner._find_periods_per_year(data_sources)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _find_periods_per_year(
|
|
101
|
+
data_sources: Mapping[str, BacktestingDataSource],
|
|
102
|
+
) -> int:
|
|
103
|
+
"""Infer *periods_per_year* for annualising metrics.
|
|
104
|
+
|
|
105
|
+
The function first tries to find the modal time-delta using timestamps
|
|
106
|
+
common to **all** data sources. If fewer than two common timestamps
|
|
107
|
+
exist (feeds with disjoint calendars or heavy gaps), it falls back to
|
|
108
|
+
computing the modal bar size **per source** and subsequently picks the
|
|
109
|
+
*smallest* step – i.e. the highest frequency – across the set. This
|
|
110
|
+
approach supports back-tests where assets are intentionally
|
|
111
|
+
mis-aligned while preserving compatibility with single-source
|
|
112
|
+
scenarios.
|
|
113
|
+
"""
|
|
114
|
+
# 1. Intersection of all indices
|
|
115
|
+
intersection: pd.Index | None = None
|
|
116
|
+
for ds in data_sources.values():
|
|
117
|
+
idx = ds.get_raw_data().index
|
|
118
|
+
intersection = (
|
|
119
|
+
idx if intersection is None else intersection.intersection(idx)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if intersection is None or len(intersection) < 2:
|
|
123
|
+
# Fallback – infer the modal bar size from individual data sources
|
|
124
|
+
# rather than their intersection. This supports scenarios where the
|
|
125
|
+
# feeds are intentionally mis-aligned (e.g. assets trading on
|
|
126
|
+
# slightly different calendars or with missing bars).
|
|
127
|
+
|
|
128
|
+
step_candidates: list[float] = []
|
|
129
|
+
for ds in data_sources.values():
|
|
130
|
+
idx_ds = pd.to_datetime(ds.get_raw_data().index).sort_values()
|
|
131
|
+
if len(idx_ds) < 2:
|
|
132
|
+
# Cannot infer a step from fewer than two timestamps
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
deltas_ds = idx_ds.to_series().diff().dropna().dt.total_seconds()
|
|
136
|
+
if not deltas_ds.empty:
|
|
137
|
+
# Use the mode (most frequent) delta for this datasource
|
|
138
|
+
step_candidates.append(float(deltas_ds.mode().iat[0]))
|
|
139
|
+
|
|
140
|
+
if not step_candidates:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Unable to infer bar frequency – insufficient timestamp data across sources"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Choose the smallest (highest-frequency) step among all candidates
|
|
146
|
+
step_sec = min(step_candidates)
|
|
147
|
+
else:
|
|
148
|
+
# 2. Consecutive deltas in seconds – use the common intersection
|
|
149
|
+
idx = pd.to_datetime(intersection).sort_values()
|
|
150
|
+
deltas_sec = idx.to_series().diff().dropna().dt.total_seconds()
|
|
151
|
+
|
|
152
|
+
# 3. Typical step = mode of the delta distribution
|
|
153
|
+
step_sec = deltas_sec.mode().iat[0]
|
|
154
|
+
|
|
155
|
+
if step_sec == 0:
|
|
156
|
+
raise ValueError("Zero-length step encountered")
|
|
157
|
+
|
|
158
|
+
# 4. Convert to periods per year
|
|
159
|
+
if step_sec == 60:
|
|
160
|
+
periods_per_year = 252 * 6.5 * 60
|
|
161
|
+
elif step_sec == 60 * 60:
|
|
162
|
+
periods_per_year = 252 * 6.5
|
|
163
|
+
elif step_sec == 60 * 60 * 24:
|
|
164
|
+
periods_per_year = 252
|
|
165
|
+
else:
|
|
166
|
+
# Generic conversion for uncommon frequencies – fall back to
|
|
167
|
+
# seconds-in-year divided by step size.
|
|
168
|
+
seconds_in_year = 365 * 24 * 60 * 60
|
|
169
|
+
periods_per_year = seconds_in_year / step_sec
|
|
170
|
+
|
|
171
|
+
return math.ceil(periods_per_year)
|
|
172
|
+
|
|
173
|
+
def run(self, *, metrics_style: str = "default") -> BacktestResult:
|
|
174
|
+
"""Runs the back-test.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
metrics_style: Controls how certain metrics are computed. Accepted
|
|
178
|
+
values:
|
|
179
|
+
|
|
180
|
+
* ``"default"`` – sample‐stdev Sharpe (ddof=1) and other
|
|
181
|
+
academic conventions (the library default).
|
|
182
|
+
* ``"bt"`` – compatibility mode mirroring the `backtesting.py`
|
|
183
|
+
package: population st-dev (ddof=0) and risk-free rate 0.0.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
A :class:`BacktestResult` instance containing NAV, orders, fills
|
|
187
|
+
and metrics.
|
|
188
|
+
"""
|
|
189
|
+
self.event_bus.run()
|
|
190
|
+
nav_series = pd.Series(
|
|
191
|
+
self.event_bus.nav, index=self.event_bus.timestamps, name="NAV"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
metrics: dict = {}
|
|
195
|
+
|
|
196
|
+
if not nav_series.empty and nav_series.iloc[0] != 0:
|
|
197
|
+
metrics["total_return"] = nav_series.iloc[-1] / nav_series.iloc[0] - 1
|
|
198
|
+
|
|
199
|
+
# Maximum drawdown ---------------------------------------------
|
|
200
|
+
metrics["max_drawdown"] = _max_drawdown(nav_series)
|
|
201
|
+
|
|
202
|
+
# Sharpe ratio -------------------------------------------------
|
|
203
|
+
if len(nav_series) > 1:
|
|
204
|
+
# Population vs sample st-dev depending on selected style
|
|
205
|
+
ddof = 0 if metrics_style == "bt" else 1
|
|
206
|
+
metrics["sharpe_ratio"] = _annualised_sharpe(
|
|
207
|
+
nav_series, self.risk_free_rate, self.periods_per_year, ddof
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# --- Additional metrics ------------------------------------
|
|
211
|
+
periods = len(nav_series)
|
|
212
|
+
if periods > 1:
|
|
213
|
+
# Annualised (geometric) return – CAGR
|
|
214
|
+
#
|
|
215
|
+
# Very short back-tests (few bars) can yield a huge exponent
|
|
216
|
+
# *(periods_per_year / periods)* which, when combined with a
|
|
217
|
+
# ratio slightly >1, overflows the double-precision range and
|
|
218
|
+
# emits a RuntimeWarning. We compute the power inside a NumPy
|
|
219
|
+
# *errstate* context that suppresses the warning while still
|
|
220
|
+
# returning ``inf`` for pathological cases.
|
|
221
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
222
|
+
cagr_val = (
|
|
223
|
+
np.power(
|
|
224
|
+
nav_series.iloc[-1] / nav_series.iloc[0],
|
|
225
|
+
self.periods_per_year / periods,
|
|
226
|
+
)
|
|
227
|
+
- 1.0
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Cast to Python float for stable downstream formatting
|
|
231
|
+
metrics["cagr"] = float(cagr_val)
|
|
232
|
+
|
|
233
|
+
returns = nav_series.pct_change().dropna()
|
|
234
|
+
# Annualised volatility – align ddof with Sharpe selection
|
|
235
|
+
metrics["volatility_annualised"] = returns.std(ddof=ddof) * np.sqrt(
|
|
236
|
+
self.periods_per_year
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Sortino ratio (downside risk only)
|
|
240
|
+
downside = returns[returns < 0]
|
|
241
|
+
if len(downside) > 0 and downside.std(ddof=0) != 0:
|
|
242
|
+
metrics["sortino_ratio"] = (
|
|
243
|
+
returns.mean() / downside.std(ddof=0)
|
|
244
|
+
) * np.sqrt(self.periods_per_year)
|
|
245
|
+
else:
|
|
246
|
+
metrics["sortino_ratio"] = float("nan")
|
|
247
|
+
|
|
248
|
+
# Calmar ratio – CAGR divided by abs(max drawdown)
|
|
249
|
+
max_dd = abs(metrics["max_drawdown"])
|
|
250
|
+
metrics["calmar_ratio"] = (
|
|
251
|
+
metrics["cagr"] / max_dd if max_dd > 0 else float("nan")
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Buy & hold return of first symbol (if available)
|
|
255
|
+
price_df = getattr(self.event_bus, "_price_df", None)
|
|
256
|
+
if price_df is not None and not price_df.empty:
|
|
257
|
+
first_symbol = price_df.columns[0]
|
|
258
|
+
metrics["buy_hold_return"] = (
|
|
259
|
+
price_df[first_symbol].iloc[-1] / price_df[first_symbol].iloc[0]
|
|
260
|
+
- 1
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return BacktestResult(
|
|
264
|
+
nav_series, self.event_bus.orders, self.event_bus.fills, Metrics(metrics)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _annualised_sharpe(
|
|
269
|
+
nav: pd.Series,
|
|
270
|
+
risk_free_rate: float = 0.0,
|
|
271
|
+
periods_per_year: int = 98_280,
|
|
272
|
+
ddof: int = 1,
|
|
273
|
+
) -> float:
|
|
274
|
+
"""Compute the annualised Sharpe ratio for a NAV series.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
nav: Series of portfolio values indexed by timestamp.
|
|
278
|
+
risk_free_rate: Annual risk-free rate expressed as a decimal. Defaults to
|
|
279
|
+
4.3 % (0.043).
|
|
280
|
+
periods_per_year: Number of return observations in a year. For US
|
|
281
|
+
equity market minutes this is ``252 * 390 = 98_280``. For assets
|
|
282
|
+
trading 24/7 (e.g. crypto) use ``365 * 1_440``.
|
|
283
|
+
ddof: Delta Degrees of Freedom for the standard deviation.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Annualised Sharpe ratio. If the standard deviation of excess returns is
|
|
287
|
+
zero the function returns ``np.nan``.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
# Minute-to-minute returns
|
|
291
|
+
returns = nav.pct_change().dropna()
|
|
292
|
+
if returns.empty:
|
|
293
|
+
return float("nan")
|
|
294
|
+
|
|
295
|
+
# Per-period risk-free rate
|
|
296
|
+
rf_per_period = (1 + risk_free_rate) ** (1 / periods_per_year) - 1
|
|
297
|
+
|
|
298
|
+
excess = returns - rf_per_period
|
|
299
|
+
|
|
300
|
+
# Sample standard deviation (ddof=1) matches common Sharpe implementations
|
|
301
|
+
std = excess.std(ddof=ddof)
|
|
302
|
+
if std == 0:
|
|
303
|
+
return float("nan")
|
|
304
|
+
|
|
305
|
+
return np.sqrt(periods_per_year) * excess.mean() / std
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _max_drawdown(nav: pd.Series) -> float:
|
|
309
|
+
"""Compute the maximum drawdown for a NAV series.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
nav: Series of portfolio values indexed by timestamp.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Maximum drawdown as a percentage (e.g., -0.15 for 15% drawdown).
|
|
316
|
+
Returns 0.0 if the series is empty or has only one value.
|
|
317
|
+
"""
|
|
318
|
+
if nav.empty or len(nav) <= 1:
|
|
319
|
+
return 0.0
|
|
320
|
+
|
|
321
|
+
# Calculate running maximum (peak values)
|
|
322
|
+
running_max = nav.expanding().max()
|
|
323
|
+
|
|
324
|
+
# Calculate drawdown as percentage from peak
|
|
325
|
+
drawdown = (nav - running_max) / running_max
|
|
326
|
+
|
|
327
|
+
# Return the maximum (most negative) drawdown
|
|
328
|
+
return float(drawdown.min())
|