Skip to content

Commit 4b3547e

Browse files
committed
Merge remote-tracking branch 'origin/master' into arkershaw-master
2 parents a7c716a + ce74a34 commit 4b3547e

File tree

12 files changed

+112
-47
lines changed

12 files changed

+112
-47
lines changed

.github/ISSUE_TEMPLATE/1-bug.yml

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
name: Bug report
22
description: File a new bug report. Please use the search
3-
title: "Short title loaded with keywords"
43
body:
54
- type: markdown
65
attributes:
@@ -80,7 +79,13 @@ body:
8079
description: >
8180
Versions of the relevant software / packages.
8281
value: |
83-
<!-- From `backtesting.__version__`. If git, use commit hash -->
84-
- Backtesting version: 0.?.?
82+
<!--
83+
# Please paste the output of:
84+
for pkg in ('backtesting', 'pandas', 'numpy', 'bokeh'):
85+
print('-', pkg, getattr(__import__(pkg), '__version__', 'git'))
86+
-->
87+
- `backtesting.__version__`:
88+
- `pandas.__version__`:
89+
- `numpy.__version__`:
8590
- `bokeh.__version__`:
8691
- OS:

.github/ISSUE_TEMPLATE/2-enh.yml

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
name: Enhancement proposal
22
description: Describe the enhancement you'd like to see
3-
title: "Short title loaded with keywords"
43
body:
54
- type: markdown
65
attributes:

.github/workflows/ci.yml

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ on:
44
pull_request: { branches: [master] }
55
schedule: [ cron: '2 2 * * 6' ] # Every Saturday, 02:02
66

7+
env:
8+
TQDM_MININTERVAL: 10
79
jobs:
810
lint:
911
runs-on: ubuntu-latest

.github/workflows/deploy-docs.yml

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ jobs:
77
deploy:
88
name: Deploy
99
runs-on: ubuntu-latest
10+
env:
11+
TQDM_MININTERVAL: 10
1012

1113
steps:
1214
- uses: actions/setup-python@v5

backtesting/__init__.py

+20
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,23 @@
6868
from . import lib # noqa: F401
6969
from ._plotting import set_bokeh_output # noqa: F401
7070
from .backtesting import Backtest, Strategy # noqa: F401
71+
72+
73+
# Add overridable backtesting.Pool used for parallel optimization
74+
def Pool(processes=None, initializer=None, initargs=()):
75+
import multiprocessing as mp
76+
if mp.get_start_method() == 'spawn':
77+
import warnings
78+
warnings.warn(
79+
"If you want to use multi-process optimization with "
80+
"`multiprocessing.get_start_method() == 'spawn'` (e.g. on Windows),"
81+
"set `backtesting.Pool = multiprocessing.Pool` (or of the desired context) "
82+
"and hide `bt.optimize()` call behind a `if __name__ == '__main__'` guard. "
83+
"Currently using thread-based paralellism, "
84+
"which might be slightly slower for non-numpy / non-GIL-releasing code. "
85+
"See https://github.com/kernc/backtesting.py/issues/1256",
86+
category=RuntimeWarning, stacklevel=3)
87+
from multiprocessing.dummy import Pool
88+
return Pool(processes, initializer, initargs)
89+
else:
90+
return mp.Pool(processes, initializer, initargs)

backtesting/_plotting.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ def _maybe_resample_data(resample_rule, df, indicators, equity_data, trades):
128128
"15min": 15,
129129
"30min": 30,
130130
"1h": 60,
131-
"2h": 60*2,
132-
"4h": 60*4,
133-
"8h": 60*8,
134-
"1D": 60*24,
135-
"1W": 60*24*7,
131+
"2h": 60 * 2,
132+
"4h": 60 * 4,
133+
"8h": 60 * 8,
134+
"1D": 60 * 24,
135+
"1W": 60 * 24 * 7,
136136
"1ME": np.inf,
137137
})
138138
timespan = df.index[-1] - df.index[0]
@@ -683,6 +683,7 @@ def __eq__(self, other):
683683
f.legend.margin = 0
684684
f.legend.label_text_font_size = '8pt'
685685
f.legend.click_policy = "hide"
686+
f.legend.background_fill_alpha = .9
686687
f.min_border_left = 0
687688
f.min_border_top = 3
688689
f.min_border_bottom = 6

