BackcastPro 0.0.2__py3-none-any.whl → 0.0.3__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/__init__.py +4 -86
- BackcastPro/_broker.py +390 -0
- BackcastPro/_stats.py +169 -212
- BackcastPro/backtest.py +269 -0
- BackcastPro/data/__init__.py +7 -0
- BackcastPro/data/datareader.py +168 -0
- BackcastPro/order.py +154 -0
- BackcastPro/position.py +61 -0
- BackcastPro/strategy.py +174 -0
- BackcastPro/trade.py +195 -0
- backcastpro-0.0.3.dist-info/METADATA +59 -0
- backcastpro-0.0.3.dist-info/RECORD +14 -0
- BackcastPro/_plotting.py +0 -785
- BackcastPro/_util.py +0 -337
- BackcastPro/backtesting.py +0 -1763
- BackcastPro/lib.py +0 -646
- BackcastPro/test/__init__.py +0 -29
- BackcastPro/test/__main__.py +0 -7
- BackcastPro/test/_test.py +0 -1174
- backcastpro-0.0.2.dist-info/METADATA +0 -53
- backcastpro-0.0.2.dist-info/RECORD +0 -13
- {backcastpro-0.0.2.dist-info → backcastpro-0.0.3.dist-info}/WHEEL +0 -0
- {backcastpro-0.0.2.dist-info → backcastpro-0.0.3.dist-info}/top_level.txt +0 -0
BackcastPro/backtesting.py
DELETED
|
@@ -1,1763 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Core framework data structures.
|
|
3
|
-
Objects from this module can also be imported from the top-level
|
|
4
|
-
module directly, e.g.
|
|
5
|
-
|
|
6
|
-
from BackcastPro import Backtest, Strategy
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import sys
|
|
12
|
-
import warnings
|
|
13
|
-
from abc import ABCMeta, abstractmethod
|
|
14
|
-
from copy import copy
|
|
15
|
-
from functools import lru_cache, partial
|
|
16
|
-
from itertools import chain, product, repeat
|
|
17
|
-
from math import copysign
|
|
18
|
-
from numbers import Number
|
|
19
|
-
from typing import Callable, List, Optional, Sequence, Tuple, Type, Union
|
|
20
|
-
|
|
21
|
-
import numpy as np
|
|
22
|
-
import pandas as pd
|
|
23
|
-
from numpy.random import default_rng
|
|
24
|
-
|
|
25
|
-
from ._plotting import plot # noqa: I001
|
|
26
|
-
from ._stats import compute_stats, dummy_stats
|
|
27
|
-
from ._util import (
|
|
28
|
-
SharedMemoryManager, _as_str, _Indicator, _Data, _batch, _indicator_warmup_nbars,
|
|
29
|
-
_strategy_indicators, patch, try_, _tqdm,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
__pdoc__ = {
|
|
33
|
-
'Strategy.__init__': False,
|
|
34
|
-
'Order.__init__': False,
|
|
35
|
-
'Position.__init__': False,
|
|
36
|
-
'Trade.__init__': False,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class Strategy(metaclass=ABCMeta):
|
|
41
|
-
"""
|
|
42
|
-
A trading strategy base class. Extend this class and
|
|
43
|
-
override methods
|
|
44
|
-
`backtesting.backtesting.Strategy.init` and
|
|
45
|
-
`backtesting.backtesting.Strategy.next` to define
|
|
46
|
-
your own strategy.
|
|
47
|
-
"""
|
|
48
|
-
def __init__(self, broker, data, params):
|
|
49
|
-
self._indicators = []
|
|
50
|
-
self._broker: _Broker = broker
|
|
51
|
-
self._data: _Data = data
|
|
52
|
-
self._params = self._check_params(params)
|
|
53
|
-
|
|
54
|
-
def __repr__(self):
|
|
55
|
-
return '<Strategy ' + str(self) + '>'
|
|
56
|
-
|
|
57
|
-
def __str__(self):
|
|
58
|
-
params = ','.join(f'{i[0]}={i[1]}' for i in zip(self._params.keys(),
|
|
59
|
-
map(_as_str, self._params.values())))
|
|
60
|
-
if params:
|
|
61
|
-
params = '(' + params + ')'
|
|
62
|
-
return f'{self.__class__.__name__}{params}'
|
|
63
|
-
|
|
64
|
-
def _check_params(self, params):
|
|
65
|
-
for k, v in params.items():
|
|
66
|
-
if not hasattr(self, k):
|
|
67
|
-
raise AttributeError(
|
|
68
|
-
f"Strategy '{self.__class__.__name__}' is missing parameter '{k}'."
|
|
69
|
-
"Strategy class should define parameters as class variables before they "
|
|
70
|
-
"can be optimized or run with.")
|
|
71
|
-
setattr(self, k, v)
|
|
72
|
-
return params
|
|
73
|
-
|
|
74
|
-
def I(self, # noqa: E743
|
|
75
|
-
func: Callable, *args,
|
|
76
|
-
name=None, plot=True, overlay=None, color=None, scatter=False,
|
|
77
|
-
**kwargs) -> np.ndarray:
|
|
78
|
-
"""
|
|
79
|
-
Declare an indicator. An indicator is just an array of values
|
|
80
|
-
(or a tuple of such arrays in case of, e.g., MACD indicator),
|
|
81
|
-
but one that is revealed gradually in
|
|
82
|
-
`backtesting.backtesting.Strategy.next` much like
|
|
83
|
-
`backtesting.backtesting.Strategy.data` is.
|
|
84
|
-
Returns `np.ndarray` of indicator values.
|
|
85
|
-
|
|
86
|
-
`func` is a function that returns the indicator array(s) of
|
|
87
|
-
same length as `backtesting.backtesting.Strategy.data`.
|
|
88
|
-
|
|
89
|
-
In the plot legend, the indicator is labeled with
|
|
90
|
-
function name, unless `name` overrides it. If `func` returns
|
|
91
|
-
a tuple of arrays, `name` can be a sequence of strings, and
|
|
92
|
-
its size must agree with the number of arrays returned.
|
|
93
|
-
|
|
94
|
-
If `plot` is `True`, the indicator is plotted on the resulting
|
|
95
|
-
`backtesting.backtesting.Backtest.plot`.
|
|
96
|
-
|
|
97
|
-
If `overlay` is `True`, the indicator is plotted overlaying the
|
|
98
|
-
price candlestick chart (suitable e.g. for moving averages).
|
|
99
|
-
If `False`, the indicator is plotted standalone below the
|
|
100
|
-
candlestick chart. By default, a heuristic is used which decides
|
|
101
|
-
correctly most of the time.
|
|
102
|
-
|
|
103
|
-
`color` can be string hex RGB triplet or X11 color name.
|
|
104
|
-
By default, the next available color is assigned.
|
|
105
|
-
|
|
106
|
-
If `scatter` is `True`, the plotted indicator marker will be a
|
|
107
|
-
circle instead of a connected line segment (default).
|
|
108
|
-
|
|
109
|
-
Additional `*args` and `**kwargs` are passed to `func` and can
|
|
110
|
-
be used for parameters.
|
|
111
|
-
|
|
112
|
-
For example, using simple moving average function from TA-Lib:
|
|
113
|
-
|
|
114
|
-
def init():
|
|
115
|
-
self.sma = self.I(ta.SMA, self.data.Close, self.n_sma)
|
|
116
|
-
|
|
117
|
-
.. warning::
|
|
118
|
-
Rolling indicators may front-pad warm-up values with NaNs.
|
|
119
|
-
In this case, the **backtest will only begin on the first bar when
|
|
120
|
-
all declared indicators have non-NaN values** (e.g. bar 201 for a
|
|
121
|
-
strategy that uses a 200-bar MA).
|
|
122
|
-
This can affect results.
|
|
123
|
-
"""
|
|
124
|
-
def _format_name(name: str) -> str:
|
|
125
|
-
return name.format(*map(_as_str, args),
|
|
126
|
-
**dict(zip(kwargs.keys(), map(_as_str, kwargs.values()))))
|
|
127
|
-
|
|
128
|
-
if name is None:
|
|
129
|
-
params = ','.join(filter(None, map(_as_str, chain(args, kwargs.values()))))
|
|
130
|
-
func_name = _as_str(func)
|
|
131
|
-
name = (f'{func_name}({params})' if params else f'{func_name}')
|
|
132
|
-
elif isinstance(name, str):
|
|
133
|
-
name = _format_name(name)
|
|
134
|
-
elif try_(lambda: all(isinstance(item, str) for item in name), False):
|
|
135
|
-
name = [_format_name(item) for item in name]
|
|
136
|
-
else:
|
|
137
|
-
raise TypeError(f'Unexpected `name=` type {type(name)}; expected `str` or '
|
|
138
|
-
'`Sequence[str]`')
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
value = func(*args, **kwargs)
|
|
142
|
-
except Exception as e:
|
|
143
|
-
raise RuntimeError(f'Indicator "{name}" error. See traceback above.') from e
|
|
144
|
-
|
|
145
|
-
if isinstance(value, pd.DataFrame):
|
|
146
|
-
value = value.values.T
|
|
147
|
-
|
|
148
|
-
if value is not None:
|
|
149
|
-
value = try_(lambda: np.asarray(value, order='C'), None)
|
|
150
|
-
is_arraylike = bool(value is not None and value.shape)
|
|
151
|
-
|
|
152
|
-
# Optionally flip the array if the user returned e.g. `df.values`
|
|
153
|
-
if is_arraylike and np.argmax(value.shape) == 0:
|
|
154
|
-
value = value.T
|
|
155
|
-
|
|
156
|
-
if isinstance(name, list) and (np.atleast_2d(value).shape[0] != len(name)):
|
|
157
|
-
raise ValueError(
|
|
158
|
-
f'Length of `name=` ({len(name)}) must agree with the number '
|
|
159
|
-
f'of arrays the indicator returns ({value.shape[0]}).')
|
|
160
|
-
|
|
161
|
-
if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.Close):
|
|
162
|
-
raise ValueError(
|
|
163
|
-
'Indicators must return (optionally a tuple of) numpy.arrays of same '
|
|
164
|
-
f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}" '
|
|
165
|
-
f'shape: {getattr(value, "shape", "")}, returned value: {value})')
|
|
166
|
-
|
|
167
|
-
if overlay is None and np.issubdtype(value.dtype, np.number):
|
|
168
|
-
x = value / self._data.Close
|
|
169
|
-
# By default, overlay if strong majority of indicator values
|
|
170
|
-
# is within 30% of Close
|
|
171
|
-
with np.errstate(invalid='ignore'):
|
|
172
|
-
overlay = ((x < 1.4) & (x > .6)).mean() > .6
|
|
173
|
-
|
|
174
|
-
value = _Indicator(value, name=name, plot=plot, overlay=overlay,
|
|
175
|
-
color=color, scatter=scatter,
|
|
176
|
-
# _Indicator.s Series accessor uses this:
|
|
177
|
-
index=self.data.index)
|
|
178
|
-
self._indicators.append(value)
|
|
179
|
-
return value
|
|
180
|
-
|
|
181
|
-
@abstractmethod
|
|
182
|
-
def init(self):
|
|
183
|
-
"""
|
|
184
|
-
Initialize the strategy.
|
|
185
|
-
Override this method.
|
|
186
|
-
Declare indicators (with `backtesting.backtesting.Strategy.I`).
|
|
187
|
-
Precompute what needs to be precomputed or can be precomputed
|
|
188
|
-
in a vectorized fashion before the strategy starts.
|
|
189
|
-
|
|
190
|
-
If you extend composable strategies from `backtesting.lib`,
|
|
191
|
-
make sure to call:
|
|
192
|
-
|
|
193
|
-
super().init()
|
|
194
|
-
"""
|
|
195
|
-
|
|
196
|
-
@abstractmethod
|
|
197
|
-
def next(self):
|
|
198
|
-
"""
|
|
199
|
-
Main strategy runtime method, called as each new
|
|
200
|
-
`backtesting.backtesting.Strategy.data`
|
|
201
|
-
instance (row; full candlestick bar) becomes available.
|
|
202
|
-
This is the main method where strategy decisions
|
|
203
|
-
upon data precomputed in `backtesting.backtesting.Strategy.init`
|
|
204
|
-
take place.
|
|
205
|
-
|
|
206
|
-
If you extend composable strategies from `backtesting.lib`,
|
|
207
|
-
make sure to call:
|
|
208
|
-
|
|
209
|
-
super().next()
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
class __FULL_EQUITY(float): # noqa: N801
|
|
213
|
-
def __repr__(self): return '.9999' # noqa: E704
|
|
214
|
-
_FULL_EQUITY = __FULL_EQUITY(1 - sys.float_info.epsilon)
|
|
215
|
-
|
|
216
|
-
def buy(self, *,
|
|
217
|
-
size: float = _FULL_EQUITY,
|
|
218
|
-
limit: Optional[float] = None,
|
|
219
|
-
stop: Optional[float] = None,
|
|
220
|
-
sl: Optional[float] = None,
|
|
221
|
-
tp: Optional[float] = None,
|
|
222
|
-
tag: object = None) -> 'Order':
|
|
223
|
-
"""
|
|
224
|
-
Place a new long order and return it. For explanation of parameters, see `Order`
|
|
225
|
-
and its properties.
|
|
226
|
-
Unless you're running `Backtest(..., trade_on_close=True)`,
|
|
227
|
-
market orders are filled on next bar's open,
|
|
228
|
-
whereas other order types (limit, stop-limit, stop-market) are filled when
|
|
229
|
-
the respective conditions are met.
|
|
230
|
-
|
|
231
|
-
See `Position.close()` and `Trade.close()` for closing existing positions.
|
|
232
|
-
|
|
233
|
-
See also `Strategy.sell()`.
|
|
234
|
-
"""
|
|
235
|
-
assert 0 < size < 1 or round(size) == size >= 1, \
|
|
236
|
-
"size must be a positive fraction of equity, or a positive whole number of units"
|
|
237
|
-
return self._broker.new_order(size, limit, stop, sl, tp, tag)
|
|
238
|
-
|
|
239
|
-
def sell(self, *,
|
|
240
|
-
size: float = _FULL_EQUITY,
|
|
241
|
-
limit: Optional[float] = None,
|
|
242
|
-
stop: Optional[float] = None,
|
|
243
|
-
sl: Optional[float] = None,
|
|
244
|
-
tp: Optional[float] = None,
|
|
245
|
-
tag: object = None) -> 'Order':
|
|
246
|
-
"""
|
|
247
|
-
Place a new short order and return it. For explanation of parameters, see `Order`
|
|
248
|
-
and its properties.
|
|
249
|
-
|
|
250
|
-
.. caution::
|
|
251
|
-
Keep in mind that `self.sell(size=.1)` doesn't close existing `self.buy(size=.1)`
|
|
252
|
-
trade unless:
|
|
253
|
-
|
|
254
|
-
* the backtest was run with `exclusive_orders=True`,
|
|
255
|
-
* the underlying asset price is equal in both cases and
|
|
256
|
-
the backtest was run with `spread = commission = 0`.
|
|
257
|
-
|
|
258
|
-
Use `Trade.close()` or `Position.close()` to explicitly exit trades.
|
|
259
|
-
|
|
260
|
-
See also `Strategy.buy()`.
|
|
261
|
-
|
|
262
|
-
.. note::
|
|
263
|
-
If you merely want to close an existing long position,
|
|
264
|
-
use `Position.close()` or `Trade.close()`.
|
|
265
|
-
"""
|
|
266
|
-
assert 0 < size < 1 or round(size) == size >= 1, \
|
|
267
|
-
"size must be a positive fraction of equity, or a positive whole number of units"
|
|
268
|
-
return self._broker.new_order(-size, limit, stop, sl, tp, tag)
|
|
269
|
-
|
|
270
|
-
@property
|
|
271
|
-
def equity(self) -> float:
|
|
272
|
-
"""Current account equity (cash plus assets)."""
|
|
273
|
-
return self._broker.equity
|
|
274
|
-
|
|
275
|
-
@property
|
|
276
|
-
def data(self) -> _Data:
|
|
277
|
-
"""
|
|
278
|
-
Price data, roughly as passed into
|
|
279
|
-
`backtesting.backtesting.Backtest.__init__`,
|
|
280
|
-
but with two significant exceptions:
|
|
281
|
-
|
|
282
|
-
* `data` is _not_ a DataFrame, but a custom structure
|
|
283
|
-
that serves customized numpy arrays for reasons of performance
|
|
284
|
-
and convenience. Besides OHLCV columns, `.index` and length,
|
|
285
|
-
it offers `.pip` property, the smallest price unit of change.
|
|
286
|
-
* Within `backtesting.backtesting.Strategy.init`, `data` arrays
|
|
287
|
-
are available in full length, as passed into
|
|
288
|
-
`backtesting.backtesting.Backtest.__init__`
|
|
289
|
-
(for precomputing indicators and such). However, within
|
|
290
|
-
`backtesting.backtesting.Strategy.next`, `data` arrays are
|
|
291
|
-
only as long as the current iteration, simulating gradual
|
|
292
|
-
price point revelation. In each call of
|
|
293
|
-
`backtesting.backtesting.Strategy.next` (iteratively called by
|
|
294
|
-
`backtesting.backtesting.Backtest` internally),
|
|
295
|
-
the last array value (e.g. `data.Close[-1]`)
|
|
296
|
-
is always the _most recent_ value.
|
|
297
|
-
* If you need data arrays (e.g. `data.Close`) to be indexed
|
|
298
|
-
**Pandas series**, you can call their `.s` accessor
|
|
299
|
-
(e.g. `data.Close.s`). If you need the whole of data
|
|
300
|
-
as a **DataFrame**, use `.df` accessor (i.e. `data.df`).
|
|
301
|
-
"""
|
|
302
|
-
return self._data
|
|
303
|
-
|
|
304
|
-
@property
|
|
305
|
-
def position(self) -> 'Position':
|
|
306
|
-
"""Instance of `backtesting.backtesting.Position`."""
|
|
307
|
-
return self._broker.position
|
|
308
|
-
|
|
309
|
-
@property
|
|
310
|
-
def orders(self) -> 'Tuple[Order, ...]':
|
|
311
|
-
"""List of orders (see `Order`) waiting for execution."""
|
|
312
|
-
return _Orders(self._broker.orders)
|
|
313
|
-
|
|
314
|
-
@property
|
|
315
|
-
def trades(self) -> 'Tuple[Trade, ...]':
|
|
316
|
-
"""List of active trades (see `Trade`)."""
|
|
317
|
-
return tuple(self._broker.trades)
|
|
318
|
-
|
|
319
|
-
@property
|
|
320
|
-
def closed_trades(self) -> 'Tuple[Trade, ...]':
|
|
321
|
-
"""List of settled trades (see `Trade`)."""
|
|
322
|
-
return tuple(self._broker.closed_trades)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
class _Orders(tuple):
|
|
326
|
-
"""
|
|
327
|
-
TODO: remove this class. Only for deprecation.
|
|
328
|
-
"""
|
|
329
|
-
def cancel(self):
|
|
330
|
-
"""Cancel all non-contingent (i.e. SL/TP) orders."""
|
|
331
|
-
for order in self:
|
|
332
|
-
if not order.is_contingent:
|
|
333
|
-
order.cancel()
|
|
334
|
-
|
|
335
|
-
def __getattr__(self, item):
|
|
336
|
-
# TODO: Warn on deprecations from the previous version. Remove in the next.
|
|
337
|
-
removed_attrs = ('entry', 'set_entry', 'is_long', 'is_short',
|
|
338
|
-
'sl', 'tp', 'set_sl', 'set_tp')
|
|
339
|
-
if item in removed_attrs:
|
|
340
|
-
raise AttributeError(f'Strategy.orders.{"/.".join(removed_attrs)} were removed in'
|
|
341
|
-
'Backtesting 0.2.0. '
|
|
342
|
-
'Use `Order` API instead. See docs.')
|
|
343
|
-
raise AttributeError(f"'tuple' object has no attribute {item!r}")
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
class Position:
|
|
347
|
-
"""
|
|
348
|
-
Currently held asset position, available as
|
|
349
|
-
`backtesting.backtesting.Strategy.position` within
|
|
350
|
-
`backtesting.backtesting.Strategy.next`.
|
|
351
|
-
Can be used in boolean contexts, e.g.
|
|
352
|
-
|
|
353
|
-
if self.position:
|
|
354
|
-
... # we have a position, either long or short
|
|
355
|
-
"""
|
|
356
|
-
def __init__(self, broker: '_Broker'):
|
|
357
|
-
self.__broker = broker
|
|
358
|
-
|
|
359
|
-
def __bool__(self):
|
|
360
|
-
return self.size != 0
|
|
361
|
-
|
|
362
|
-
@property
|
|
363
|
-
def size(self) -> float:
|
|
364
|
-
"""Position size in units of asset. Negative if position is short."""
|
|
365
|
-
return sum(trade.size for trade in self.__broker.trades)
|
|
366
|
-
|
|
367
|
-
@property
|
|
368
|
-
def pl(self) -> float:
|
|
369
|
-
"""Profit (positive) or loss (negative) of the current position in cash units."""
|
|
370
|
-
return sum(trade.pl for trade in self.__broker.trades)
|
|
371
|
-
|
|
372
|
-
@property
|
|
373
|
-
def pl_pct(self) -> float:
|
|
374
|
-
"""Profit (positive) or loss (negative) of the current position in percent."""
|
|
375
|
-
total_invested = sum(trade.entry_price * abs(trade.size) for trade in self.__broker.trades)
|
|
376
|
-
return (self.pl / total_invested) * 100 if total_invested else 0
|
|
377
|
-
|
|
378
|
-
@property
|
|
379
|
-
def is_long(self) -> bool:
|
|
380
|
-
"""True if the position is long (position size is positive)."""
|
|
381
|
-
return self.size > 0
|
|
382
|
-
|
|
383
|
-
@property
|
|
384
|
-
def is_short(self) -> bool:
|
|
385
|
-
"""True if the position is short (position size is negative)."""
|
|
386
|
-
return self.size < 0
|
|
387
|
-
|
|
388
|
-
def close(self, portion: float = 1.):
|
|
389
|
-
"""
|
|
390
|
-
Close portion of position by closing `portion` of each active trade. See `Trade.close`.
|
|
391
|
-
"""
|
|
392
|
-
for trade in self.__broker.trades:
|
|
393
|
-
trade.close(portion)
|
|
394
|
-
|
|
395
|
-
def __repr__(self):
|
|
396
|
-
return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
class _OutOfMoneyError(Exception):
|
|
400
|
-
pass
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
class Order:
|
|
404
|
-
"""
|
|
405
|
-
Place new orders through `Strategy.buy()` and `Strategy.sell()`.
|
|
406
|
-
Query existing orders through `Strategy.orders`.
|
|
407
|
-
|
|
408
|
-
When an order is executed or [filled], it results in a `Trade`.
|
|
409
|
-
|
|
410
|
-
If you wish to modify aspects of a placed but not yet filled order,
|
|
411
|
-
cancel it and place a new one instead.
|
|
412
|
-
|
|
413
|
-
All placed orders are [Good 'Til Canceled].
|
|
414
|
-
|
|
415
|
-
[filled]: https://www.investopedia.com/terms/f/fill.asp
|
|
416
|
-
[Good 'Til Canceled]: https://www.investopedia.com/terms/g/gtc.asp
|
|
417
|
-
"""
|
|
418
|
-
def __init__(self, broker: '_Broker',
|
|
419
|
-
size: float,
|
|
420
|
-
limit_price: Optional[float] = None,
|
|
421
|
-
stop_price: Optional[float] = None,
|
|
422
|
-
sl_price: Optional[float] = None,
|
|
423
|
-
tp_price: Optional[float] = None,
|
|
424
|
-
parent_trade: Optional['Trade'] = None,
|
|
425
|
-
tag: object = None):
|
|
426
|
-
self.__broker = broker
|
|
427
|
-
assert size != 0
|
|
428
|
-
self.__size = size
|
|
429
|
-
self.__limit_price = limit_price
|
|
430
|
-
self.__stop_price = stop_price
|
|
431
|
-
self.__sl_price = sl_price
|
|
432
|
-
self.__tp_price = tp_price
|
|
433
|
-
self.__parent_trade = parent_trade
|
|
434
|
-
self.__tag = tag
|
|
435
|
-
|
|
436
|
-
def _replace(self, **kwargs):
|
|
437
|
-
for k, v in kwargs.items():
|
|
438
|
-
setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
|
|
439
|
-
return self
|
|
440
|
-
|
|
441
|
-
def __repr__(self):
|
|
442
|
-
return '<Order {}>'.format(', '.join(f'{param}={try_(lambda: round(value, 5), value)!r}'
|
|
443
|
-
for param, value in (
|
|
444
|
-
('size', self.__size),
|
|
445
|
-
('limit', self.__limit_price),
|
|
446
|
-
('stop', self.__stop_price),
|
|
447
|
-
('sl', self.__sl_price),
|
|
448
|
-
('tp', self.__tp_price),
|
|
449
|
-
('contingent', self.is_contingent),
|
|
450
|
-
('tag', self.__tag),
|
|
451
|
-
) if value is not None)) # noqa: E126
|
|
452
|
-
|
|
453
|
-
def cancel(self):
|
|
454
|
-
"""Cancel the order."""
|
|
455
|
-
self.__broker.orders.remove(self)
|
|
456
|
-
trade = self.__parent_trade
|
|
457
|
-
if trade:
|
|
458
|
-
if self is trade._sl_order:
|
|
459
|
-
trade._replace(sl_order=None)
|
|
460
|
-
elif self is trade._tp_order:
|
|
461
|
-
trade._replace(tp_order=None)
|
|
462
|
-
else:
|
|
463
|
-
pass # Order placed by Trade.close()
|
|
464
|
-
|
|
465
|
-
# Fields getters
|
|
466
|
-
|
|
467
|
-
@property
|
|
468
|
-
def size(self) -> float:
|
|
469
|
-
"""
|
|
470
|
-
Order size (negative for short orders).
|
|
471
|
-
|
|
472
|
-
If size is a value between 0 and 1, it is interpreted as a fraction of current
|
|
473
|
-
available liquidity (cash plus `Position.pl` minus used margin).
|
|
474
|
-
A value greater than or equal to 1 indicates an absolute number of units.
|
|
475
|
-
"""
|
|
476
|
-
return self.__size
|
|
477
|
-
|
|
478
|
-
@property
|
|
479
|
-
def limit(self) -> Optional[float]:
|
|
480
|
-
"""
|
|
481
|
-
Order limit price for [limit orders], or None for [market orders],
|
|
482
|
-
which are filled at next available price.
|
|
483
|
-
|
|
484
|
-
[limit orders]: https://www.investopedia.com/terms/l/limitorder.asp
|
|
485
|
-
[market orders]: https://www.investopedia.com/terms/m/marketorder.asp
|
|
486
|
-
"""
|
|
487
|
-
return self.__limit_price
|
|
488
|
-
|
|
489
|
-
@property
|
|
490
|
-
def stop(self) -> Optional[float]:
|
|
491
|
-
"""
|
|
492
|
-
Order stop price for [stop-limit/stop-market][_] order,
|
|
493
|
-
otherwise None if no stop was set, or the stop price has already been hit.
|
|
494
|
-
|
|
495
|
-
[_]: https://www.investopedia.com/terms/s/stoporder.asp
|
|
496
|
-
"""
|
|
497
|
-
return self.__stop_price
|
|
498
|
-
|
|
499
|
-
@property
|
|
500
|
-
def sl(self) -> Optional[float]:
|
|
501
|
-
"""
|
|
502
|
-
A stop-loss price at which, if set, a new contingent stop-market order
|
|
503
|
-
will be placed upon the `Trade` following this order's execution.
|
|
504
|
-
See also `Trade.sl`.
|
|
505
|
-
"""
|
|
506
|
-
return self.__sl_price
|
|
507
|
-
|
|
508
|
-
@property
|
|
509
|
-
def tp(self) -> Optional[float]:
|
|
510
|
-
"""
|
|
511
|
-
A take-profit price at which, if set, a new contingent limit order
|
|
512
|
-
will be placed upon the `Trade` following this order's execution.
|
|
513
|
-
See also `Trade.tp`.
|
|
514
|
-
"""
|
|
515
|
-
return self.__tp_price
|
|
516
|
-
|
|
517
|
-
@property
|
|
518
|
-
def parent_trade(self):
|
|
519
|
-
return self.__parent_trade
|
|
520
|
-
|
|
521
|
-
@property
|
|
522
|
-
def tag(self):
|
|
523
|
-
"""
|
|
524
|
-
Arbitrary value (such as a string) which, if set, enables tracking
|
|
525
|
-
of this order and the associated `Trade` (see `Trade.tag`).
|
|
526
|
-
"""
|
|
527
|
-
return self.__tag
|
|
528
|
-
|
|
529
|
-
__pdoc__['Order.parent_trade'] = False
|
|
530
|
-
|
|
531
|
-
# Extra properties
|
|
532
|
-
|
|
533
|
-
@property
|
|
534
|
-
def is_long(self):
|
|
535
|
-
"""True if the order is long (order size is positive)."""
|
|
536
|
-
return self.__size > 0
|
|
537
|
-
|
|
538
|
-
@property
|
|
539
|
-
def is_short(self):
|
|
540
|
-
"""True if the order is short (order size is negative)."""
|
|
541
|
-
return self.__size < 0
|
|
542
|
-
|
|
543
|
-
@property
|
|
544
|
-
def is_contingent(self):
|
|
545
|
-
"""
|
|
546
|
-
True for [contingent] orders, i.e. [OCO] stop-loss and take-profit bracket orders
|
|
547
|
-
placed upon an active trade. Remaining contingent orders are canceled when
|
|
548
|
-
their parent `Trade` is closed.
|
|
549
|
-
|
|
550
|
-
You can modify contingent orders through `Trade.sl` and `Trade.tp`.
|
|
551
|
-
|
|
552
|
-
[contingent]: https://www.investopedia.com/terms/c/contingentorder.asp
|
|
553
|
-
[OCO]: https://www.investopedia.com/terms/o/oco.asp
|
|
554
|
-
"""
|
|
555
|
-
return bool((parent := self.__parent_trade) and
|
|
556
|
-
(self is parent._sl_order or
|
|
557
|
-
self is parent._tp_order))
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
class Trade:
|
|
561
|
-
"""
|
|
562
|
-
When an `Order` is filled, it results in an active `Trade`.
|
|
563
|
-
Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
|
|
564
|
-
"""
|
|
565
|
-
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
|
|
566
|
-
self.__broker = broker
|
|
567
|
-
self.__size = size
|
|
568
|
-
self.__entry_price = entry_price
|
|
569
|
-
self.__exit_price: Optional[float] = None
|
|
570
|
-
self.__entry_bar: int = entry_bar
|
|
571
|
-
self.__exit_bar: Optional[int] = None
|
|
572
|
-
self.__sl_order: Optional[Order] = None
|
|
573
|
-
self.__tp_order: Optional[Order] = None
|
|
574
|
-
self.__tag = tag
|
|
575
|
-
self._commissions = 0
|
|
576
|
-
|
|
577
|
-
def __repr__(self):
|
|
578
|
-
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
|
|
579
|
-
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
|
|
580
|
-
f'{" tag=" + str(self.__tag) if self.__tag is not None else ""}>'
|
|
581
|
-
|
|
582
|
-
def _replace(self, **kwargs):
|
|
583
|
-
for k, v in kwargs.items():
|
|
584
|
-
setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
|
|
585
|
-
return self
|
|
586
|
-
|
|
587
|
-
def _copy(self, **kwargs):
|
|
588
|
-
return copy(self)._replace(**kwargs)
|
|
589
|
-
|
|
590
|
-
def close(self, portion: float = 1.):
|
|
591
|
-
"""Place new `Order` to close `portion` of the trade at next market price."""
|
|
592
|
-
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
|
|
593
|
-
# Ensure size is an int to avoid rounding errors on 32-bit OS
|
|
594
|
-
size = copysign(max(1, int(round(abs(self.__size) * portion))), -self.__size)
|
|
595
|
-
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
|
|
596
|
-
self.__broker.orders.insert(0, order)
|
|
597
|
-
|
|
598
|
-
# Fields getters
|
|
599
|
-
|
|
600
|
-
@property
|
|
601
|
-
def size(self):
|
|
602
|
-
"""Trade size (volume; negative for short trades)."""
|
|
603
|
-
return self.__size
|
|
604
|
-
|
|
605
|
-
@property
|
|
606
|
-
def entry_price(self) -> float:
|
|
607
|
-
"""Trade entry price."""
|
|
608
|
-
return self.__entry_price
|
|
609
|
-
|
|
610
|
-
@property
|
|
611
|
-
def exit_price(self) -> Optional[float]:
|
|
612
|
-
"""Trade exit price (or None if the trade is still active)."""
|
|
613
|
-
return self.__exit_price
|
|
614
|
-
|
|
615
|
-
@property
|
|
616
|
-
def entry_bar(self) -> int:
|
|
617
|
-
"""Candlestick bar index of when the trade was entered."""
|
|
618
|
-
return self.__entry_bar
|
|
619
|
-
|
|
620
|
-
@property
|
|
621
|
-
def exit_bar(self) -> Optional[int]:
|
|
622
|
-
"""
|
|
623
|
-
Candlestick bar index of when the trade was exited
|
|
624
|
-
(or None if the trade is still active).
|
|
625
|
-
"""
|
|
626
|
-
return self.__exit_bar
|
|
627
|
-
|
|
628
|
-
@property
|
|
629
|
-
def tag(self):
|
|
630
|
-
"""
|
|
631
|
-
A tag value inherited from the `Order` that opened
|
|
632
|
-
this trade.
|
|
633
|
-
|
|
634
|
-
This can be used to track trades and apply conditional
|
|
635
|
-
logic / subgroup analysis.
|
|
636
|
-
|
|
637
|
-
See also `Order.tag`.
|
|
638
|
-
"""
|
|
639
|
-
return self.__tag
|
|
640
|
-
|
|
641
|
-
@property
|
|
642
|
-
def _sl_order(self):
|
|
643
|
-
return self.__sl_order
|
|
644
|
-
|
|
645
|
-
@property
|
|
646
|
-
def _tp_order(self):
|
|
647
|
-
return self.__tp_order
|
|
648
|
-
|
|
649
|
-
# Extra properties
|
|
650
|
-
|
|
651
|
-
@property
|
|
652
|
-
def entry_time(self) -> Union[pd.Timestamp, int]:
|
|
653
|
-
"""Datetime of when the trade was entered."""
|
|
654
|
-
return self.__broker._data.index[self.__entry_bar]
|
|
655
|
-
|
|
656
|
-
@property
|
|
657
|
-
def exit_time(self) -> Optional[Union[pd.Timestamp, int]]:
|
|
658
|
-
"""Datetime of when the trade was exited."""
|
|
659
|
-
if self.__exit_bar is None:
|
|
660
|
-
return None
|
|
661
|
-
return self.__broker._data.index[self.__exit_bar]
|
|
662
|
-
|
|
663
|
-
@property
|
|
664
|
-
def is_long(self):
|
|
665
|
-
"""True if the trade is long (trade size is positive)."""
|
|
666
|
-
return self.__size > 0
|
|
667
|
-
|
|
668
|
-
@property
|
|
669
|
-
def is_short(self):
|
|
670
|
-
"""True if the trade is short (trade size is negative)."""
|
|
671
|
-
return not self.is_long
|
|
672
|
-
|
|
673
|
-
@property
|
|
674
|
-
def pl(self):
|
|
675
|
-
"""
|
|
676
|
-
Trade profit (positive) or loss (negative) in cash units.
|
|
677
|
-
Commissions are reflected only after the Trade is closed.
|
|
678
|
-
"""
|
|
679
|
-
price = self.__exit_price or self.__broker.last_price
|
|
680
|
-
return (self.__size * (price - self.__entry_price)) - self._commissions
|
|
681
|
-
|
|
682
|
-
@property
|
|
683
|
-
def pl_pct(self):
|
|
684
|
-
"""Trade profit (positive) or loss (negative) in percent."""
|
|
685
|
-
price = self.__exit_price or self.__broker.last_price
|
|
686
|
-
gross_pl_pct = copysign(1, self.__size) * (price / self.__entry_price - 1)
|
|
687
|
-
|
|
688
|
-
# Total commission across the entire trade size to individual units
|
|
689
|
-
commission_pct = self._commissions / (abs(self.__size) * self.__entry_price)
|
|
690
|
-
return gross_pl_pct - commission_pct
|
|
691
|
-
|
|
692
|
-
@property
|
|
693
|
-
def value(self):
|
|
694
|
-
"""Trade total value in cash (volume × price)."""
|
|
695
|
-
price = self.__exit_price or self.__broker.last_price
|
|
696
|
-
return abs(self.__size) * price
|
|
697
|
-
|
|
698
|
-
# SL/TP management API
|
|
699
|
-
|
|
700
|
-
@property
|
|
701
|
-
def sl(self):
|
|
702
|
-
"""
|
|
703
|
-
Stop-loss price at which to close the trade.
|
|
704
|
-
|
|
705
|
-
This variable is writable. By assigning it a new price value,
|
|
706
|
-
you create or modify the existing SL order.
|
|
707
|
-
By assigning it `None`, you cancel it.
|
|
708
|
-
"""
|
|
709
|
-
return self.__sl_order and self.__sl_order.stop
|
|
710
|
-
|
|
711
|
-
@sl.setter
|
|
712
|
-
def sl(self, price: float):
|
|
713
|
-
self.__set_contingent('sl', price)
|
|
714
|
-
|
|
715
|
-
@property
|
|
716
|
-
def tp(self):
|
|
717
|
-
"""
|
|
718
|
-
Take-profit price at which to close the trade.
|
|
719
|
-
|
|
720
|
-
This property is writable. By assigning it a new price value,
|
|
721
|
-
you create or modify the existing TP order.
|
|
722
|
-
By assigning it `None`, you cancel it.
|
|
723
|
-
"""
|
|
724
|
-
return self.__tp_order and self.__tp_order.limit
|
|
725
|
-
|
|
726
|
-
@tp.setter
|
|
727
|
-
def tp(self, price: float):
|
|
728
|
-
self.__set_contingent('tp', price)
|
|
729
|
-
|
|
730
|
-
def __set_contingent(self, type, price):
|
|
731
|
-
assert type in ('sl', 'tp')
|
|
732
|
-
assert price is None or 0 < price < np.inf, f'Make sure 0 < price < inf! price: {price}'
|
|
733
|
-
attr = f'_{self.__class__.__qualname__}__{type}_order'
|
|
734
|
-
order: Order = getattr(self, attr)
|
|
735
|
-
if order:
|
|
736
|
-
order.cancel()
|
|
737
|
-
if price:
|
|
738
|
-
kwargs = {'stop': price} if type == 'sl' else {'limit': price}
|
|
739
|
-
order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
|
|
740
|
-
setattr(self, attr, order)
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
class _Broker:
|
|
744
|
-
def __init__(self, *, data, cash, spread, commission, margin,
|
|
745
|
-
trade_on_close, hedging, exclusive_orders, index):
|
|
746
|
-
assert cash > 0, f"cash should be > 0, is {cash}"
|
|
747
|
-
assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
|
|
748
|
-
self._data: _Data = data
|
|
749
|
-
self._cash = cash
|
|
750
|
-
|
|
751
|
-
if callable(commission):
|
|
752
|
-
self._commission = commission
|
|
753
|
-
else:
|
|
754
|
-
try:
|
|
755
|
-
self._commission_fixed, self._commission_relative = commission
|
|
756
|
-
except TypeError:
|
|
757
|
-
self._commission_fixed, self._commission_relative = 0, commission
|
|
758
|
-
assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0'
|
|
759
|
-
assert -.1 <= self._commission_relative < .1, \
|
|
760
|
-
("commission should be between -10% "
|
|
761
|
-
f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}")
|
|
762
|
-
self._commission = self._commission_func
|
|
763
|
-
|
|
764
|
-
self._spread = spread
|
|
765
|
-
self._leverage = 1 / margin
|
|
766
|
-
self._trade_on_close = trade_on_close
|
|
767
|
-
self._hedging = hedging
|
|
768
|
-
self._exclusive_orders = exclusive_orders
|
|
769
|
-
|
|
770
|
-
self._equity = np.tile(np.nan, len(index))
|
|
771
|
-
self.orders: List[Order] = []
|
|
772
|
-
self.trades: List[Trade] = []
|
|
773
|
-
self.position = Position(self)
|
|
774
|
-
self.closed_trades: List[Trade] = []
|
|
775
|
-
|
|
776
|
-
def _commission_func(self, order_size, price):
|
|
777
|
-
return self._commission_fixed + abs(order_size) * price * self._commission_relative
|
|
778
|
-
|
|
779
|
-
def __repr__(self):
|
|
780
|
-
return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'
|
|
781
|
-
|
|
782
|
-
def new_order(self,
|
|
783
|
-
size: float,
|
|
784
|
-
limit: Optional[float] = None,
|
|
785
|
-
stop: Optional[float] = None,
|
|
786
|
-
sl: Optional[float] = None,
|
|
787
|
-
tp: Optional[float] = None,
|
|
788
|
-
tag: object = None,
|
|
789
|
-
*,
|
|
790
|
-
trade: Optional[Trade] = None) -> Order:
|
|
791
|
-
"""
|
|
792
|
-
Argument size indicates whether the order is long or short
|
|
793
|
-
"""
|
|
794
|
-
size = float(size)
|
|
795
|
-
stop = stop and float(stop)
|
|
796
|
-
limit = limit and float(limit)
|
|
797
|
-
sl = sl and float(sl)
|
|
798
|
-
tp = tp and float(tp)
|
|
799
|
-
|
|
800
|
-
is_long = size > 0
|
|
801
|
-
assert size != 0, size
|
|
802
|
-
adjusted_price = self._adjusted_price(size)
|
|
803
|
-
|
|
804
|
-
if is_long:
|
|
805
|
-
if not (sl or -np.inf) < (limit or stop or adjusted_price) < (tp or np.inf):
|
|
806
|
-
raise ValueError(
|
|
807
|
-
"Long orders require: "
|
|
808
|
-
f"SL ({sl}) < LIMIT ({limit or stop or adjusted_price}) < TP ({tp})")
|
|
809
|
-
else:
|
|
810
|
-
if not (tp or -np.inf) < (limit or stop or adjusted_price) < (sl or np.inf):
|
|
811
|
-
raise ValueError(
|
|
812
|
-
"Short orders require: "
|
|
813
|
-
f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
|
|
814
|
-
|
|
815
|
-
order = Order(self, size, limit, stop, sl, tp, trade, tag)
|
|
816
|
-
|
|
817
|
-
if not trade:
|
|
818
|
-
# If exclusive orders (each new order auto-closes previous orders/position),
|
|
819
|
-
# cancel all non-contingent orders and close all open trades beforehand
|
|
820
|
-
if self._exclusive_orders:
|
|
821
|
-
for o in self.orders:
|
|
822
|
-
if not o.is_contingent:
|
|
823
|
-
o.cancel()
|
|
824
|
-
for t in self.trades:
|
|
825
|
-
t.close()
|
|
826
|
-
|
|
827
|
-
# Put the new order in the order queue, Ensure SL orders are processed first
|
|
828
|
-
self.orders.insert(0 if trade and stop else len(self.orders), order)
|
|
829
|
-
|
|
830
|
-
return order
|
|
831
|
-
|
|
832
|
-
@property
|
|
833
|
-
def last_price(self) -> float:
|
|
834
|
-
""" Price at the last (current) close. """
|
|
835
|
-
return self._data.Close[-1]
|
|
836
|
-
|
|
837
|
-
def _adjusted_price(self, size=None, price=None) -> float:
|
|
838
|
-
"""
|
|
839
|
-
Long/short `price`, adjusted for spread.
|
|
840
|
-
In long positions, the adjusted price is a fraction higher, and vice versa.
|
|
841
|
-
"""
|
|
842
|
-
return (price or self.last_price) * (1 + copysign(self._spread, size))
|
|
843
|
-
|
|
844
|
-
@property
|
|
845
|
-
def equity(self) -> float:
|
|
846
|
-
return self._cash + sum(trade.pl for trade in self.trades)
|
|
847
|
-
|
|
848
|
-
@property
|
|
849
|
-
def margin_available(self) -> float:
|
|
850
|
-
# From https://github.com/QuantConnect/Lean/pull/3768
|
|
851
|
-
margin_used = sum(trade.value / self._leverage for trade in self.trades)
|
|
852
|
-
return max(0, self.equity - margin_used)
|
|
853
|
-
|
|
854
|
-
def next(self):
|
|
855
|
-
i = self._i = len(self._data) - 1
|
|
856
|
-
self._process_orders()
|
|
857
|
-
|
|
858
|
-
# Log account equity for the equity curve
|
|
859
|
-
equity = self.equity
|
|
860
|
-
self._equity[i] = equity
|
|
861
|
-
|
|
862
|
-
# If equity is negative, set all to 0 and stop the simulation
|
|
863
|
-
if equity <= 0:
|
|
864
|
-
assert self.margin_available <= 0
|
|
865
|
-
for trade in self.trades:
|
|
866
|
-
self._close_trade(trade, self._data.Close[-1], i)
|
|
867
|
-
self._cash = 0
|
|
868
|
-
self._equity[i:] = 0
|
|
869
|
-
raise _OutOfMoneyError
|
|
870
|
-
|
|
871
|
-
def _process_orders(self):
|
|
872
|
-
data = self._data
|
|
873
|
-
open, high, low = data.Open[-1], data.High[-1], data.Low[-1]
|
|
874
|
-
reprocess_orders = False
|
|
875
|
-
|
|
876
|
-
# Process orders
|
|
877
|
-
for order in list(self.orders): # type: Order
|
|
878
|
-
|
|
879
|
-
# Related SL/TP order was already removed
|
|
880
|
-
if order not in self.orders:
|
|
881
|
-
continue
|
|
882
|
-
|
|
883
|
-
# Check if stop condition was hit
|
|
884
|
-
stop_price = order.stop
|
|
885
|
-
if stop_price:
|
|
886
|
-
is_stop_hit = ((high >= stop_price) if order.is_long else (low <= stop_price))
|
|
887
|
-
if not is_stop_hit:
|
|
888
|
-
continue
|
|
889
|
-
|
|
890
|
-
# > When the stop price is reached, a stop order becomes a market/limit order.
|
|
891
|
-
# https://www.sec.gov/fast-answers/answersstopordhtm.html
|
|
892
|
-
order._replace(stop_price=None)
|
|
893
|
-
|
|
894
|
-
# Determine purchase price.
|
|
895
|
-
# Check if limit order can be filled.
|
|
896
|
-
if order.limit:
|
|
897
|
-
is_limit_hit = low <= order.limit if order.is_long else high >= order.limit
|
|
898
|
-
# When stop and limit are hit within the same bar, we pessimistically
|
|
899
|
-
# assume limit was hit before the stop (i.e. "before it counts")
|
|
900
|
-
is_limit_hit_before_stop = (is_limit_hit and
|
|
901
|
-
(order.limit <= (stop_price or -np.inf)
|
|
902
|
-
if order.is_long
|
|
903
|
-
else order.limit >= (stop_price or np.inf)))
|
|
904
|
-
if not is_limit_hit or is_limit_hit_before_stop:
|
|
905
|
-
continue
|
|
906
|
-
|
|
907
|
-
# stop_price, if set, was hit within this bar
|
|
908
|
-
price = (min(stop_price or open, order.limit)
|
|
909
|
-
if order.is_long else
|
|
910
|
-
max(stop_price or open, order.limit))
|
|
911
|
-
else:
|
|
912
|
-
# Market-if-touched / market order
|
|
913
|
-
# Contingent orders always on next open
|
|
914
|
-
prev_close = data.Close[-2]
|
|
915
|
-
price = prev_close if self._trade_on_close and not order.is_contingent else open
|
|
916
|
-
if stop_price:
|
|
917
|
-
price = max(price, stop_price) if order.is_long else min(price, stop_price)
|
|
918
|
-
|
|
919
|
-
# Determine entry/exit bar index
|
|
920
|
-
is_market_order = not order.limit and not stop_price
|
|
921
|
-
time_index = (
|
|
922
|
-
(self._i - 1)
|
|
923
|
-
if is_market_order and self._trade_on_close and not order.is_contingent else
|
|
924
|
-
self._i)
|
|
925
|
-
|
|
926
|
-
# If order is a SL/TP order, it should close an existing trade it was contingent upon
|
|
927
|
-
if order.parent_trade:
|
|
928
|
-
trade = order.parent_trade
|
|
929
|
-
_prev_size = trade.size
|
|
930
|
-
# If order.size is "greater" than trade.size, this order is a trade.close()
|
|
931
|
-
# order and part of the trade was already closed beforehand
|
|
932
|
-
size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
|
|
933
|
-
# If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
|
|
934
|
-
if trade in self.trades:
|
|
935
|
-
self._reduce_trade(trade, price, size, time_index)
|
|
936
|
-
assert order.size != -_prev_size or trade not in self.trades
|
|
937
|
-
if price == stop_price:
|
|
938
|
-
# Set SL back on the order for stats._trades["SL"]
|
|
939
|
-
trade._sl_order._replace(stop_price=stop_price)
|
|
940
|
-
if order in (trade._sl_order,
|
|
941
|
-
trade._tp_order):
|
|
942
|
-
assert order.size == -trade.size
|
|
943
|
-
assert order not in self.orders # Removed when trade was closed
|
|
944
|
-
else:
|
|
945
|
-
# It's a trade.close() order, now done
|
|
946
|
-
assert abs(_prev_size) >= abs(size) >= 1
|
|
947
|
-
self.orders.remove(order)
|
|
948
|
-
continue
|
|
949
|
-
|
|
950
|
-
# Else this is a stand-alone trade
|
|
951
|
-
|
|
952
|
-
# Adjust price to include commission (or bid-ask spread).
|
|
953
|
-
# In long positions, the adjusted price is a fraction higher, and vice versa.
|
|
954
|
-
adjusted_price = self._adjusted_price(order.size, price)
|
|
955
|
-
adjusted_price_plus_commission = \
|
|
956
|
-
adjusted_price + self._commission(order.size, price) / abs(order.size)
|
|
957
|
-
|
|
958
|
-
# If order size was specified proportionally,
|
|
959
|
-
# precompute true size in units, accounting for margin and spread/commissions
|
|
960
|
-
size = order.size
|
|
961
|
-
if -1 < size < 1:
|
|
962
|
-
size = copysign(int((self.margin_available * self._leverage * abs(size))
|
|
963
|
-
// adjusted_price_plus_commission), size)
|
|
964
|
-
# Not enough cash/margin even for a single unit
|
|
965
|
-
if not size:
|
|
966
|
-
warnings.warn(
|
|
967
|
-
f'time={self._i}: Broker canceled the relative-sized '
|
|
968
|
-
f'order due to insufficient margin.', category=UserWarning)
|
|
969
|
-
# XXX: The order is canceled by the broker?
|
|
970
|
-
self.orders.remove(order)
|
|
971
|
-
continue
|
|
972
|
-
assert size == round(size)
|
|
973
|
-
need_size = int(size)
|
|
974
|
-
|
|
975
|
-
if not self._hedging:
|
|
976
|
-
# Fill position by FIFO closing/reducing existing opposite-facing trades.
|
|
977
|
-
# Existing trades are closed at unadjusted price, because the adjustment
|
|
978
|
-
# was already made when buying.
|
|
979
|
-
for trade in list(self.trades):
|
|
980
|
-
if trade.is_long == order.is_long:
|
|
981
|
-
continue
|
|
982
|
-
assert trade.size * order.size < 0
|
|
983
|
-
|
|
984
|
-
# Order size greater than this opposite-directed existing trade,
|
|
985
|
-
# so it will be closed completely
|
|
986
|
-
if abs(need_size) >= abs(trade.size):
|
|
987
|
-
self._close_trade(trade, price, time_index)
|
|
988
|
-
need_size += trade.size
|
|
989
|
-
else:
|
|
990
|
-
# The existing trade is larger than the new order,
|
|
991
|
-
# so it will only be closed partially
|
|
992
|
-
self._reduce_trade(trade, price, need_size, time_index)
|
|
993
|
-
need_size = 0
|
|
994
|
-
|
|
995
|
-
if not need_size:
|
|
996
|
-
break
|
|
997
|
-
|
|
998
|
-
# If we don't have enough liquidity to cover for the order, the broker CANCELS it
|
|
999
|
-
if abs(need_size) * adjusted_price_plus_commission > \
|
|
1000
|
-
self.margin_available * self._leverage:
|
|
1001
|
-
self.orders.remove(order)
|
|
1002
|
-
continue
|
|
1003
|
-
|
|
1004
|
-
# Open a new trade
|
|
1005
|
-
if need_size:
|
|
1006
|
-
self._open_trade(adjusted_price,
|
|
1007
|
-
need_size,
|
|
1008
|
-
order.sl,
|
|
1009
|
-
order.tp,
|
|
1010
|
-
time_index,
|
|
1011
|
-
order.tag)
|
|
1012
|
-
|
|
1013
|
-
# We need to reprocess the SL/TP orders newly added to the queue.
|
|
1014
|
-
# This allows e.g. SL hitting in the same bar the order was open.
|
|
1015
|
-
# See https://github.com/kernc/backtesting.py/issues/119
|
|
1016
|
-
if order.sl or order.tp:
|
|
1017
|
-
if is_market_order:
|
|
1018
|
-
reprocess_orders = True
|
|
1019
|
-
# Order.stop and TP hit within the same bar, but SL wasn't. This case
|
|
1020
|
-
# is not ambiguous, because stop and TP go in the same price direction.
|
|
1021
|
-
elif stop_price and not order.limit and order.tp and (
|
|
1022
|
-
(order.is_long and order.tp <= high and (order.sl or -np.inf) < low) or
|
|
1023
|
-
(order.is_short and order.tp >= low and (order.sl or np.inf) > high)):
|
|
1024
|
-
reprocess_orders = True
|
|
1025
|
-
elif (low <= (order.sl or -np.inf) <= high or
|
|
1026
|
-
low <= (order.tp or -np.inf) <= high):
|
|
1027
|
-
warnings.warn(
|
|
1028
|
-
f"({data.index[-1]}) A contingent SL/TP order would execute in the "
|
|
1029
|
-
"same bar its parent stop/limit order was turned into a trade. "
|
|
1030
|
-
"Since we can't assert the precise intra-candle "
|
|
1031
|
-
"price movement, the affected SL/TP order will instead be executed on "
|
|
1032
|
-
"the next (matching) price/bar, making the result (of this trade) "
|
|
1033
|
-
"somewhat dubious. "
|
|
1034
|
-
"See https://github.com/kernc/backtesting.py/issues/119",
|
|
1035
|
-
UserWarning)
|
|
1036
|
-
|
|
1037
|
-
# Order processed
|
|
1038
|
-
self.orders.remove(order)
|
|
1039
|
-
|
|
1040
|
-
if reprocess_orders:
|
|
1041
|
-
self._process_orders()
|
|
1042
|
-
|
|
1043
|
-
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
|
|
1044
|
-
assert trade.size * size < 0
|
|
1045
|
-
assert abs(trade.size) >= abs(size)
|
|
1046
|
-
|
|
1047
|
-
size_left = trade.size + size
|
|
1048
|
-
assert size_left * trade.size >= 0
|
|
1049
|
-
if not size_left:
|
|
1050
|
-
close_trade = trade
|
|
1051
|
-
else:
|
|
1052
|
-
# Reduce existing trade ...
|
|
1053
|
-
trade._replace(size=size_left)
|
|
1054
|
-
if trade._sl_order:
|
|
1055
|
-
trade._sl_order._replace(size=-trade.size)
|
|
1056
|
-
if trade._tp_order:
|
|
1057
|
-
trade._tp_order._replace(size=-trade.size)
|
|
1058
|
-
|
|
1059
|
-
# ... by closing a reduced copy of it
|
|
1060
|
-
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
|
|
1061
|
-
self.trades.append(close_trade)
|
|
1062
|
-
|
|
1063
|
-
self._close_trade(close_trade, price, time_index)
|
|
1064
|
-
|
|
1065
|
-
def _close_trade(self, trade: Trade, price: float, time_index: int):
|
|
1066
|
-
self.trades.remove(trade)
|
|
1067
|
-
if trade._sl_order:
|
|
1068
|
-
self.orders.remove(trade._sl_order)
|
|
1069
|
-
if trade._tp_order:
|
|
1070
|
-
self.orders.remove(trade._tp_order)
|
|
1071
|
-
|
|
1072
|
-
closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
|
|
1073
|
-
self.closed_trades.append(closed_trade)
|
|
1074
|
-
# Apply commission one more time at trade exit
|
|
1075
|
-
commission = self._commission(trade.size, price)
|
|
1076
|
-
self._cash += trade.pl - commission
|
|
1077
|
-
# Save commissions on Trade instance for stats
|
|
1078
|
-
trade_open_commission = self._commission(closed_trade.size, closed_trade.entry_price)
|
|
1079
|
-
# applied here instead of on Trade open because size could have changed
|
|
1080
|
-
# by way of _reduce_trade()
|
|
1081
|
-
closed_trade._commissions = commission + trade_open_commission
|
|
1082
|
-
|
|
1083
|
-
def _open_trade(self, price: float, size: int,
|
|
1084
|
-
sl: Optional[float], tp: Optional[float], time_index: int, tag):
|
|
1085
|
-
trade = Trade(self, size, price, time_index, tag)
|
|
1086
|
-
self.trades.append(trade)
|
|
1087
|
-
# Apply broker commission at trade open
|
|
1088
|
-
self._cash -= self._commission(size, price)
|
|
1089
|
-
# Create SL/TP (bracket) orders.
|
|
1090
|
-
if tp:
|
|
1091
|
-
trade.tp = tp
|
|
1092
|
-
if sl:
|
|
1093
|
-
trade.sl = sl
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
class Backtest:
|
|
1097
|
-
"""
|
|
1098
|
-
Backtest a particular (parameterized) strategy
|
|
1099
|
-
on particular data.
|
|
1100
|
-
|
|
1101
|
-
Initialize a backtest. Requires data and a strategy to test.
|
|
1102
|
-
After initialization, you can call method
|
|
1103
|
-
`backtesting.backtesting.Backtest.run` to run a backtest
|
|
1104
|
-
instance, or `backtesting.backtesting.Backtest.optimize` to
|
|
1105
|
-
optimize it.
|
|
1106
|
-
|
|
1107
|
-
`data` is a `pd.DataFrame` with columns:
|
|
1108
|
-
`Open`, `High`, `Low`, `Close`, and (optionally) `Volume`.
|
|
1109
|
-
If any columns are missing, set them to what you have available,
|
|
1110
|
-
e.g.
|
|
1111
|
-
|
|
1112
|
-
df['Open'] = df['High'] = df['Low'] = df['Close']
|
|
1113
|
-
|
|
1114
|
-
The passed data frame can contain additional columns that
|
|
1115
|
-
can be used by the strategy (e.g. sentiment info).
|
|
1116
|
-
DataFrame index can be either a datetime index (timestamps)
|
|
1117
|
-
or a monotonic range index (i.e. a sequence of periods).
|
|
1118
|
-
|
|
1119
|
-
`strategy` is a `backtesting.backtesting.Strategy`
|
|
1120
|
-
_subclass_ (not an instance).
|
|
1121
|
-
|
|
1122
|
-
`cash` is the initial cash to start with.
|
|
1123
|
-
|
|
1124
|
-
`spread` is the the constant bid-ask spread rate (relative to the price).
|
|
1125
|
-
E.g. set it to `0.0002` for commission-less forex
|
|
1126
|
-
trading where the average spread is roughly 0.2‰ of the asking price.
|
|
1127
|
-
|
|
1128
|
-
`commission` is the commission rate. E.g. if your broker's commission
|
|
1129
|
-
is 1% of order value, set commission to `0.01`.
|
|
1130
|
-
The commission is applied twice: at trade entry and at trade exit.
|
|
1131
|
-
Besides one single floating value, `commission` can also be a tuple of floating
|
|
1132
|
-
values `(fixed, relative)`. E.g. set it to `(100, .01)`
|
|
1133
|
-
if your broker charges minimum $100 + 1%.
|
|
1134
|
-
Additionally, `commission` can be a callable
|
|
1135
|
-
`func(order_size: int, price: float) -> float`
|
|
1136
|
-
(note, order size is negative for short orders),
|
|
1137
|
-
which can be used to model more complex commission structures.
|
|
1138
|
-
Negative commission values are interpreted as market-maker's rebates.
|
|
1139
|
-
|
|
1140
|
-
.. note::
|
|
1141
|
-
Before v0.4.0, the commission was only applied once, like `spread` is now.
|
|
1142
|
-
If you want to keep the old behavior, simply set `spread` instead.
|
|
1143
|
-
|
|
1144
|
-
.. note::
|
|
1145
|
-
With nonzero `commission`, long and short orders will be placed
|
|
1146
|
-
at an adjusted price that is slightly higher or lower (respectively)
|
|
1147
|
-
than the current price. See e.g.
|
|
1148
|
-
[#153](https://github.com/kernc/backtesting.py/issues/153),
|
|
1149
|
-
[#538](https://github.com/kernc/backtesting.py/issues/538),
|
|
1150
|
-
[#633](https://github.com/kernc/backtesting.py/issues/633).
|
|
1151
|
-
|
|
1152
|
-
`margin` is the required margin (ratio) of a leveraged account.
|
|
1153
|
-
No difference is made between initial and maintenance margins.
|
|
1154
|
-
To run the backtest using e.g. 50:1 leverge that your broker allows,
|
|
1155
|
-
set margin to `0.02` (1 / leverage).
|
|
1156
|
-
|
|
1157
|
-
If `trade_on_close` is `True`, market orders will be filled
|
|
1158
|
-
with respect to the current bar's closing price instead of the
|
|
1159
|
-
next bar's open.
|
|
1160
|
-
|
|
1161
|
-
If `hedging` is `True`, allow trades in both directions simultaneously.
|
|
1162
|
-
If `False`, the opposite-facing orders first close existing trades in
|
|
1163
|
-
a [FIFO] manner.
|
|
1164
|
-
|
|
1165
|
-
If `exclusive_orders` is `True`, each new order auto-closes the previous
|
|
1166
|
-
trade/position, making at most a single trade (long or short) in effect
|
|
1167
|
-
at each time.
|
|
1168
|
-
|
|
1169
|
-
If `finalize_trades` is `True`, the trades that are still
|
|
1170
|
-
[active and ongoing] at the end of the backtest will be closed on
|
|
1171
|
-
the last bar and will contribute to the computed backtest statistics.
|
|
1172
|
-
|
|
1173
|
-
.. tip:: Fractional trading
|
|
1174
|
-
See also `backtesting.lib.FractionalBacktest` if you want to trade
|
|
1175
|
-
fractional units (of e.g. bitcoin).
|
|
1176
|
-
|
|
1177
|
-
[FIFO]: https://www.investopedia.com/terms/n/nfa-compliance-rule-2-43b.asp
|
|
1178
|
-
[active and ongoing]: https://kernc.github.io/backtesting.py/doc/backtesting/backtesting.html#backtesting.backtesting.Strategy.trades
|
|
1179
|
-
""" # noqa: E501
|
|
1180
|
-
def __init__(self,
|
|
1181
|
-
data: pd.DataFrame,
|
|
1182
|
-
strategy: Type[Strategy],
|
|
1183
|
-
*,
|
|
1184
|
-
cash: float = 10_000,
|
|
1185
|
-
spread: float = .0,
|
|
1186
|
-
commission: Union[float, Tuple[float, float]] = .0,
|
|
1187
|
-
margin: float = 1.,
|
|
1188
|
-
trade_on_close=False,
|
|
1189
|
-
hedging=False,
|
|
1190
|
-
exclusive_orders=False,
|
|
1191
|
-
finalize_trades=False,
|
|
1192
|
-
):
|
|
1193
|
-
if not (isinstance(strategy, type) and issubclass(strategy, Strategy)):
|
|
1194
|
-
raise TypeError('`strategy` must be a Strategy sub-type')
|
|
1195
|
-
if not isinstance(data, pd.DataFrame):
|
|
1196
|
-
raise TypeError("`data` must be a pandas.DataFrame with columns")
|
|
1197
|
-
if not isinstance(spread, Number):
|
|
1198
|
-
raise TypeError('`spread` must be a float value, percent of '
|
|
1199
|
-
'entry order price')
|
|
1200
|
-
if not isinstance(commission, (Number, tuple)) and not callable(commission):
|
|
1201
|
-
raise TypeError('`commission` must be a float percent of order value, '
|
|
1202
|
-
'a tuple of `(fixed, relative)` commission, '
|
|
1203
|
-
'or a function that takes `(order_size, price)`'
|
|
1204
|
-
'and returns commission dollar value')
|
|
1205
|
-
|
|
1206
|
-
data = data.copy(deep=False)
|
|
1207
|
-
|
|
1208
|
-
# Convert index to datetime index
|
|
1209
|
-
if (not isinstance(data.index, pd.DatetimeIndex) and
|
|
1210
|
-
not isinstance(data.index, pd.RangeIndex) and
|
|
1211
|
-
# Numeric index with most large numbers
|
|
1212
|
-
(data.index.is_numeric() and
|
|
1213
|
-
(data.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
|
|
1214
|
-
try:
|
|
1215
|
-
data.index = pd.to_datetime(data.index, infer_datetime_format=True)
|
|
1216
|
-
except ValueError:
|
|
1217
|
-
pass
|
|
1218
|
-
|
|
1219
|
-
if 'Volume' not in data:
|
|
1220
|
-
data['Volume'] = np.nan
|
|
1221
|
-
|
|
1222
|
-
if len(data) == 0:
|
|
1223
|
-
raise ValueError('OHLC `data` is empty')
|
|
1224
|
-
if len(data.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
|
|
1225
|
-
raise ValueError("`data` must be a pandas.DataFrame with columns "
|
|
1226
|
-
"'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
|
|
1227
|
-
if data[['Open', 'High', 'Low', 'Close']].isnull().values.any():
|
|
1228
|
-
raise ValueError('Some OHLC values are missing (NaN). '
|
|
1229
|
-
'Please strip those lines with `df.dropna()` or '
|
|
1230
|
-
'fill them in with `df.interpolate()` or whatever.')
|
|
1231
|
-
if np.any(data['Close'] > cash):
|
|
1232
|
-
warnings.warn('Some prices are larger than initial cash value. Note that fractional '
|
|
1233
|
-
'trading is not supported by this class. If you want to trade Bitcoin, '
|
|
1234
|
-
'increase initial cash, or trade μBTC or satoshis instead (see e.g. class '
|
|
1235
|
-
'`backtesting.lib.FractionalBacktest`.',
|
|
1236
|
-
stacklevel=2)
|
|
1237
|
-
if not data.index.is_monotonic_increasing:
|
|
1238
|
-
warnings.warn('Data index is not sorted in ascending order. Sorting.',
|
|
1239
|
-
stacklevel=2)
|
|
1240
|
-
data = data.sort_index()
|
|
1241
|
-
if not isinstance(data.index, pd.DatetimeIndex):
|
|
1242
|
-
warnings.warn('Data index is not datetime. Assuming simple periods, '
|
|
1243
|
-
'but `pd.DateTimeIndex` is advised.',
|
|
1244
|
-
stacklevel=2)
|
|
1245
|
-
|
|
1246
|
-
self._data: pd.DataFrame = data
|
|
1247
|
-
self._broker = partial(
|
|
1248
|
-
_Broker, cash=cash, spread=spread, commission=commission, margin=margin,
|
|
1249
|
-
trade_on_close=trade_on_close, hedging=hedging,
|
|
1250
|
-
exclusive_orders=exclusive_orders, index=data.index,
|
|
1251
|
-
)
|
|
1252
|
-
self._strategy = strategy
|
|
1253
|
-
self._results: Optional[pd.Series] = None
|
|
1254
|
-
self._finalize_trades = bool(finalize_trades)
|
|
1255
|
-
|
|
1256
|
-
def run(self, **kwargs) -> pd.Series:
|
|
1257
|
-
"""
|
|
1258
|
-
Run the backtest. Returns `pd.Series` with results and statistics.
|
|
1259
|
-
|
|
1260
|
-
Keyword arguments are interpreted as strategy parameters.
|
|
1261
|
-
|
|
1262
|
-
>>> Backtest(GOOG, SmaCross).run()
|
|
1263
|
-
Start 2004-08-19 00:00:00
|
|
1264
|
-
End 2013-03-01 00:00:00
|
|
1265
|
-
Duration 3116 days 00:00:00
|
|
1266
|
-
Exposure Time [%] 96.74115
|
|
1267
|
-
Equity Final [$] 51422.99
|
|
1268
|
-
Equity Peak [$] 75787.44
|
|
1269
|
-
Return [%] 414.2299
|
|
1270
|
-
Buy & Hold Return [%] 703.45824
|
|
1271
|
-
Return (Ann.) [%] 21.18026
|
|
1272
|
-
Volatility (Ann.) [%] 36.49391
|
|
1273
|
-
CAGR [%] 14.15984
|
|
1274
|
-
Sharpe Ratio 0.58038
|
|
1275
|
-
Sortino Ratio 1.08479
|
|
1276
|
-
Calmar Ratio 0.44144
|
|
1277
|
-
Alpha [%] 394.37391
|
|
1278
|
-
Beta 0.03803
|
|
1279
|
-
Max. Drawdown [%] -47.98013
|
|
1280
|
-
Avg. Drawdown [%] -5.92585
|
|
1281
|
-
Max. Drawdown Duration 584 days 00:00:00
|
|
1282
|
-
Avg. Drawdown Duration 41 days 00:00:00
|
|
1283
|
-
# Trades 66
|
|
1284
|
-
Win Rate [%] 46.9697
|
|
1285
|
-
Best Trade [%] 53.59595
|
|
1286
|
-
Worst Trade [%] -18.39887
|
|
1287
|
-
Avg. Trade [%] 2.53172
|
|
1288
|
-
Max. Trade Duration 183 days 00:00:00
|
|
1289
|
-
Avg. Trade Duration 46 days 00:00:00
|
|
1290
|
-
Profit Factor 2.16795
|
|
1291
|
-
Expectancy [%] 3.27481
|
|
1292
|
-
SQN 1.07662
|
|
1293
|
-
Kelly Criterion 0.15187
|
|
1294
|
-
_strategy SmaCross
|
|
1295
|
-
_equity_curve Eq...
|
|
1296
|
-
_trades Size EntryB...
|
|
1297
|
-
dtype: object
|
|
1298
|
-
|
|
1299
|
-
.. warning::
|
|
1300
|
-
You may obtain different results for different strategy parameters.
|
|
1301
|
-
E.g. if you use 50- and 200-bar SMA, the trading simulation will
|
|
1302
|
-
begin on bar 201. The actual length of delay is equal to the lookback
|
|
1303
|
-
period of the `Strategy.I` indicator which lags the most.
|
|
1304
|
-
Obviously, this can affect results.
|
|
1305
|
-
"""
|
|
1306
|
-
data = _Data(self._data.copy(deep=False))
|
|
1307
|
-
broker: _Broker = self._broker(data=data)
|
|
1308
|
-
strategy: Strategy = self._strategy(broker, data, kwargs)
|
|
1309
|
-
|
|
1310
|
-
strategy.init()
|
|
1311
|
-
data._update() # Strategy.init might have changed/added to data.df
|
|
1312
|
-
|
|
1313
|
-
# Indicators used in Strategy.next()
|
|
1314
|
-
indicator_attrs = _strategy_indicators(strategy)
|
|
1315
|
-
|
|
1316
|
-
# Skip first few candles where indicators are still "warming up"
|
|
1317
|
-
# +1 to have at least two entries available
|
|
1318
|
-
start = 1 + _indicator_warmup_nbars(strategy)
|
|
1319
|
-
|
|
1320
|
-
# Disable "invalid value encountered in ..." warnings. Comparison
|
|
1321
|
-
# np.nan >= 3 is not invalid; it's False.
|
|
1322
|
-
with np.errstate(invalid='ignore'):
|
|
1323
|
-
|
|
1324
|
-
for i in _tqdm(range(start, len(self._data)), desc=self.run.__qualname__,
|
|
1325
|
-
unit='bar', mininterval=2, miniters=100):
|
|
1326
|
-
# Prepare data and indicators for `next` call
|
|
1327
|
-
data._set_length(i + 1)
|
|
1328
|
-
for attr, indicator in indicator_attrs:
|
|
1329
|
-
# Slice indicator on the last dimension (case of 2d indicator)
|
|
1330
|
-
setattr(strategy, attr, indicator[..., :i + 1])
|
|
1331
|
-
|
|
1332
|
-
# Handle orders processing and broker stuff
|
|
1333
|
-
try:
|
|
1334
|
-
broker.next()
|
|
1335
|
-
except _OutOfMoneyError:
|
|
1336
|
-
break
|
|
1337
|
-
|
|
1338
|
-
# Next tick, a moment before bar close
|
|
1339
|
-
strategy.next()
|
|
1340
|
-
else:
|
|
1341
|
-
if self._finalize_trades is True:
|
|
1342
|
-
# Close any remaining open trades so they produce some stats
|
|
1343
|
-
for trade in reversed(broker.trades):
|
|
1344
|
-
trade.close()
|
|
1345
|
-
|
|
1346
|
-
# HACK: Re-run broker one last time to handle close orders placed in the last
|
|
1347
|
-
# strategy iteration. Use the same OHLC values as in the last broker iteration.
|
|
1348
|
-
if start < len(self._data):
|
|
1349
|
-
try_(broker.next, exception=_OutOfMoneyError)
|
|
1350
|
-
elif len(broker.trades):
|
|
1351
|
-
warnings.warn(
|
|
1352
|
-
'Some trades remain open at the end of backtest. Use '
|
|
1353
|
-
'`Backtest(..., finalize_trades=True)` to close them and '
|
|
1354
|
-
'include them in stats.', stacklevel=2)
|
|
1355
|
-
|
|
1356
|
-
# Set data back to full length
|
|
1357
|
-
# for future `indicator._opts['data'].index` calls to work
|
|
1358
|
-
data._set_length(len(self._data))
|
|
1359
|
-
|
|
1360
|
-
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
|
|
1361
|
-
self._results = compute_stats(
|
|
1362
|
-
trades=broker.closed_trades,
|
|
1363
|
-
equity=equity,
|
|
1364
|
-
ohlc_data=self._data,
|
|
1365
|
-
risk_free_rate=0.0,
|
|
1366
|
-
strategy_instance=strategy,
|
|
1367
|
-
)
|
|
1368
|
-
|
|
1369
|
-
return self._results
|
|
1370
|
-
|
|
1371
|
-
def optimize(self, *,
|
|
1372
|
-
maximize: Union[str, Callable[[pd.Series], float]] = 'SQN',
|
|
1373
|
-
method: str = 'grid',
|
|
1374
|
-
max_tries: Optional[Union[int, float]] = None,
|
|
1375
|
-
constraint: Optional[Callable[[dict], bool]] = None,
|
|
1376
|
-
return_heatmap: bool = False,
|
|
1377
|
-
return_optimization: bool = False,
|
|
1378
|
-
random_state: Optional[int] = None,
|
|
1379
|
-
**kwargs) -> Union[pd.Series,
|
|
1380
|
-
Tuple[pd.Series, pd.Series],
|
|
1381
|
-
Tuple[pd.Series, pd.Series, dict]]:
|
|
1382
|
-
"""
|
|
1383
|
-
Optimize strategy parameters to an optimal combination.
|
|
1384
|
-
Returns result `pd.Series` of the best run.
|
|
1385
|
-
|
|
1386
|
-
`maximize` is a string key from the
|
|
1387
|
-
`backtesting.backtesting.Backtest.run`-returned results series,
|
|
1388
|
-
or a function that accepts this series object and returns a number;
|
|
1389
|
-
the higher the better. By default, the method maximizes
|
|
1390
|
-
Van Tharp's [System Quality Number](https://google.com/search?q=System+Quality+Number).
|
|
1391
|
-
|
|
1392
|
-
`method` is the optimization method. Currently two methods are supported:
|
|
1393
|
-
|
|
1394
|
-
* `"grid"` which does an exhaustive (or randomized) search over the
|
|
1395
|
-
cartesian product of parameter combinations, and
|
|
1396
|
-
* `"sambo"` which finds close-to-optimal strategy parameters using
|
|
1397
|
-
[model-based optimization], making at most `max_tries` evaluations.
|
|
1398
|
-
|
|
1399
|
-
[model-based optimization]: https://sambo-optimization.github.io
|
|
1400
|
-
|
|
1401
|
-
`max_tries` is the maximal number of strategy runs to perform.
|
|
1402
|
-
If `method="grid"`, this results in randomized grid search.
|
|
1403
|
-
If `max_tries` is a floating value between (0, 1], this sets the
|
|
1404
|
-
number of runs to approximately that fraction of full grid space.
|
|
1405
|
-
Alternatively, if integer, it denotes the absolute maximum number
|
|
1406
|
-
of evaluations. If unspecified (default), grid search is exhaustive,
|
|
1407
|
-
whereas for `method="sambo"`, `max_tries` is set to 200.
|
|
1408
|
-
|
|
1409
|
-
`constraint` is a function that accepts a dict-like object of
|
|
1410
|
-
parameters (with values) and returns `True` when the combination
|
|
1411
|
-
is admissible to test with. By default, any parameters combination
|
|
1412
|
-
is considered admissible.
|
|
1413
|
-
|
|
1414
|
-
If `return_heatmap` is `True`, besides returning the result
|
|
1415
|
-
series, an additional `pd.Series` is returned with a multiindex
|
|
1416
|
-
of all admissible parameter combinations, which can be further
|
|
1417
|
-
inspected or projected onto 2D to plot a heatmap
|
|
1418
|
-
(see `backtesting.lib.plot_heatmaps()`).
|
|
1419
|
-
|
|
1420
|
-
If `return_optimization` is True and `method = 'sambo'`,
|
|
1421
|
-
in addition to result series (and maybe heatmap), return raw
|
|
1422
|
-
[`scipy.optimize.OptimizeResult`][OptimizeResult] for further
|
|
1423
|
-
inspection, e.g. with [SAMBO]'s [plotting tools].
|
|
1424
|
-
|
|
1425
|
-
[OptimizeResult]: https://sambo-optimization.github.io/doc/sambo/#sambo.OptimizeResult
|
|
1426
|
-
[SAMBO]: https://sambo-optimization.github.io
|
|
1427
|
-
[plotting tools]: https://sambo-optimization.github.io/doc/sambo/plot.html
|
|
1428
|
-
|
|
1429
|
-
If you want reproducible optimization results, set `random_state`
|
|
1430
|
-
to a fixed integer random seed.
|
|
1431
|
-
|
|
1432
|
-
Additional keyword arguments represent strategy arguments with
|
|
1433
|
-
list-like collections of possible values. For example, the following
|
|
1434
|
-
code finds and returns the "best" of the 7 admissible (of the
|
|
1435
|
-
9 possible) parameter combinations:
|
|
1436
|
-
|
|
1437
|
-
best_stats = backtest.optimize(sma1=[5, 10, 15], sma2=[10, 20, 40],
|
|
1438
|
-
constraint=lambda p: p.sma1 < p.sma2)
|
|
1439
|
-
"""
|
|
1440
|
-
if not kwargs:
|
|
1441
|
-
raise ValueError('Need some strategy parameters to optimize')
|
|
1442
|
-
|
|
1443
|
-
maximize_key = None
|
|
1444
|
-
if isinstance(maximize, str):
|
|
1445
|
-
maximize_key = str(maximize)
|
|
1446
|
-
if maximize not in dummy_stats().index:
|
|
1447
|
-
raise ValueError('`maximize`, if str, must match a key in pd.Series '
|
|
1448
|
-
'result of backtest.run()')
|
|
1449
|
-
|
|
1450
|
-
def maximize(stats: pd.Series, _key=maximize):
|
|
1451
|
-
return stats[_key]
|
|
1452
|
-
|
|
1453
|
-
elif not callable(maximize):
|
|
1454
|
-
raise TypeError('`maximize` must be str (a field of backtest.run() result '
|
|
1455
|
-
'Series) or a function that accepts result Series '
|
|
1456
|
-
'and returns a number; the higher the better')
|
|
1457
|
-
assert callable(maximize), maximize
|
|
1458
|
-
|
|
1459
|
-
have_constraint = bool(constraint)
|
|
1460
|
-
if constraint is None:
|
|
1461
|
-
|
|
1462
|
-
def constraint(_):
|
|
1463
|
-
return True
|
|
1464
|
-
|
|
1465
|
-
elif not callable(constraint):
|
|
1466
|
-
raise TypeError("`constraint` must be a function that accepts a dict "
|
|
1467
|
-
"of strategy parameters and returns a bool whether "
|
|
1468
|
-
"the combination of parameters is admissible or not")
|
|
1469
|
-
assert callable(constraint), constraint
|
|
1470
|
-
|
|
1471
|
-
if method == 'skopt':
|
|
1472
|
-
method = 'sambo'
|
|
1473
|
-
warnings.warn('`Backtest.optimize(method="skopt")` is deprecated. Use `method="sambo"`.',
|
|
1474
|
-
DeprecationWarning, stacklevel=2)
|
|
1475
|
-
if return_optimization and method != 'sambo':
|
|
1476
|
-
raise ValueError("return_optimization=True only valid if method='sambo'")
|
|
1477
|
-
|
|
1478
|
-
def _tuple(x):
|
|
1479
|
-
return x if isinstance(x, Sequence) and not isinstance(x, str) else (x,)
|
|
1480
|
-
|
|
1481
|
-
for k, v in kwargs.items():
|
|
1482
|
-
if len(_tuple(v)) == 0:
|
|
1483
|
-
raise ValueError(f"Optimization variable '{k}' is passed no "
|
|
1484
|
-
f"optimization values: {k}={v}")
|
|
1485
|
-
|
|
1486
|
-
class AttrDict(dict):
|
|
1487
|
-
def __getattr__(self, item):
|
|
1488
|
-
return self[item]
|
|
1489
|
-
|
|
1490
|
-
def _grid_size():
|
|
1491
|
-
size = int(np.prod([len(_tuple(v)) for v in kwargs.values()]))
|
|
1492
|
-
if size < 10_000 and have_constraint:
|
|
1493
|
-
size = sum(1 for p in product(*(zip(repeat(k), _tuple(v))
|
|
1494
|
-
for k, v in kwargs.items()))
|
|
1495
|
-
if constraint(AttrDict(p)))
|
|
1496
|
-
return size
|
|
1497
|
-
|
|
1498
|
-
def _optimize_grid() -> Union[pd.Series, Tuple[pd.Series, pd.Series]]:
|
|
1499
|
-
rand = default_rng(random_state).random
|
|
1500
|
-
grid_frac = (1 if max_tries is None else
|
|
1501
|
-
max_tries if 0 < max_tries <= 1 else
|
|
1502
|
-
max_tries / _grid_size())
|
|
1503
|
-
param_combos = [dict(params) # back to dict so it pickles
|
|
1504
|
-
for params in (AttrDict(params)
|
|
1505
|
-
for params in product(*(zip(repeat(k), _tuple(v))
|
|
1506
|
-
for k, v in kwargs.items())))
|
|
1507
|
-
if constraint(params)
|
|
1508
|
-
and rand() <= grid_frac]
|
|
1509
|
-
if not param_combos:
|
|
1510
|
-
raise ValueError('No admissible parameter combinations to test')
|
|
1511
|
-
|
|
1512
|
-
if len(param_combos) > 300:
|
|
1513
|
-
warnings.warn(f'Searching for best of {len(param_combos)} configurations.',
|
|
1514
|
-
stacklevel=2)
|
|
1515
|
-
|
|
1516
|
-
heatmap = pd.Series(np.nan,
|
|
1517
|
-
name=maximize_key,
|
|
1518
|
-
index=pd.MultiIndex.from_tuples(
|
|
1519
|
-
[p.values() for p in param_combos],
|
|
1520
|
-
names=next(iter(param_combos)).keys()))
|
|
1521
|
-
|
|
1522
|
-
from . import Pool
|
|
1523
|
-
with Pool() as pool, \
|
|
1524
|
-
SharedMemoryManager() as smm:
|
|
1525
|
-
with patch(self, '_data', None):
|
|
1526
|
-
bt = copy(self) # bt._data will be reassigned in _mp_task worker
|
|
1527
|
-
results = _tqdm(
|
|
1528
|
-
pool.imap(Backtest._mp_task,
|
|
1529
|
-
((bt, smm.df2shm(self._data), params_batch)
|
|
1530
|
-
for params_batch in _batch(param_combos))),
|
|
1531
|
-
total=len(param_combos),
|
|
1532
|
-
desc='Backtest.optimize'
|
|
1533
|
-
)
|
|
1534
|
-
for param_batch, result in zip(_batch(param_combos), results):
|
|
1535
|
-
for params, stats in zip(param_batch, result):
|
|
1536
|
-
if stats is not None:
|
|
1537
|
-
heatmap[tuple(params.values())] = maximize(stats)
|
|
1538
|
-
|
|
1539
|
-
if pd.isnull(heatmap).all():
|
|
1540
|
-
# No trade was made in any of the runs. Just make a random
|
|
1541
|
-
# run so we get some, if empty, results
|
|
1542
|
-
stats = self.run(**param_combos[0])
|
|
1543
|
-
else:
|
|
1544
|
-
best_params = heatmap.idxmax(skipna=True)
|
|
1545
|
-
stats = self.run(**dict(zip(heatmap.index.names, best_params)))
|
|
1546
|
-
|
|
1547
|
-
if return_heatmap:
|
|
1548
|
-
return stats, heatmap
|
|
1549
|
-
return stats
|
|
1550
|
-
|
|
1551
|
-
def _optimize_sambo() -> Union[pd.Series,
|
|
1552
|
-
Tuple[pd.Series, pd.Series],
|
|
1553
|
-
Tuple[pd.Series, pd.Series, dict]]:
|
|
1554
|
-
try:
|
|
1555
|
-
import sambo
|
|
1556
|
-
except ImportError:
|
|
1557
|
-
raise ImportError("Need package 'sambo' for method='sambo'. pip install sambo") from None
|
|
1558
|
-
|
|
1559
|
-
nonlocal max_tries
|
|
1560
|
-
max_tries = (200 if max_tries is None else
|
|
1561
|
-
max(1, int(max_tries * _grid_size())) if 0 < max_tries <= 1 else
|
|
1562
|
-
max_tries)
|
|
1563
|
-
|
|
1564
|
-
dimensions = []
|
|
1565
|
-
for key, values in kwargs.items():
|
|
1566
|
-
values = np.asarray(values)
|
|
1567
|
-
if values.dtype.kind in 'mM': # timedelta, datetime64
|
|
1568
|
-
# these dtypes are unsupported in SAMBO, so convert to raw int
|
|
1569
|
-
# TODO: save dtype and convert back later
|
|
1570
|
-
values = values.astype(np.int64)
|
|
1571
|
-
|
|
1572
|
-
if values.dtype.kind in 'iumM':
|
|
1573
|
-
dimensions.append((values.min(), values.max() + 1))
|
|
1574
|
-
elif values.dtype.kind == 'f':
|
|
1575
|
-
dimensions.append((values.min(), values.max()))
|
|
1576
|
-
else:
|
|
1577
|
-
dimensions.append(values.tolist())
|
|
1578
|
-
|
|
1579
|
-
# Avoid recomputing re-evaluations
|
|
1580
|
-
@lru_cache()
|
|
1581
|
-
def memoized_run(tup):
|
|
1582
|
-
nonlocal maximize, self
|
|
1583
|
-
stats = self.run(**dict(tup))
|
|
1584
|
-
return -maximize(stats)
|
|
1585
|
-
|
|
1586
|
-
progress = iter(_tqdm(repeat(None), total=max_tries, leave=False,
|
|
1587
|
-
desc=self.optimize.__qualname__, mininterval=2))
|
|
1588
|
-
_names = tuple(kwargs.keys())
|
|
1589
|
-
|
|
1590
|
-
def objective_function(x):
|
|
1591
|
-
nonlocal progress, memoized_run, constraint, _names
|
|
1592
|
-
next(progress)
|
|
1593
|
-
value = memoized_run(tuple(zip(_names, x)))
|
|
1594
|
-
return 0 if np.isnan(value) else value
|
|
1595
|
-
|
|
1596
|
-
def cons(x):
|
|
1597
|
-
nonlocal constraint, _names
|
|
1598
|
-
return constraint(AttrDict(zip(_names, x)))
|
|
1599
|
-
|
|
1600
|
-
res = sambo.minimize(
|
|
1601
|
-
fun=objective_function,
|
|
1602
|
-
bounds=dimensions,
|
|
1603
|
-
constraints=cons,
|
|
1604
|
-
max_iter=max_tries,
|
|
1605
|
-
method='sceua',
|
|
1606
|
-
rng=random_state)
|
|
1607
|
-
|
|
1608
|
-
stats = self.run(**dict(zip(kwargs.keys(), res.x)))
|
|
1609
|
-
output = [stats]
|
|
1610
|
-
|
|
1611
|
-
if return_heatmap:
|
|
1612
|
-
heatmap = pd.Series(dict(zip(map(tuple, res.xv), -res.funv)),
|
|
1613
|
-
name=maximize_key)
|
|
1614
|
-
heatmap.index.names = kwargs.keys()
|
|
1615
|
-
heatmap.sort_index(inplace=True)
|
|
1616
|
-
output.append(heatmap)
|
|
1617
|
-
|
|
1618
|
-
if return_optimization:
|
|
1619
|
-
output.append(res)
|
|
1620
|
-
|
|
1621
|
-
return stats if len(output) == 1 else tuple(output)
|
|
1622
|
-
|
|
1623
|
-
if method == 'grid':
|
|
1624
|
-
output = _optimize_grid()
|
|
1625
|
-
elif method in ('sambo', 'skopt'):
|
|
1626
|
-
output = _optimize_sambo()
|
|
1627
|
-
else:
|
|
1628
|
-
raise ValueError(f"Method should be 'grid' or 'sambo', not {method!r}")
|
|
1629
|
-
return output
|
|
1630
|
-
|
|
1631
|
-
@staticmethod
|
|
1632
|
-
def _mp_task(arg):
|
|
1633
|
-
bt, data_shm, params_batch = arg
|
|
1634
|
-
bt._data, shm = SharedMemoryManager.shm2df(data_shm)
|
|
1635
|
-
try:
|
|
1636
|
-
return [stats.filter(regex='^[^_]') if stats['# Trades'] else None
|
|
1637
|
-
for stats in (bt.run(**params)
|
|
1638
|
-
for params in params_batch)]
|
|
1639
|
-
finally:
|
|
1640
|
-
for shmem in shm:
|
|
1641
|
-
shmem.close()
|
|
1642
|
-
|
|
1643
|
-
def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
|
|
1644
|
-
plot_equity=True, plot_return=False, plot_pl=True,
|
|
1645
|
-
plot_volume=True, plot_drawdown=False, plot_trades=True,
|
|
1646
|
-
smooth_equity=False, relative_equity=True,
|
|
1647
|
-
superimpose: Union[bool, str] = True,
|
|
1648
|
-
resample=True, reverse_indicators=False,
|
|
1649
|
-
show_legend=True, open_browser=True):
|
|
1650
|
-
"""
|
|
1651
|
-
Plot the progression of the last backtest run.
|
|
1652
|
-
|
|
1653
|
-
If `results` is provided, it should be a particular result
|
|
1654
|
-
`pd.Series` such as returned by
|
|
1655
|
-
`backtesting.backtesting.Backtest.run` or
|
|
1656
|
-
`backtesting.backtesting.Backtest.optimize`, otherwise the last
|
|
1657
|
-
run's results are used.
|
|
1658
|
-
|
|
1659
|
-
`filename` is the path to save the interactive HTML plot to.
|
|
1660
|
-
By default, a strategy/parameter-dependent file is created in the
|
|
1661
|
-
current working directory.
|
|
1662
|
-
|
|
1663
|
-
`plot_width` is the width of the plot in pixels. If None (default),
|
|
1664
|
-
the plot is made to span 100% of browser width. The height is
|
|
1665
|
-
currently non-adjustable.
|
|
1666
|
-
|
|
1667
|
-
If `plot_equity` is `True`, the resulting plot will contain
|
|
1668
|
-
an equity (initial cash plus assets) graph section. This is the same
|
|
1669
|
-
as `plot_return` plus initial 100%.
|
|
1670
|
-
|
|
1671
|
-
If `plot_return` is `True`, the resulting plot will contain
|
|
1672
|
-
a cumulative return graph section. This is the same
|
|
1673
|
-
as `plot_equity` minus initial 100%.
|
|
1674
|
-
|
|
1675
|
-
If `plot_pl` is `True`, the resulting plot will contain
|
|
1676
|
-
a profit/loss (P/L) indicator section.
|
|
1677
|
-
|
|
1678
|
-
If `plot_volume` is `True`, the resulting plot will contain
|
|
1679
|
-
a trade volume section.
|
|
1680
|
-
|
|
1681
|
-
If `plot_drawdown` is `True`, the resulting plot will contain
|
|
1682
|
-
a separate drawdown graph section.
|
|
1683
|
-
|
|
1684
|
-
If `plot_trades` is `True`, the stretches between trade entries
|
|
1685
|
-
and trade exits are marked by hash-marked tractor beams.
|
|
1686
|
-
|
|
1687
|
-
If `smooth_equity` is `True`, the equity graph will be
|
|
1688
|
-
interpolated between fixed points at trade closing times,
|
|
1689
|
-
unaffected by any interim asset volatility.
|
|
1690
|
-
|
|
1691
|
-
If `relative_equity` is `True`, scale and label equity graph axis
|
|
1692
|
-
with return percent, not absolute cash-equivalent values.
|
|
1693
|
-
|
|
1694
|
-
If `superimpose` is `True`, superimpose larger-timeframe candlesticks
|
|
1695
|
-
over the original candlestick chart. Default downsampling rule is:
|
|
1696
|
-
monthly for daily data, daily for hourly data, hourly for minute data,
|
|
1697
|
-
and minute for (sub-)second data.
|
|
1698
|
-
`superimpose` can also be a valid [Pandas offset string],
|
|
1699
|
-
such as `'5T'` or `'5min'`, in which case this frequency will be
|
|
1700
|
-
used to superimpose.
|
|
1701
|
-
Note, this only works for data with a datetime index.
|
|
1702
|
-
|
|
1703
|
-
If `resample` is `True`, the OHLC data is resampled in a way that
|
|
1704
|
-
makes the upper number of candles for Bokeh to plot limited to 10_000.
|
|
1705
|
-
This may, in situations of overabundant data,
|
|
1706
|
-
improve plot's interactive performance and avoid browser's
|
|
1707
|
-
`Javascript Error: Maximum call stack size exceeded` or similar.
|
|
1708
|
-
Equity & dropdown curves and individual trades data is,
|
|
1709
|
-
likewise, [reasonably _aggregated_][TRADES_AGG].
|
|
1710
|
-
`resample` can also be a [Pandas offset string],
|
|
1711
|
-
such as `'5T'` or `'5min'`, in which case this frequency will be
|
|
1712
|
-
used to resample, overriding above numeric limitation.
|
|
1713
|
-
Note, all this only works for data with a datetime index.
|
|
1714
|
-
|
|
1715
|
-
If `reverse_indicators` is `True`, the indicators below the OHLC chart
|
|
1716
|
-
are plotted in reverse order of declaration.
|
|
1717
|
-
|
|
1718
|
-
[Pandas offset string]: \
|
|
1719
|
-
https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects
|
|
1720
|
-
|
|
1721
|
-
[TRADES_AGG]: lib.html#backtesting.lib.TRADES_AGG
|
|
1722
|
-
|
|
1723
|
-
If `show_legend` is `True`, the resulting plot graphs will contain
|
|
1724
|
-
labeled legends.
|
|
1725
|
-
|
|
1726
|
-
If `open_browser` is `True`, the resulting `filename` will be
|
|
1727
|
-
opened in the default web browser.
|
|
1728
|
-
"""
|
|
1729
|
-
if results is None:
|
|
1730
|
-
if self._results is None:
|
|
1731
|
-
raise RuntimeError('First issue `backtest.run()` to obtain results.')
|
|
1732
|
-
results = self._results
|
|
1733
|
-
|
|
1734
|
-
return plot(
|
|
1735
|
-
results=results,
|
|
1736
|
-
df=self._data,
|
|
1737
|
-
indicators=results._strategy._indicators,
|
|
1738
|
-
filename=filename,
|
|
1739
|
-
plot_width=plot_width,
|
|
1740
|
-
plot_equity=plot_equity,
|
|
1741
|
-
plot_return=plot_return,
|
|
1742
|
-
plot_pl=plot_pl,
|
|
1743
|
-
plot_volume=plot_volume,
|
|
1744
|
-
plot_drawdown=plot_drawdown,
|
|
1745
|
-
plot_trades=plot_trades,
|
|
1746
|
-
smooth_equity=smooth_equity,
|
|
1747
|
-
relative_equity=relative_equity,
|
|
1748
|
-
superimpose=superimpose,
|
|
1749
|
-
resample=resample,
|
|
1750
|
-
reverse_indicators=reverse_indicators,
|
|
1751
|
-
show_legend=show_legend,
|
|
1752
|
-
open_browser=open_browser)
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
# NOTE: Don't put anything public below this __all__ list
|
|
1756
|
-
|
|
1757
|
-
__all__ = [getattr(v, '__name__', k)
|
|
1758
|
-
for k, v in globals().items() # export
|
|
1759
|
-
if ((callable(v) and getattr(v, '__module__', None) == __name__ or # callables from this module; getattr for Python 3.9; # noqa: E501
|
|
1760
|
-
k.isupper()) and # or CONSTANTS
|
|
1761
|
-
not getattr(v, '__name__', k).startswith('_'))] # neither marked internal
|
|
1762
|
-
|
|
1763
|
-
# NOTE: Don't put anything public below here. See above.
|