BackcastPro 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl

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.

Potentially problematic release.


This version of BackcastPro might be problematic. Click here for more details.

BackcastPro/lib.py ADDED
@@ -0,0 +1,646 @@
1
+ """
2
+ Collection of common building blocks, helper auxiliary functions and
3
+ composable strategy classes for reuse.
4
+
5
+ Intended for simple missing-link procedures, not reinventing
6
+ of better-suited, state-of-the-art, fast libraries,
7
+ such as TA-Lib, Tulipy, PyAlgoTrade, NumPy, SciPy ...
8
+
9
+ Please raise ideas for additions to this collection on the [issue tracker].
10
+
11
+ [issue tracker]: https://github.com/kernc/backtesting.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+ from collections import OrderedDict
18
+ from inspect import currentframe
19
+ from itertools import chain, compress, count
20
+ from numbers import Number
21
+ from typing import Callable, Generator, Optional, Sequence, Union
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from ._plotting import plot_heatmaps as _plot_heatmaps
27
+ from ._stats import compute_stats as _compute_stats
28
+ from ._util import SharedMemoryManager, _Array, _as_str, _batch, _tqdm, patch
29
+ from .backtesting import Backtest, Strategy
30
+
31
+ __pdoc__ = {}
32
+
33
+
34
+ OHLCV_AGG = OrderedDict((
35
+ ('Open', 'first'),
36
+ ('High', 'max'),
37
+ ('Low', 'min'),
38
+ ('Close', 'last'),
39
+ ('Volume', 'sum'),
40
+ ))
41
+ """Dictionary of rules for aggregating resampled OHLCV data frames,
42
+ e.g.
43
+
44
+ df.resample('4H', label='right').agg(OHLCV_AGG).dropna()
45
+ """
46
+
47
+ TRADES_AGG = OrderedDict((
48
+ ('Size', 'sum'),
49
+ ('EntryBar', 'first'),
50
+ ('ExitBar', 'last'),
51
+ ('EntryPrice', 'mean'),
52
+ ('ExitPrice', 'mean'),
53
+ ('PnL', 'sum'),
54
+ ('ReturnPct', 'mean'),
55
+ ('EntryTime', 'first'),
56
+ ('ExitTime', 'last'),
57
+ ('Duration', 'sum'),
58
+ ))
59
+ """Dictionary of rules for aggregating resampled trades data,
60
+ e.g.
61
+
62
+ stats['_trades'].resample('1D', on='ExitTime',
63
+ label='right').agg(TRADES_AGG)
64
+ """
65
+
66
+ _EQUITY_AGG = {
67
+ 'Equity': 'last',
68
+ 'DrawdownPct': 'max',
69
+ 'DrawdownDuration': 'max',
70
+ }
71
+
72
+
73
+ def barssince(condition: Sequence[bool], default=np.inf) -> int:
74
+ """
75
+ Return the number of bars since `condition` sequence was last `True`,
76
+ or if never, return `default`.
77
+
78
+ >>> barssince(self.data.Close > self.data.Open)
79
+ 3
80
+ """
81
+ return next(compress(range(len(condition)), reversed(condition)), default)
82
+
83
+
84
+ def cross(series1: Sequence, series2: Sequence) -> bool:
85
+ """
86
+ Return `True` if `series1` and `series2` just crossed
87
+ (above or below) each other.
88
+
89
+ >>> cross(self.data.Close, self.sma)
90
+ True
91
+
92
+ """
93
+ return crossover(series1, series2) or crossover(series2, series1)
94
+
95
+
96
+ def crossover(series1: Sequence, series2: Sequence) -> bool:
97
+ """
98
+ Return `True` if `series1` just crossed over (above)
99
+ `series2`.
100
+
101
+ >>> crossover(self.data.Close, self.sma)
102
+ True
103
+ """
104
+ series1 = (
105
+ series1.values if isinstance(series1, pd.Series) else
106
+ (series1, series1) if isinstance(series1, Number) else
107
+ series1)
108
+ series2 = (
109
+ series2.values if isinstance(series2, pd.Series) else
110
+ (series2, series2) if isinstance(series2, Number) else
111
+ series2)
112
+ try:
113
+ return series1[-2] < series2[-2] and series1[-1] > series2[-1] # type: ignore
114
+ except IndexError:
115
+ return False
116
+
117
+
118
+ def plot_heatmaps(heatmap: pd.Series,
119
+ agg: Union[str, Callable] = 'max',
120
+ *,
121
+ ncols: int = 3,
122
+ plot_width: int = 1200,
123
+ filename: str = '',
124
+ open_browser: bool = True):
125
+ """
126
+ Plots a grid of heatmaps, one for every pair of parameters in `heatmap`.
127
+ See example in [the tutorial].
128
+
129
+ [the tutorial]: https://kernc.github.io/backtesting.py/doc/examples/Parameter%20Heatmap%20&%20Optimization.html#plot-heatmap # noqa: E501
130
+
131
+ `heatmap` is a Series as returned by
132
+ `backtesting.backtesting.Backtest.optimize` when its parameter
133
+ `return_heatmap=True`.
134
+
135
+ When projecting the n-dimensional (n > 2) heatmap onto 2D, the values are
136
+ aggregated by 'max' function by default. This can be tweaked
137
+ with `agg` parameter, which accepts any argument pandas knows
138
+ how to aggregate by.
139
+
140
+ .. todo::
141
+ Lay heatmaps out lower-triangular instead of in a simple grid.
142
+ Like [`sambo.plot.plot_objective()`][plot_objective] does.
143
+
144
+ [plot_objective]: \
145
+ https://sambo-optimization.github.io/doc/sambo/plot.html#sambo.plot.plot_objective
146
+ """
147
+ return _plot_heatmaps(heatmap, agg, ncols, filename, plot_width, open_browser)
148
+
149
+
150
+ def quantile(series: Sequence, quantile: Union[None, float] = None):
151
+ """
152
+ If `quantile` is `None`, return the quantile _rank_ of the last
153
+ value of `series` wrt former series values.
154
+
155
+ If `quantile` is a value between 0 and 1, return the _value_ of
156
+ `series` at this quantile. If used to working with percentiles, just
157
+ divide your percentile amount with 100 to obtain quantiles.
158
+
159
+ >>> quantile(self.data.Close[-20:], .1)
160
+ 162.130
161
+ >>> quantile(self.data.Close)
162
+ 0.13
163
+ """
164
+ if quantile is None:
165
+ try:
166
+ last, series = series[-1], series[:-1]
167
+ return np.mean(series < last)
168
+ except IndexError:
169
+ return np.nan
170
+ assert 0 <= quantile <= 1, "quantile must be within [0, 1]"
171
+ return np.nanpercentile(series, quantile * 100)
172
+
173
+
174
+ def compute_stats(
175
+ *,
176
+ stats: pd.Series,
177
+ data: pd.DataFrame,
178
+ trades: pd.DataFrame = None,
179
+ risk_free_rate: float = 0.) -> pd.Series:
180
+ """
181
+ (Re-)compute strategy performance metrics.
182
+
183
+ `stats` is the statistics series as returned by `backtesting.backtesting.Backtest.run()`.
184
+ `data` is OHLC data as passed to the `backtesting.backtesting.Backtest`
185
+ the `stats` were obtained in.
186
+ `trades` can be a dataframe subset of `stats._trades` (e.g. only long trades).
187
+ You can also tune `risk_free_rate`, used in calculation of Sharpe and Sortino ratios.
188
+
189
+ >>> stats = Backtest(GOOG, MyStrategy).run()
190
+ >>> only_long_trades = stats._trades[stats._trades.Size > 0]
191
+ >>> long_stats = compute_stats(stats=stats, trades=only_long_trades,
192
+ ... data=GOOG, risk_free_rate=.02)
193
+ """
194
+ equity = stats._equity_curve.Equity
195
+ if trades is None:
196
+ trades = stats._trades
197
+ else:
198
+ # XXX: Is this buggy?
199
+ equity = equity.copy()
200
+ equity[:] = stats._equity_curve.Equity.iloc[0]
201
+ for t in trades.itertuples(index=False):
202
+ equity.iloc[t.EntryBar:] += t.PnL
203
+ return _compute_stats(trades=trades, equity=equity.values, ohlc_data=data,
204
+ risk_free_rate=risk_free_rate, strategy_instance=stats._strategy)
205
+
206
+
207
+ def resample_apply(rule: str,
208
+ func: Optional[Callable[..., Sequence]],
209
+ series: Union[pd.Series, pd.DataFrame, _Array],
210
+ *args,
211
+ agg: Optional[Union[str, dict]] = None,
212
+ **kwargs):
213
+ """
214
+ Apply `func` (such as an indicator) to `series`, resampled to
215
+ a time frame specified by `rule`. When called from inside
216
+ `backtesting.backtesting.Strategy.init`,
217
+ the result (returned) series will be automatically wrapped in
218
+ `backtesting.backtesting.Strategy.I`
219
+ wrapper method.
220
+
221
+ `rule` is a valid [Pandas offset string] indicating
222
+ a time frame to resample `series` to.
223
+
224
+ [Pandas offset string]: \
225
+ http://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases
226
+
227
+ `func` is the indicator function to apply on the resampled series.
228
+
229
+ `series` is a data series (or array), such as any of the
230
+ `backtesting.backtesting.Strategy.data` series. Due to pandas
231
+ resampling limitations, this only works when input series
232
+ has a datetime index.
233
+
234
+ `agg` is the aggregation function to use on resampled groups of data.
235
+ Valid values are anything accepted by `pandas/resample/.agg()`.
236
+ Default value for dataframe input is `OHLCV_AGG` dictionary.
237
+ Default value for series input is the appropriate entry from `OHLCV_AGG`
238
+ if series has a matching name, or otherwise the value `"last"`,
239
+ which is suitable for closing prices,
240
+ but you might prefer another (e.g. `"max"` for peaks, or similar).
241
+
242
+ Finally, any `*args` and `**kwargs` that are not already eaten by
243
+ implicit `backtesting.backtesting.Strategy.I` call
244
+ are passed to `func`.
245
+
246
+ For example, if we have a typical moving average function
247
+ `SMA(values, lookback_period)`, _hourly_ data source, and need to
248
+ apply the moving average MA(10) on a _daily_ time frame,
249
+ but don't want to plot the resulting indicator, we can do:
250
+
251
+ class System(Strategy):
252
+ def init(self):
253
+ self.sma = resample_apply(
254
+ 'D', SMA, self.data.Close, 10, plot=False)
255
+
256
+ The above short snippet is roughly equivalent to:
257
+
258
+ class System(Strategy):
259
+ def init(self):
260
+ # Strategy exposes `self.data` as raw NumPy arrays.
261
+ # Let's convert closing prices back to pandas Series.
262
+ close = self.data.Close.s
263
+
264
+ # Resample to daily resolution. Aggregate groups
265
+ # using their last value (i.e. closing price at the end
266
+ # of the day). Notice `label='right'`. If it were set to
267
+ # 'left' (default), the strategy would exhibit
268
+ # look-ahead bias.
269
+ daily = close.resample('D', label='right').agg('last')
270
+
271
+ # We apply SMA(10) to daily close prices,
272
+ # then reindex it back to original hourly index,
273
+ # forward-filling the missing values in each day.
274
+ # We make a separate function that returns the final
275
+ # indicator array.
276
+ def SMA(series, n):
277
+ from BackcastPro.test import SMA
278
+ return SMA(series, n).reindex(close.index).ffill()
279
+
280
+ # The result equivalent to the short example above:
281
+ self.sma = self.I(SMA, daily, 10, plot=False)
282
+
283
+ """
284
+ if func is None:
285
+ def func(x, *_, **__):
286
+ return x
287
+ assert callable(func), 'resample_apply(func=) must be callable'
288
+
289
+ if not isinstance(series, (pd.Series, pd.DataFrame)):
290
+ assert isinstance(series, _Array), \
291
+ 'resample_apply(series=) must be `pd.Series`, `pd.DataFrame`, ' \
292
+ 'or a `Strategy.data.*` array'
293
+ series = series.s
294
+
295
+ if agg is None:
296
+ agg = OHLCV_AGG.get(getattr(series, 'name', ''), 'last')
297
+ if isinstance(series, pd.DataFrame):
298
+ agg = {column: OHLCV_AGG.get(column, 'last')
299
+ for column in series.columns}
300
+
301
+ resampled = series.resample(rule, label='right').agg(agg).dropna()
302
+ resampled.name = _as_str(series) + '[' + rule + ']'
303
+
304
+ # Check first few stack frames if we are being called from
305
+ # inside Strategy.init, and if so, extract Strategy.I wrapper.
306
+ frame, level = currentframe(), 0
307
+ while frame and level <= 3:
308
+ frame = frame.f_back
309
+ level += 1
310
+ if isinstance(frame.f_locals.get('self'), Strategy): # type: ignore
311
+ strategy_I = frame.f_locals['self'].I # type: ignore
312
+ break
313
+ else:
314
+ def strategy_I(func, *args, **kwargs): # noqa: F811
315
+ return func(*args, **kwargs)
316
+
317
+ def wrap_func(resampled, *args, **kwargs):
318
+ result = func(resampled, *args, **kwargs)
319
+ if not isinstance(result, pd.DataFrame) and not isinstance(result, pd.Series):
320
+ result = np.asarray(result)
321
+ if result.ndim == 1:
322
+ result = pd.Series(result, name=resampled.name)
323
+ elif result.ndim == 2:
324
+ result = pd.DataFrame(result.T)
325
+ # Resample back to data index
326
+ if not isinstance(result.index, pd.DatetimeIndex):
327
+ result.index = resampled.index
328
+ result = result.reindex(index=series.index.union(resampled.index),
329
+ method='ffill').reindex(series.index)
330
+ return result
331
+
332
+ wrap_func.__name__ = func.__name__
333
+
334
+ array = strategy_I(wrap_func, resampled, *args, **kwargs)
335
+ return array
336
+
337
+
338
+ def random_ohlc_data(example_data: pd.DataFrame, *,
339
+ frac=1., random_state: Optional[int] = None) -> Generator[pd.DataFrame, None, None]:
340
+ """
341
+ OHLC data generator. The generated OHLC data has basic
342
+ [descriptive statistics](https://en.wikipedia.org/wiki/Descriptive_statistics)
343
+ similar to the provided `example_data`.
344
+
345
+ `frac` is a fraction of data to sample (with replacement). Values greater
346
+ than 1 result in oversampling.
347
+
348
+ Such random data can be effectively used for stress testing trading
349
+ strategy robustness, Monte Carlo simulations, significance testing, etc.
350
+
351
+ >>> from BackcastPro.test import EURUSD
352
+ >>> ohlc_generator = random_ohlc_data(EURUSD)
353
+ >>> next(ohlc_generator) # returns new random data
354
+ ...
355
+ >>> next(ohlc_generator) # returns new random data
356
+ ...
357
+ """
358
+ def shuffle(x):
359
+ return x.sample(frac=frac, replace=frac > 1, random_state=random_state)
360
+
361
+ if len(example_data.columns.intersection({'Open', 'High', 'Low', 'Close'})) != 4:
362
+ raise ValueError("`data` must be a pandas.DataFrame with columns "
363
+ "'Open', 'High', 'Low', 'Close'")
364
+ while True:
365
+ df = shuffle(example_data)
366
+ df.index = example_data.index
367
+ padding = df.Close - df.Open.shift(-1)
368
+ gaps = shuffle(example_data.Open.shift(-1) - example_data.Close)
369
+ deltas = (padding + gaps).shift(1).fillna(0).cumsum()
370
+ for key in ('Open', 'High', 'Low', 'Close'):
371
+ df[key] += deltas
372
+ yield df
373
+
374
+
375
+ class SignalStrategy(Strategy):
376
+ """
377
+ A simple helper strategy that operates on position entry/exit signals.
378
+ This makes the backtest of the strategy simulate a [vectorized backtest].
379
+ See [tutorials] for usage examples.
380
+
381
+ [vectorized backtest]: https://www.google.com/search?q=vectorized+backtest
382
+ [tutorials]: index.html#tutorials
383
+
384
+ To use this helper strategy, subclass it, override its
385
+ `backtesting.backtesting.Strategy.init` method,
386
+ and set the signal vector by calling
387
+ `backtesting.lib.SignalStrategy.set_signal` method from within it.
388
+
389
+ class ExampleStrategy(SignalStrategy):
390
+ def init(self):
391
+ super().init()
392
+ self.set_signal(sma1 > sma2, sma1 < sma2)
393
+
394
+ Remember to call `super().init()` and `super().next()` in your
395
+ overridden methods.
396
+ """
397
+ __entry_signal = (0,)
398
+ __exit_signal = (False,)
399
+
400
+ def set_signal(self, entry_size: Sequence[float],
401
+ exit_portion: Optional[Sequence[float]] = None,
402
+ *,
403
+ plot: bool = True):
404
+ """
405
+ Set entry/exit signal vectors (arrays).
406
+
407
+ A long entry signal is considered present wherever `entry_size`
408
+ is greater than zero, and a short signal wherever `entry_size`
409
+ is less than zero, following `backtesting.backtesting.Order.size` semantics.
410
+
411
+ If `exit_portion` is provided, a nonzero value closes portion the position
412
+ (see `backtesting.backtesting.Trade.close()`) in the respective direction
413
+ (positive values close long trades, negative short).
414
+
415
+ If `plot` is `True`, the signal entry/exit indicators are plotted when
416
+ `backtesting.backtesting.Backtest.plot` is called.
417
+ """
418
+ self.__entry_signal = self.I( # type: ignore
419
+ lambda: pd.Series(entry_size, dtype=float).replace(0, np.nan),
420
+ name='entry size', plot=plot, overlay=False, scatter=True, color='black')
421
+
422
+ if exit_portion is not None:
423
+ self.__exit_signal = self.I( # type: ignore
424
+ lambda: pd.Series(exit_portion, dtype=float).replace(0, np.nan),
425
+ name='exit portion', plot=plot, overlay=False, scatter=True, color='black')
426
+
427
+ def next(self):
428
+ super().next()
429
+
430
+ exit_portion = self.__exit_signal[-1]
431
+ if exit_portion > 0:
432
+ for trade in self.trades:
433
+ if trade.is_long:
434
+ trade.close(exit_portion)
435
+ elif exit_portion < 0:
436
+ for trade in self.trades:
437
+ if trade.is_short:
438
+ trade.close(-exit_portion)
439
+
440
+ entry_size = self.__entry_signal[-1]
441
+ if entry_size > 0:
442
+ self.buy(size=entry_size)
443
+ elif entry_size < 0:
444
+ self.sell(size=-entry_size)
445
+
446
+
447
+ class TrailingStrategy(Strategy):
448
+ """
449
+ A strategy with automatic trailing stop-loss, trailing the current
450
+ price at distance of some multiple of average true range (ATR). Call
451
+ `TrailingStrategy.set_trailing_sl()` to set said multiple
452
+ (`6` by default). See [tutorials] for usage examples.
453
+
454
+ [tutorials]: index.html#tutorials
455
+
456
+ Remember to call `super().init()` and `super().next()` in your
457
+ overridden methods.
458
+ """
459
+ __n_atr = 6.
460
+ __atr = None
461
+
462
+ def init(self):
463
+ super().init()
464
+ self.set_atr_periods()
465
+
466
+ def set_atr_periods(self, periods: int = 100):
467
+ """
468
+ Set the lookback period for computing ATR. The default value
469
+ of 100 ensures a _stable_ ATR.
470
+ """
471
+ hi, lo, c_prev = self.data.High, self.data.Low, pd.Series(self.data.Close).shift(1)
472
+ tr = np.max([hi - lo, (c_prev - hi).abs(), (c_prev - lo).abs()], axis=0)
473
+ atr = pd.Series(tr).rolling(periods).mean().bfill().values
474
+ self.__atr = atr
475
+
476
+ def set_trailing_sl(self, n_atr: float = 6):
477
+ """
478
+ Set the future trailing stop-loss as some multiple (`n_atr`)
479
+ average true bar ranges away from the current price.
480
+ """
481
+ self.__n_atr = n_atr
482
+
483
+ def set_trailing_pct(self, pct: float = .05):
484
+ """
485
+ Set the future trailing stop-loss as some percent (`0 < pct < 1`)
486
+ below the current price (default 5% below).
487
+
488
+ .. note:: Stop-loss set by `pct` is inexact
489
+ Stop-loss set by `set_trailing_pct` is converted to units of ATR
490
+ with `mean(Close * pct / atr)` and set with `set_trailing_sl`.
491
+ """
492
+ assert 0 < pct < 1, 'Need pct= as rate, i.e. 5% == 0.05'
493
+ pct_in_atr = np.mean(self.data.Close * pct / self.__atr) # type: ignore
494
+ self.set_trailing_sl(pct_in_atr)
495
+
496
+ def next(self):
497
+ super().next()
498
+ # Can't use index=-1 because self.__atr is not an Indicator type
499
+ index = len(self.data) - 1
500
+ for trade in self.trades:
501
+ if trade.is_long:
502
+ trade.sl = max(trade.sl or -np.inf,
503
+ self.data.Close[index] - self.__atr[index] * self.__n_atr)
504
+ else:
505
+ trade.sl = min(trade.sl or np.inf,
506
+ self.data.Close[index] + self.__atr[index] * self.__n_atr)
507
+
508
+
509
+ class FractionalBacktest(Backtest):
510
+ """
511
+ A `backtesting.backtesting.Backtest` that supports fractional share trading
512
+ by simple composition. It applies roughly the transformation:
513
+
514
+ data = (data * fractional_unit).assign(Volume=data.Volume / fractional_unit)
515
+
516
+ as left unchallenged in [this FAQ entry on GitHub](https://github.com/kernc/backtesting.py/issues/134),
517
+ then passes `data`, `args*`, and `**kwargs` to its super.
518
+
519
+ Parameter `fractional_unit` represents the smallest fraction of currency that can be traded
520
+ and defaults to one [satoshi]. For μBTC trading, pass `fractional_unit=1/1e6`.
521
+ Thus-transformed backtest does a whole-sized trading of `fractional_unit` units.
522
+
523
+ [satoshi]: https://en.wikipedia.org/wiki/Bitcoin#Units_and_divisibility
524
+ """
525
+ def __init__(self,
526
+ data,
527
+ *args,
528
+ fractional_unit=1 / 100e6,
529
+ **kwargs):
530
+ if 'satoshi' in kwargs:
531
+ warnings.warn(
532
+ 'Parameter `FractionalBacktest(..., satoshi=)` is deprecated. '
533
+ 'Use `FractionalBacktest(..., fractional_unit=)`.',
534
+ category=DeprecationWarning, stacklevel=2)
535
+ fractional_unit = 1 / kwargs.pop('satoshi')
536
+ self._fractional_unit = fractional_unit
537
+ self.__data: pd.DataFrame = data.copy(deep=False) # Shallow copy
538
+ for col in ('Open', 'High', 'Low', 'Close',):
539
+ self.__data[col] = self.__data[col] * self._fractional_unit
540
+ for col in ('Volume',):
541
+ self.__data[col] = self.__data[col] / self._fractional_unit
542
+ with warnings.catch_warnings(record=True):
543
+ warnings.filterwarnings(action='ignore', message='frac')
544
+ super().__init__(data, *args, **kwargs)
545
+
546
+ def run(self, **kwargs) -> pd.Series:
547
+ with patch(self, '_data', self.__data):
548
+ result = super().run(**kwargs)
549
+
550
+ trades: pd.DataFrame = result['_trades']
551
+ trades['Size'] *= self._fractional_unit
552
+ trades[['EntryPrice', 'ExitPrice', 'TP', 'SL']] /= self._fractional_unit
553
+
554
+ indicators = result['_strategy']._indicators
555
+ for indicator in indicators:
556
+ if indicator._opts['overlay']:
557
+ indicator /= self._fractional_unit
558
+
559
+ return result
560
+
561
+
562
+ # Prevent pdoc3 documenting __init__ signature of Strategy subclasses
563
+ for cls in list(globals().values()):
564
+ if isinstance(cls, type) and issubclass(cls, Strategy):
565
+ __pdoc__[f'{cls.__name__}.__init__'] = False
566
+
567
+
568
+ class MultiBacktest:
569
+ """
570
+ Multi-dataset `backtesting.backtesting.Backtest` wrapper.
571
+
572
+ Run supplied `backtesting.backtesting.Strategy` on several instruments,
573
+ in parallel. Used for comparing strategy runs across many instruments
574
+ or classes of instruments. Example:
575
+
576
+ from BackcastPro.test import EURUSD, BTCUSD, SmaCross
577
+ btm = MultiBacktest([EURUSD, BTCUSD], SmaCross)
578
+ stats_per_ticker: pd.DataFrame = btm.run(fast=10, slow=20)
579
+ heatmap_per_ticker: pd.DataFrame = btm.optimize(...)
580
+ """
581
+ def __init__(self, df_list, strategy_cls, **kwargs):
582
+ self._dfs = df_list
583
+ self._strategy = strategy_cls
584
+ self._bt_kwargs = kwargs
585
+
586
+ def run(self, **kwargs):
587
+ """
588
+ Wraps `backtesting.backtesting.Backtest.run`. Returns `pd.DataFrame` with
589
+ currency indexes in columns.
590
+ """
591
+ from . import Pool
592
+ with Pool() as pool, \
593
+ SharedMemoryManager() as smm:
594
+ shm = [smm.df2shm(df) for df in self._dfs]
595
+ results = _tqdm(
596
+ pool.imap(self._mp_task_run,
597
+ ((df_batch, self._strategy, self._bt_kwargs, kwargs)
598
+ for df_batch in _batch(shm))),
599
+ total=len(shm),
600
+ desc=self.run.__qualname__,
601
+ mininterval=2
602
+ )
603
+ df = pd.DataFrame(list(chain(*results))).transpose()
604
+ return df
605
+
606
+ @staticmethod
607
+ def _mp_task_run(args):
608
+ data_shm, strategy, bt_kwargs, run_kwargs = args
609
+ dfs, shms = zip(*(SharedMemoryManager.shm2df(i) for i in data_shm))
610
+ try:
611
+ return [stats.filter(regex='^[^_]') if stats['# Trades'] else None
612
+ for stats in (Backtest(df, strategy, **bt_kwargs).run(**run_kwargs)
613
+ for df in dfs)]
614
+ finally:
615
+ for shmem in chain(*shms):
616
+ shmem.close()
617
+
618
+ def optimize(self, **kwargs) -> pd.DataFrame:
619
+ """
620
+ Wraps `backtesting.backtesting.Backtest.optimize`, but returns `pd.DataFrame` with
621
+ currency indexes in columns.
622
+
623
+ heamap: pd.DataFrame = btm.optimize(...)
624
+ from BackcastPro.plot import plot_heatmaps
625
+ plot_heatmaps(heatmap.mean(axis=1))
626
+ """
627
+ heatmaps = []
628
+ # Simple loop since bt.optimize already does its own multiprocessing
629
+ for df in _tqdm(self._dfs, desc=self.__class__.__name__, mininterval=2):
630
+ bt = Backtest(df, self._strategy, **self._bt_kwargs)
631
+ _best_stats, heatmap = bt.optimize( # type: ignore
632
+ return_heatmap=True, return_optimization=False, **kwargs)
633
+ heatmaps.append(heatmap)
634
+ heatmap = pd.DataFrame(dict(zip(count(), heatmaps)))
635
+ return heatmap
636
+
637
+
638
+ # NOTE: Don't put anything below this __all__ list
639
+
640
+ __all__ = [getattr(v, '__name__', k)
641
+ for k, v in globals().items() # export
642
+ if ((callable(v) and getattr(v, '__module__', None) == __name__ or # callables from this module
643
+ k.isupper()) and # or CONSTANTS
644
+ not getattr(v, '__name__', k).startswith('_'))] # neither marked internal
645
+
646
+ # NOTE: Don't put anything below here. See above.
@@ -0,0 +1,29 @@
1
+ """Data and utilities for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def _read_file(filename):
9
+ from os.path import dirname, join
10
+
11
+ return pd.read_csv(join(dirname(__file__), filename),
12
+ index_col=0, parse_dates=True)
13
+
14
+
15
+ BTCUSD = _read_file('BTCUSD.csv')
16
+ """DataFrame of monthly BTC/USD histrical index data from 2012 through 2024 (12 years)."""
17
+
18
+ GOOG = _read_file('GOOG.csv')
19
+ """DataFrame of daily NASDAQ:GOOG (Google/Alphabet) stock price data from 2004 to 2013."""
20
+
21
+ EURUSD = _read_file('EURUSD.csv')
22
+ """DataFrame of hourly EUR/USD forex data from April 2017 to February 2018."""
23
+
24
+
25
+ def SMA(arr: pd.Series, n: int) -> pd.Series:
26
+ """
27
+ Returns `n`-period simple moving average of array `arr`.
28
+ """
29
+ return pd.Series(arr).rolling(n).mean()
@@ -0,0 +1,7 @@
1
+ import unittest
2
+ import warnings
3
+
4
+
5
+ if __name__ == '__main__':
6
+ warnings.filterwarnings('error')
7
+ unittest.main(module='backtesting.test._test', verbosity=2)