backtesting/_stats.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,12 @@ def _round_timedelta(value, _period=_data_period(index)):
137137
# our risk doesn't; they use the simpler approach below.
138138
annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
139139
s.loc['Return (Ann.) [%]'] = annualized_return * 100
140-
s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2*annual_trading_days)) * 100 # noqa: E501
140+
s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2 * annual_trading_days)) * 100 # noqa: E501
141141
# s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
142142
# s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
143143
if is_datetime_index:
144144
time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days
145-
s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1/time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
145+
s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
146146

147147
# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
148148
# and simple standard deviation
@@ -154,8 +154,11 @@ def _round_timedelta(value, _period=_data_period(index)):
154154
s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
155155
equity_log_returns = np.log(equity[1:] / equity[:-1])
156156
market_log_returns = np.log(c[1:] / c[:-1])
157-
cov_matrix = np.cov(equity_log_returns, market_log_returns)
158-
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
157+
beta = np.nan
158+
if len(equity_log_returns) > 1 and len(market_log_returns) > 1:
159+
# len == 0 on dummy call `stats_keys = compute_stats(...)` pre optimization
160+
cov_matrix = np.cov(equity_log_returns, market_log_returns)
161+
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
159162
# Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large
160163
s.loc['Alpha [%]'] = s.loc['Return [%]'] - risk_free_rate * 100 - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501
161164
s.loc['Beta'] = beta
@@ -194,3 +197,15 @@ def __repr__(self):
194197
# 'format.na_rep', '--', # TODO: Enable once it works
195198
):
196199
return super().__repr__()
200+
201+
202+
def dummy_stats():
203+
from .backtesting import Trade, _Broker
204+
index = pd.DatetimeIndex(['2025'])
205+
data = pd.DataFrame({col: [np.nan] for col in ('Close',)}, index=index)
206+
trade = Trade(_Broker(data=data, cash=10000, spread=.01, commission=.01, margin=.1,
207+
trade_on_close=True, hedging=True, exclusive_orders=False, index=index),
208+
1, 1, 0, None)
209+
trade._replace(exit_price=1, exit_bar=0)
210+
trade._commissions = np.nan
211+
return compute_stats([trade], np.r_[[np.nan]], data, None, 0)

backtesting/_util.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,11 @@ def arr2shm(self, vals):
305305
"""Array to shared memory. Returns (shm_name, shape, dtype) used for restore."""
306306
assert vals.ndim == 1, (vals.ndim, vals.shape, vals)
307307
shm = self.SharedMemory(size=vals.nbytes, create=True)
308-
buf = np.ndarray(vals.shape, dtype=vals.dtype, buffer=shm.buf)
309-
buf[:] = vals[:] # Copy into shared memory
308+
# np.array can't handle pandas' tz-aware datetimes
309+
# https://github.com/numpy/numpy/issues/18279
310+
buf = np.ndarray(vals.shape, dtype=vals.dtype.base, buffer=shm.buf)
311+
has_tz = getattr(vals.dtype, 'tz', None)
312+
buf[:] = vals.tz_localize(None) if has_tz else vals # Copy into shared memory
310313
return shm.name, vals.shape, vals.dtype
311314

312315
def df2shm(self, df):
@@ -316,18 +319,18 @@ def df2shm(self, df):
316319
))
317320

318321
@staticmethod
319-
def shm2arr(shm, shape, dtype):
320-
arr = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
322+
def shm2s(shm, shape, dtype) -> pd.Series:
323+
arr = np.ndarray(shape, dtype=dtype.base, buffer=shm.buf)
321324
arr.setflags(write=False)
322-
return arr
325+
return pd.Series(arr, dtype=dtype)
323326

