Skip to content

Commit

Permalink
Add run_batch method.
Browse files Browse the repository at this point in the history
Add sharpe ration as internal calc.
Allow weiner path sims to have different sigmas.
Swap ql for pyfeng in notebooks.
Add some features to README.
  • Loading branch information
bsdz committed May 6, 2024
1 parent e23c181 commit 90ddbbf
Show file tree
Hide file tree
Showing 9 changed files with 777 additions and 679 deletions.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

Python module for backtesting trading strategies.

Support event driven backtesting, ie `on_open`, `on_close`, etc. Also supports multiple assets.
Features

Very basic statistics like book cash, mtm and total value. Currently, everything else needs to be deferred to a 3rd party module like `empyrical`.
* Event driven, ie `on_open`, `on_close`, etc.
* Multiple assets.
* OHLC Asset. Extendable (e.g support additional fields, e.g. Volatility, or entirely different fields, e.g. Barrels per day).
* Multiple books.
* Positional and Basket orders. Extendible (e.g. can support stop loss).
* Batch runs (for optimization).
* Captures book history including transactions & daily cash, MtM and total values.

There are some basic tests but use at your own peril. It's not production level code.
The module provides basic statistics like book cash, mtm and total value. Currently, everything else needs to be deferred to a 3rd party module like `empyrical`.

## Core dependencies

Expand All @@ -20,7 +26,7 @@ pip install yatbe

## Usage

Below is an example usage (the performance of the example strategy won't be good).
Below is an example usage (the economic performance of the example strategy won't be good).

```python
import pandas as pd
Expand Down
File renamed without changes.
1,368 changes: 702 additions & 666 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ readme = "README.md"
repository = "https://github.com/bsdz/yabte"

[tool.poetry.build]
script = "build.py"
script = "build_mypyc.py"
generate-setup-file = true

[tool.poetry.dependencies]
python = "^3.10,<3.13"
pandas = ">1.5,<3"
pandas = "^2.2.1"
scipy = "^1.10.0"
pandas-stubs = "^2.1.4.231227"
mypy = "^1.8.0"
Expand Down Expand Up @@ -48,6 +48,7 @@ plotly = "^5.10.0"
ipykernel = "^6.20.2"
pyfeng = "^0.2.5"
nbconvert = "^7.2.9"
quantlib = "^1.34"

[tool.isort]
profile = "black"
Expand Down
5 changes: 4 additions & 1 deletion yabte/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
_author__ = "Blair Azzopardi"
from importlib.metadata import version

__author__ = "Blair Azzopardi"
__version__ = version(__package__)
15 changes: 14 additions & 1 deletion yabte/backtest/strategyrunner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import concurrent.futures
import logging
from concurrent.futures import ProcessPoolExecutor
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterable, List, Optional

import pandas as pd

Expand Down Expand Up @@ -219,3 +221,14 @@ def run(self, params: Dict[str, Any] = None) -> StrategyRunnerResult:
book.eod_tasks(ts, day_data, asset_map)

return srr

def run_batch(
self,
params_iterable: Iterable[Dict[str, Any]],
executor: ProcessPoolExecutor | None = None,
) -> List[StrategyRunnerResult]:
"""Run a set of parameter combinations."""

executor = executor or concurrent.futures.ThreadPoolExecutor()
with executor:
return list(executor.map(self.run, params_iterable))
29 changes: 29 additions & 0 deletions yabte/tests/test_strategy_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import numpy as np
import pandas as pd

import yabte.utilities.pandas_extension
from yabte.backtest import (
BasketOrder,
Book,
Expand Down Expand Up @@ -571,6 +572,34 @@ def on_close(self):
[(o.status, o.key, o.size) for o in srr.orders_unprocessed],
)

def test_run_batch(self):

book = Book(name="Main", cash=Decimal("100000"))

sr = StrategyRunner(
data=self.df_combined,
assets=self.assets,
strategies=[TestSMAXOStrat()],
books=[book],
)

param_iter = [
{"days_long": n, "days_short": m}
for n, m in zip([20, 30, 40, 50], [5, 10, 15, 20])
if n > m
]

srrs = sr.run_batch(param_iter)

self.assertEqual(len(srrs), len(param_iter))

# check we have distinct sharpe ratios for each param set
sharpes = {
srr.book_history.loc[:, ("Main", "total")].prc.sharpe_ratio()
for srr in srrs
}
self.assertEqual(len(sharpes), len(param_iter))


if __name__ == "__main__":
unittest.main()
14 changes: 11 additions & 3 deletions yabte/utilities/pandas_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,23 @@ def standard(self):


@pd.api.extensions.register_dataframe_accessor("prc")
@pd.api.extensions.register_series_accessor("prc")
class PriceAccessor:
# TODO: add ledoit cov (via sklearn)
# http://www.ledoit.net/honey.pdf
# TODO: add Sharpe ratio

def __init__(self, pandas_obj):
self._validate(pandas_obj)
self._obj = pandas_obj

@staticmethod
def _validate(obj):
if (obj < 0).any(axis=None):
raise AttributeError("Prices must be non-negative")
pass

@property
def log_returns(self):
if (self._obj < 0).any(axis=None):
raise AttributeError("Prices must be non-negative for log returns")
return np.log((self._obj / self._obj.shift())[1:])

@property
Expand All @@ -42,6 +43,8 @@ def frequency(self):
return 252
elif days == 7:
return 52
elif 28 <= days <= 31:
return 12

def capm_returns(self, risk_free_rate=0):
returns = self.returns
Expand All @@ -56,6 +59,11 @@ def capm_returns(self, risk_free_rate=0):
+ betas * (returns_mkt.mean() * self.frequency - risk_free_rate)
).rename("CAPM")

def sharpe_ratio(self, risk_free_rate=0, use_log_returns=True):
ann_factor = np.sqrt(self.frequency)
returns = self.log_returns if use_log_returns else self.returns
return ann_factor * (returns.mean() - risk_free_rate) / returns.std()

def null_blips(self, sd=5, sdd=7):
df = self._obj
z = df.scl.standard
Expand Down
6 changes: 4 additions & 2 deletions yabte/utilities/simulation/weiner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@
def weiner_simulate_paths(
n_steps: int,
n_sims: int = 1,
stdev: float = 1,
stdev: float | np.ndarray = 1,
R: np.ndarray = np.array([[1]]),
rng=None,
):
"""Generate simulated Weiner paths.
`stdev` is the increment size, `R` a correlation matrix, `n_steps`
is how many time steps, `n_sims` the number of simulations and `rng`
a numpy random number generator (optional).
a numpy random number generator (optional). If `stdev` is a scalar
it will be broadcasted to the size of `n_sims`.
"""

R = np.atleast_2d(R)
stdev = np.resize(stdev, n_sims).reshape(n_sims, 1)

if rng is None:
rng = np.random.default_rng()
Expand Down

0 comments on commit 90ddbbf

Please sign in to comment.