324327
_DF_INDEX_COL = '__bt_index'
325328

326329
@staticmethod
327330
def shm2df(data_shm):
328331
shm = [SharedMemory(name=name, create=False, track=False) for _, name, _, _ in data_shm]
329332
df = pd.DataFrame({
330-
col: SharedMemoryManager.shm2arr(shm, shape, dtype)
333+
col: SharedMemoryManager.shm2s(shm, shape, dtype)
331334
for shm, (col, _, shape, dtype) in zip(shm, data_shm)})
332335
df.set_index(SharedMemoryManager._DF_INDEX_COL, drop=True, inplace=True)
333336
df.index.name = None

backtesting/backtesting.py

+11-12
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from __future__ import annotations
1010

11-
import multiprocessing as mp
1211
import sys
1312
import warnings
1413
from abc import ABCMeta, abstractmethod
@@ -24,7 +23,7 @@
2423
from numpy.random import default_rng
2524

2625
from ._plotting import plot # noqa: I001
27-
from ._stats import compute_stats
26+
from ._stats import compute_stats, dummy_stats
2827
from ._util import (
2928
SharedMemoryManager, _as_str, _Indicator, _Data, _batch, _indicator_warmup_nbars,
3029
_strategy_indicators, patch, try_, _tqdm,
@@ -211,7 +210,7 @@ def next(self):
211210
"""
212211

213212
class __FULL_EQUITY(float): # noqa: N801
214-
def __repr__(self): return '.9999'
213+
def __repr__(self): return '.9999' # noqa: E704
215214
_FULL_EQUITY = __FULL_EQUITY(1 - sys.float_info.epsilon)
216215

217216
def buy(self, *,
@@ -449,7 +448,7 @@ def __repr__(self):
449448
('tp', self.__tp_price),
450449
('contingent', self.is_contingent),
451450
('tag', self.__tag),
452-
) if value is not None))
451+
) if value is not None)) # noqa: E126
453452

454453
def cancel(self):
455454
"""Cancel the order."""
@@ -578,7 +577,7 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar,
578577
def __repr__(self):
579578
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
580579
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
581-
f'{" tag="+str(self.__tag) if self.__tag is not None else ""}>'
580+
f'{" tag=" + str(self.__tag) if self.__tag is not None else ""}>'
582581

583582
def _replace(self, **kwargs):
584583
for k, v in kwargs.items():
@@ -1309,7 +1308,8 @@ def run(self, **kwargs) -> pd.Series:
13091308
# np.nan >= 3 is not invalid; it's False.
13101309
with np.errstate(invalid='ignore'):
13111310

1312-
for i in _tqdm(range(start, len(self._data)), desc=self.run.__qualname__):
1311+
for i in _tqdm(range(start, len(self._data)), desc=self.run.__qualname__,
1312+
unit='bar', mininterval=2, miniters=100):
13131313
# Prepare data and indicators for `next` call
13141314
data._set_length(i + 1)
13151315
for attr, indicator in indicator_attrs:
@@ -1425,9 +1425,7 @@ def optimize(self, *,
14251425
maximize_key = None
14261426
if isinstance(maximize, str):
14271427
maximize_key = str(maximize)
1428-
stats_keys = compute_stats(
1429-
[], np.r_[[np.nan]], pd.DataFrame({col: [np.nan] for col in ('Close',)}), None, 0).index
1430-
if maximize not in stats_keys:
1428+
if maximize not in dummy_stats().index:
14311429
raise ValueError('`maximize`, if str, must match a key in pd.Series '
14321430
'result of backtest.run()')
14331431

@@ -1503,9 +1501,9 @@ def _optimize_grid() -> Union[pd.Series, Tuple[pd.Series, pd.Series]]:
15031501
[p.values() for p in param_combos],
15041502
names=next(iter(param_combos)).keys()))
15051503

1506-
with mp.Pool() as pool, \
1504+
from . import Pool
1505+
with Pool() as pool, \
15071506
SharedMemoryManager() as smm:
1508-
15091507
with patch(self, '_data', None):
15101508
bt = copy(self) # bt._data will be reassigned in _mp_task worker
15111509
results = _tqdm(
@@ -1567,7 +1565,8 @@ def memoized_run(tup):
15671565
stats = self.run(**dict(tup))
15681566
return -maximize(stats)
15691567

1570-
progress = iter(_tqdm(repeat(None), total=max_tries, leave=False, desc='Backtest.optimize'))
1568+
progress = iter(_tqdm(repeat(None), total=max_tries, leave=False,
1569+
desc=self.optimize.__qualname__, mininterval=2))
15711570
_names = tuple(kwargs.keys())
15721571

15731572
def objective_function(x):

backtesting/lib.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
from __future__ import annotations
1515

16-
import multiprocessing as mp
1716
import warnings
1817
from collections import OrderedDict
1918
from inspect import currentframe
@@ -497,7 +496,7 @@ def set_trailing_pct(self, pct: float = .05):
497496
def next(self):
498497
super().next()
499498
# Can't use index=-1 because self.__atr is not an Indicator type
500-
index = len(self.data)-1
499+
index = len(self.data) - 1
501500
for trade in self.trades:
502501
if trade.is_long:
503502
trade.sl = max(trade.sl or -np.inf,
@@ -587,15 +586,17 @@ def run(self, **kwargs):
587586
Wraps `backtesting.backtesting.Backtest.run`. Returns `pd.DataFrame` with
588587
currency indexes in columns.
589588
"""
590-
with mp.Pool() as pool, \
589+
from . import Pool
590+
with Pool() as pool, \
591591
SharedMemoryManager() as smm:
592592
shm = [smm.df2shm(df) for df in self._dfs]
593593
results = _tqdm(
594594
pool.imap(self._mp_task_run,
595595
((df_batch, self._strategy, self._bt_kwargs, kwargs)
596596
for df_batch in _batch(shm))),
597597
total=len(shm),
598-
desc=self.__class__.__name__,
598+
desc=self.run.__qualname__,
599+
mininterval=2
599600
)
600601
df = pd.DataFrame(list(chain(*results))).transpose()
601602
return df
@@ -623,7 +624,7 @@ def optimize(self, **kwargs) -> pd.DataFrame:
623624
"""
624625
heatmaps = []
625626
# Simple loop since bt.optimize already does its own multiprocessing
626-
for df in _tqdm(self._dfs, desc=self.__class__.__name__):
627+
for df in _tqdm(self._dfs, desc=self.__class__.__name__, mininterval=2):
627628
bt = Backtest(df, self._strategy, **self._bt_kwargs)
628629
_best_stats, heatmap = bt.optimize( # type: ignore
629630
return_heatmap=True, return_optimization=False, **kwargs)

backtesting/test/_test.py

+25-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import inspect
2+
import multiprocessing as mp
23
import os
34
import sys
45
import time
@@ -287,7 +288,7 @@ def test_compute_drawdown(self):
287288
def test_compute_stats(self):
288289
stats = Backtest(GOOG, SmaCross, finalize_trades=True).run()
289290
expected = pd.Series({
290-
# NOTE: These values are also used on the website!
291+
# NOTE: These values are also used on the website! # noqa: E126
291292
'# Trades': 66,
292293
'Avg. Drawdown Duration': pd.Timedelta('41 days 00:00:00'),
293294
'Avg. Drawdown [%]': -5.925851581948801,
@@ -629,7 +630,8 @@ def test_optimize_speed(self):
629630
bt.optimize(fast=range(2, 20, 2), slow=range(10, 40, 2))
630631
end = time.process_time()
631632
print(end - start)
632-
self.assertLess(end - start, .3)
633+
handicap = 5 if 'win' in sys.platform else .1
634+
self.assertLess(end - start, .3 + handicap)
633635

634636

635637
class TestPlot(TestCase):
@@ -932,7 +934,7 @@ def next(self):
932934
self.assertEqual(stats['# Trades'], 56)
933935

934936
def test_FractionalBacktest(self):
935-
ubtc_bt = FractionalBacktest(BTCUSD['2015':], SmaCross, fractional_unit=1/1e6, cash=100)
937+
ubtc_bt = FractionalBacktest(BTCUSD['2015':], SmaCross, fractional_unit=1 / 1e6, cash=100)
936938
stats = ubtc_bt.run(fast=2, slow=3)
937939
self.assertEqual(stats['# Trades'], 41)
938940
trades = stats['_trades']
@@ -942,13 +944,20 @@ def test_FractionalBacktest(self):
942944
self.assertAlmostEqual(stats['_strategy']._indicators[0][trade['EntryBar']], 234.14)
943945

944946
def test_MultiBacktest(self):
945-
btm = MultiBacktest([GOOG, EURUSD, BTCUSD], SmaCross, cash=100_000)
946-
res = btm.run(fast=2)
947-
self.assertIsInstance(res, pd.DataFrame)
948-
self.assertEqual(res.columns.tolist(), [0, 1, 2])
949-
heatmap = btm.optimize(fast=[2, 4], slow=[10, 20])
950-
self.assertIsInstance(heatmap, pd.DataFrame)
951-
self.assertEqual(heatmap.columns.tolist(), [0, 1, 2])
947+
import backtesting
948+
assert callable(getattr(backtesting, 'Pool', None)), backtesting.__dict__
949+
for start_method in mp.get_all_start_methods():
950+
with self.subTest(start_method=start_method), \
951+
patch(backtesting, 'Pool', mp.get_context(start_method).Pool):
952+
start_time = time.monotonic()
953+
btm = MultiBacktest([GOOG, EURUSD, BTCUSD], SmaCross, cash=100_000)
954+
res = btm.run(fast=2)
955+
self.assertIsInstance(res, pd.DataFrame)
956+
self.assertEqual(res.columns.tolist(), [0, 1, 2])
957+
heatmap = btm.optimize(fast=[2, 4], slow=[10, 20])
958+
self.assertIsInstance(heatmap, pd.DataFrame)
959+
self.assertEqual(heatmap.columns.tolist(), [0, 1, 2])
960+
print(start_method, time.monotonic() - start_time)
952961
plot_heatmaps(heatmap.mean(axis=1), open_browser=False)
953962

954963

@@ -1009,7 +1018,6 @@ def test_indicators_picklable(self):
10091018
class TestDocs(TestCase):
10101019
DOCS_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'doc')
10111020

1012-
@unittest.skipIf('win' in sys.platform, "Locks up with `ModuleNotFoundError: No module named '<run_path>'`")
10131021
@unittest.skipUnless(os.path.isdir(DOCS_DIR), "docs dir doesn't exist")
10141022
def test_examples(self):
10151023
examples = glob(os.path.join(self.DOCS_DIR, 'examples', '*.py'))
@@ -1127,3 +1135,9 @@ def next(self):
11271135
trades = Backtest(SHORT_DATA, S).run()._trades
11281136
self.assertEqual(trades['ExitBar'].iloc[0], 3)
11291137
self.assertEqual(trades['ExitPrice'].iloc[0], 105)
1138+
1139+
def test_optimize_datetime_index_with_timezone(self):
1140+
data: pd.DataFrame = GOOG.iloc[:100]
1141+
data.index = data.index.tz_localize('Asia/Kolkata')
1142+
res = Backtest(data, SmaCross).optimize(fast=range(2, 3), slow=range(4, 5))
1143+
self.assertGreater(res['# Trades'], 0)

0 commit comments

Comments
 (0)