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/_plotting.py
DELETED
|
@@ -1,785 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import sys
|
|
6
|
-
import warnings
|
|
7
|
-
from colorsys import hls_to_rgb, rgb_to_hls
|
|
8
|
-
from itertools import cycle, combinations
|
|
9
|
-
from functools import partial
|
|
10
|
-
from typing import Callable, List, Union
|
|
11
|
-
|
|
12
|
-
import numpy as np
|
|
13
|
-
import pandas as pd
|
|
14
|
-
|
|
15
|
-
from bokeh.colors import RGB
|
|
16
|
-
from bokeh.colors.named import (
|
|
17
|
-
lime as BULL_COLOR,
|
|
18
|
-
tomato as BEAR_COLOR
|
|
19
|
-
)
|
|
20
|
-
from bokeh.events import DocumentReady
|
|
21
|
-
from bokeh.plotting import figure as _figure
|
|
22
|
-
from bokeh.models import ( # type: ignore
|
|
23
|
-
CrosshairTool,
|
|
24
|
-
CustomJS,
|
|
25
|
-
ColumnDataSource,
|
|
26
|
-
CustomJSTransform,
|
|
27
|
-
Label, NumeralTickFormatter,
|
|
28
|
-
Span,
|
|
29
|
-
HoverTool,
|
|
30
|
-
Range1d,
|
|
31
|
-
DatetimeTickFormatter,
|
|
32
|
-
WheelZoomTool,
|
|
33
|
-
LinearColorMapper,
|
|
34
|
-
)
|
|
35
|
-
try:
|
|
36
|
-
from bokeh.models import CustomJSTickFormatter
|
|
37
|
-
except ImportError: # Bokeh < 3.0
|
|
38
|
-
from bokeh.models import FuncTickFormatter as CustomJSTickFormatter # type: ignore
|
|
39
|
-
from bokeh.io import curdoc, output_notebook, output_file, show
|
|
40
|
-
from bokeh.io.state import curstate
|
|
41
|
-
from bokeh.layouts import gridplot
|
|
42
|
-
from bokeh.palettes import Category10
|
|
43
|
-
from bokeh.transform import factor_cmap, transform
|
|
44
|
-
|
|
45
|
-
from ._util import _data_period, _as_list, _Indicator, try_
|
|
46
|
-
|
|
47
|
-
# Temporary fix: JavaScript callback for autoscale
|
|
48
|
-
# TODO: Add the proper autoscale_cb.js file
|
|
49
|
-
_AUTOSCALE_JS_CALLBACK = ""
|
|
50
|
-
|
|
51
|
-
IS_JUPYTER_NOTEBOOK = ('JPY_PARENT_PID' in os.environ or
|
|
52
|
-
'inline' in os.environ.get('MPLBACKEND', ''))
|
|
53
|
-
|
|
54
|
-
if IS_JUPYTER_NOTEBOOK:
|
|
55
|
-
warnings.warn('Jupyter Notebook detected. '
|
|
56
|
-
'Setting Bokeh output to notebook. '
|
|
57
|
-
'This may not work in Jupyter clients without JavaScript '
|
|
58
|
-
'support, such as old IDEs. '
|
|
59
|
-
'Reset with `backtesting.set_bokeh_output(notebook=False)`.')
|
|
60
|
-
output_notebook()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def set_bokeh_output(notebook=False):
|
|
64
|
-
"""
|
|
65
|
-
Set Bokeh to output either to a file or Jupyter notebook.
|
|
66
|
-
By default, Bokeh outputs to notebook if running from within
|
|
67
|
-
notebook was detected.
|
|
68
|
-
"""
|
|
69
|
-
global IS_JUPYTER_NOTEBOOK
|
|
70
|
-
IS_JUPYTER_NOTEBOOK = notebook
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _windos_safe_filename(filename):
|
|
74
|
-
if sys.platform.startswith('win'):
|
|
75
|
-
return re.sub(r'[^a-zA-Z0-9,_-]', '_', filename.replace('=', '-'))
|
|
76
|
-
return filename
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _bokeh_reset(filename=None):
|
|
80
|
-
curstate().reset()
|
|
81
|
-
if filename:
|
|
82
|
-
if not filename.endswith('.html'):
|
|
83
|
-
filename += '.html'
|
|
84
|
-
output_file(filename, title=filename)
|
|
85
|
-
elif IS_JUPYTER_NOTEBOOK:
|
|
86
|
-
curstate().output_notebook()
|
|
87
|
-
_add_popcon()
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _add_popcon():
|
|
91
|
-
curdoc().js_on_event(DocumentReady, CustomJS(code='''(function() { var i = document.createElement('iframe'); i.style.display='none';i.width=i.height=1;i.loading='eager';i.src='https://kernc.github.io/backtesting.py/plx.gif.html?utm_source='+location.origin;document.body.appendChild(i);})();''')) # noqa: E501
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _watermark(fig: _figure):
|
|
95
|
-
fig.add_layout(
|
|
96
|
-
Label(
|
|
97
|
-
x=10, y=15, x_units='screen', y_units='screen', text_color='silver',
|
|
98
|
-
text='Created with Backtesting.py: http://kernc.github.io/backtesting.py',
|
|
99
|
-
text_alpha=.09))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def colorgen():
|
|
103
|
-
yield from cycle(Category10[10])
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def lightness(color, lightness=.94):
|
|
107
|
-
rgb = np.array([color.r, color.g, color.b]) / 255
|
|
108
|
-
h, _, s = rgb_to_hls(*rgb)
|
|
109
|
-
rgb = (np.array(hls_to_rgb(h, lightness, s)) * 255).astype(int)
|
|
110
|
-
return RGB(*rgb)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
_MAX_CANDLES = 10_000
|
|
114
|
-
_INDICATOR_HEIGHT = 50
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _maybe_resample_data(resample_rule, df, indicators, equity_data, trades):
|
|
118
|
-
if isinstance(resample_rule, str):
|
|
119
|
-
freq = resample_rule
|
|
120
|
-
else:
|
|
121
|
-
if resample_rule is False or len(df) <= _MAX_CANDLES:
|
|
122
|
-
return df, indicators, equity_data, trades
|
|
123
|
-
|
|
124
|
-
freq_minutes = pd.Series({
|
|
125
|
-
"1min": 1,
|
|
126
|
-
"5min": 5,
|
|
127
|
-
"10min": 10,
|
|
128
|
-
"15min": 15,
|
|
129
|
-
"30min": 30,
|
|
130
|
-
"1h": 60,
|
|
131
|
-
"2h": 60 * 2,
|
|
132
|
-
"4h": 60 * 4,
|
|
133
|
-
"8h": 60 * 8,
|
|
134
|
-
"1D": 60 * 24,
|
|
135
|
-
"1W": 60 * 24 * 7,
|
|
136
|
-
"1ME": np.inf,
|
|
137
|
-
})
|
|
138
|
-
timespan = df.index[-1] - df.index[0]
|
|
139
|
-
require_minutes = (timespan / _MAX_CANDLES).total_seconds() // 60
|
|
140
|
-
freq = freq_minutes.where(freq_minutes >= require_minutes).first_valid_index()
|
|
141
|
-
warnings.warn(f"Data contains too many candlesticks to plot; downsampling to {freq!r}. "
|
|
142
|
-
"See `Backtest.plot(resample=...)`")
|
|
143
|
-
|
|
144
|
-
from .lib import OHLCV_AGG, TRADES_AGG, _EQUITY_AGG
|
|
145
|
-
df = df.resample(freq, label='right').agg(OHLCV_AGG).dropna()
|
|
146
|
-
|
|
147
|
-
def try_mean_first(indicator):
|
|
148
|
-
nonlocal freq
|
|
149
|
-
resampled = indicator.df.fillna(np.nan).resample(freq, label='right')
|
|
150
|
-
try:
|
|
151
|
-
return resampled.mean()
|
|
152
|
-
except Exception:
|
|
153
|
-
return resampled.first()
|
|
154
|
-
|
|
155
|
-
indicators = [_Indicator(try_mean_first(i).dropna().reindex(df.index).values.T,
|
|
156
|
-
**dict(i._opts, name=i.name,
|
|
157
|
-
# Replace saved index with the resampled one
|
|
158
|
-
index=df.index))
|
|
159
|
-
for i in indicators]
|
|
160
|
-
assert not indicators or indicators[0].df.index.equals(df.index)
|
|
161
|
-
|
|
162
|
-
equity_data = equity_data.resample(freq, label='right').agg(_EQUITY_AGG).dropna(how='all')
|
|
163
|
-
assert equity_data.index.equals(df.index)
|
|
164
|
-
|
|
165
|
-
def _weighted_returns(s, trades=trades):
|
|
166
|
-
df = trades.loc[s.index]
|
|
167
|
-
return ((df['Size'].abs() * df['ReturnPct']) / df['Size'].abs().sum()).sum()
|
|
168
|
-
|
|
169
|
-
def _group_trades(column):
|
|
170
|
-
def f(s, new_index=pd.Index(df.index.astype(np.int64)), bars=trades[column]):
|
|
171
|
-
if s.size:
|
|
172
|
-
# Via int64 because on pandas recently broken datetime
|
|
173
|
-
mean_time = int(bars.loc[s.index].astype(np.int64).mean())
|
|
174
|
-
new_bar_idx = new_index.get_indexer([mean_time], method='nearest')[0]
|
|
175
|
-
return new_bar_idx
|
|
176
|
-
return f
|
|
177
|
-
|
|
178
|
-
if len(trades): # Avoid pandas "resampling on Int64 index" error
|
|
179
|
-
trades = trades.assign(count=1).resample(freq, on='ExitTime', label='right').agg(dict(
|
|
180
|
-
TRADES_AGG,
|
|
181
|
-
ReturnPct=_weighted_returns,
|
|
182
|
-
count='sum',
|
|
183
|
-
EntryBar=_group_trades('EntryTime'),
|
|
184
|
-
ExitBar=_group_trades('ExitTime'),
|
|
185
|
-
)).dropna()
|
|
186
|
-
|
|
187
|
-
return df, indicators, equity_data, trades
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def plot(*, results: pd.Series,
|
|
191
|
-
df: pd.DataFrame,
|
|
192
|
-
indicators: List[_Indicator],
|
|
193
|
-
filename='', plot_width=None,
|
|
194
|
-
plot_equity=True, plot_return=False, plot_pl=True,
|
|
195
|
-
plot_volume=True, plot_drawdown=False, plot_trades=True,
|
|
196
|
-
smooth_equity=False, relative_equity=True,
|
|
197
|
-
superimpose=True, resample=True,
|
|
198
|
-
reverse_indicators=True,
|
|
199
|
-
show_legend=True, open_browser=True):
|
|
200
|
-
"""
|
|
201
|
-
Like much of GUI code everywhere, this is a mess.
|
|
202
|
-
"""
|
|
203
|
-
# We need to reset global Bokeh state, otherwise subsequent runs of
|
|
204
|
-
# plot() contain some previous run's cruft data (was noticed when
|
|
205
|
-
# TestPlot.test_file_size() test was failing).
|
|
206
|
-
if not filename and not IS_JUPYTER_NOTEBOOK:
|
|
207
|
-
filename = _windos_safe_filename(str(results._strategy))
|
|
208
|
-
_bokeh_reset(filename)
|
|
209
|
-
|
|
210
|
-
COLORS = [BEAR_COLOR, BULL_COLOR]
|
|
211
|
-
BAR_WIDTH = .8
|
|
212
|
-
|
|
213
|
-
assert df.index.equals(results['_equity_curve'].index)
|
|
214
|
-
equity_data = results['_equity_curve'].copy(deep=False)
|
|
215
|
-
trades = results['_trades']
|
|
216
|
-
|
|
217
|
-
plot_volume = plot_volume and not df.Volume.isnull().all()
|
|
218
|
-
plot_equity = plot_equity and not trades.empty
|
|
219
|
-
plot_return = plot_return and not trades.empty
|
|
220
|
-
plot_pl = plot_pl and not trades.empty
|
|
221
|
-
plot_trades = plot_trades and not trades.empty
|
|
222
|
-
is_datetime_index = isinstance(df.index, pd.DatetimeIndex)
|
|
223
|
-
|
|
224
|
-
from .lib import OHLCV_AGG
|
|
225
|
-
# ohlc df may contain many columns. We're only interested in, and pass on to Bokeh, these
|
|
226
|
-
df = df[list(OHLCV_AGG.keys())].copy(deep=False)
|
|
227
|
-
|
|
228
|
-
# Limit data to max_candles
|
|
229
|
-
if is_datetime_index:
|
|
230
|
-
df, indicators, equity_data, trades = _maybe_resample_data(
|
|
231
|
-
resample, df, indicators, equity_data, trades)
|
|
232
|
-
|
|
233
|
-
df.index.name = None # Provides source name @index
|
|
234
|
-
df['datetime'] = df.index # Save original, maybe datetime index
|
|
235
|
-
df = df.reset_index(drop=True)
|
|
236
|
-
equity_data = equity_data.reset_index(drop=True)
|
|
237
|
-
index = df.index
|
|
238
|
-
|
|
239
|
-
new_bokeh_figure = partial( # type: ignore[call-arg]
|
|
240
|
-
_figure,
|
|
241
|
-
x_axis_type='linear',
|
|
242
|
-
width=plot_width,
|
|
243
|
-
height=400,
|
|
244
|
-
# TODO: xwheel_pan on horizontal after https://github.com/bokeh/bokeh/issues/14363
|
|
245
|
-
tools="xpan,xwheel_zoom,xwheel_pan,box_zoom,undo,redo,reset,save",
|
|
246
|
-
active_drag='xpan',
|
|
247
|
-
active_scroll='xwheel_zoom')
|
|
248
|
-
|
|
249
|
-
pad = (index[-1] - index[0]) / 20
|
|
250
|
-
|
|
251
|
-
_kwargs = dict(x_range=Range1d(index[0], index[-1], # type: ignore[call-arg]
|
|
252
|
-
min_interval=10,
|
|
253
|
-
bounds=(index[0] - pad,
|
|
254
|
-
index[-1] + pad))) if index.size > 1 else {}
|
|
255
|
-
fig_ohlc = new_bokeh_figure(**_kwargs) # type: ignore[arg-type]
|
|
256
|
-
figs_above_ohlc, figs_below_ohlc = [], []
|
|
257
|
-
|
|
258
|
-
source = ColumnDataSource(df)
|
|
259
|
-
source.add((df.Close >= df.Open).values.astype(np.uint8).astype(str), 'inc')
|
|
260
|
-
|
|
261
|
-
trade_source = ColumnDataSource(dict(
|
|
262
|
-
index=trades['ExitBar'],
|
|
263
|
-
datetime=trades['ExitTime'],
|
|
264
|
-
size=trades['Size'],
|
|
265
|
-
returns_positive=(trades['ReturnPct'] > 0).astype(int).astype(str),
|
|
266
|
-
))
|
|
267
|
-
|
|
268
|
-
inc_cmap = factor_cmap('inc', COLORS, ['0', '1'])
|
|
269
|
-
cmap = factor_cmap('returns_positive', COLORS, ['0', '1'])
|
|
270
|
-
colors_darker = [lightness(BEAR_COLOR, .35),
|
|
271
|
-
lightness(BULL_COLOR, .35)]
|
|
272
|
-
trades_cmap = factor_cmap('returns_positive', colors_darker, ['0', '1'])
|
|
273
|
-
|
|
274
|
-
if is_datetime_index:
|
|
275
|
-
fig_ohlc.xaxis.formatter = CustomJSTickFormatter( # type: ignore[attr-defined]
|
|
276
|
-
args=dict(axis=fig_ohlc.xaxis[0],
|
|
277
|
-
formatter=DatetimeTickFormatter(days='%a, %d %b',
|
|
278
|
-
months='%m/%Y'),
|
|
279
|
-
source=source),
|
|
280
|
-
code='''
|
|
281
|
-
this.labels = this.labels || formatter.doFormat(ticks
|
|
282
|
-
.map(i => source.data.datetime[i])
|
|
283
|
-
.filter(t => t !== undefined));
|
|
284
|
-
return this.labels[index] || "";
|
|
285
|
-
''')
|
|
286
|
-
|
|
287
|
-
NBSP = '\N{NBSP}' * 4 # noqa: E999
|
|
288
|
-
ohlc_extreme_values = df[['High', 'Low']].copy(deep=False)
|
|
289
|
-
ohlc_tooltips = [
|
|
290
|
-
('x, y', NBSP.join(('$index',
|
|
291
|
-
'$y{0,0.0[0000]}'))),
|
|
292
|
-
('OHLC', NBSP.join(('@Open{0,0.0[0000]}',
|
|
293
|
-
'@High{0,0.0[0000]}',
|
|
294
|
-
'@Low{0,0.0[0000]}',
|
|
295
|
-
'@Close{0,0.0[0000]}'))),
|
|
296
|
-
('Volume', '@Volume{0,0}')]
|
|
297
|
-
|
|
298
|
-
def new_indicator_figure(**kwargs):
|
|
299
|
-
kwargs.setdefault('height', _INDICATOR_HEIGHT)
|
|
300
|
-
fig = new_bokeh_figure(x_range=fig_ohlc.x_range,
|
|
301
|
-
active_scroll='xwheel_zoom',
|
|
302
|
-
active_drag='xpan',
|
|
303
|
-
**kwargs)
|
|
304
|
-
fig.xaxis.visible = False
|
|
305
|
-
fig.yaxis.minor_tick_line_color = None
|
|
306
|
-
fig.yaxis.ticker.desired_num_ticks = 3
|
|
307
|
-
return fig
|
|
308
|
-
|
|
309
|
-
def set_tooltips(fig, tooltips=(), vline=True, renderers=()):
|
|
310
|
-
tooltips = list(tooltips)
|
|
311
|
-
renderers = list(renderers)
|
|
312
|
-
|
|
313
|
-
if is_datetime_index:
|
|
314
|
-
formatters = {'@datetime': 'datetime'}
|
|
315
|
-
tooltips = [("Date", "@datetime{%c}")] + tooltips
|
|
316
|
-
else:
|
|
317
|
-
formatters = {}
|
|
318
|
-
tooltips = [("#", "@index")] + tooltips
|
|
319
|
-
fig.add_tools(HoverTool(
|
|
320
|
-
point_policy='follow_mouse',
|
|
321
|
-
renderers=renderers, formatters=formatters,
|
|
322
|
-
tooltips=tooltips, mode='vline' if vline else 'mouse'))
|
|
323
|
-
|
|
324
|
-
def _plot_equity_section(is_return=False):
|
|
325
|
-
"""Equity section"""
|
|
326
|
-
# Max DD Dur. line
|
|
327
|
-
equity = equity_data['Equity'].copy()
|
|
328
|
-
dd_end = equity_data['DrawdownDuration'].idxmax()
|
|
329
|
-
if np.isnan(dd_end):
|
|
330
|
-
dd_start = dd_end = equity.index[0]
|
|
331
|
-
else:
|
|
332
|
-
dd_start = equity[:dd_end].idxmax()
|
|
333
|
-
# If DD not extending into the future, get exact point of intersection with equity
|
|
334
|
-
if dd_end != equity.index[-1]:
|
|
335
|
-
dd_end = np.interp(equity[dd_start],
|
|
336
|
-
(equity[dd_end - 1], equity[dd_end]),
|
|
337
|
-
(dd_end - 1, dd_end))
|
|
338
|
-
|
|
339
|
-
if smooth_equity:
|
|
340
|
-
interest_points = pd.Index([
|
|
341
|
-
# Beginning and end
|
|
342
|
-
equity.index[0], equity.index[-1],
|
|
343
|
-
# Peak equity and peak DD
|
|
344
|
-
equity.idxmax(), equity_data['DrawdownPct'].idxmax(),
|
|
345
|
-
# Include max dd end points. Otherwise the MaxDD line looks amiss.
|
|
346
|
-
dd_start, int(dd_end), min(int(dd_end + 1), equity.size - 1),
|
|
347
|
-
])
|
|
348
|
-
select = pd.Index(trades['ExitBar']).union(interest_points)
|
|
349
|
-
select = select.unique().dropna()
|
|
350
|
-
equity = equity.iloc[select].reindex(equity.index)
|
|
351
|
-
equity.interpolate(inplace=True)
|
|
352
|
-
|
|
353
|
-
assert equity.index.equals(equity_data.index)
|
|
354
|
-
|
|
355
|
-
if relative_equity:
|
|
356
|
-
equity /= equity.iloc[0]
|
|
357
|
-
if is_return:
|
|
358
|
-
equity -= equity.iloc[0]
|
|
359
|
-
|
|
360
|
-
yaxis_label = 'Return' if is_return else 'Equity'
|
|
361
|
-
source_key = 'eq_return' if is_return else 'equity'
|
|
362
|
-
source.add(equity, source_key)
|
|
363
|
-
fig = new_indicator_figure(
|
|
364
|
-
y_axis_label=yaxis_label,
|
|
365
|
-
**(dict(height=80) if plot_drawdown else dict(height=100)))
|
|
366
|
-
|
|
367
|
-
# High-watermark drawdown dents
|
|
368
|
-
fig.patch('index', 'equity_dd',
|
|
369
|
-
source=ColumnDataSource(dict(
|
|
370
|
-
index=np.r_[index, index[::-1]],
|
|
371
|
-
equity_dd=np.r_[equity, equity.cummax()[::-1]]
|
|
372
|
-
)),
|
|
373
|
-
fill_color='#ffffea', line_color='#ffcb66')
|
|
374
|
-
|
|
375
|
-
# Equity line
|
|
376
|
-
r = fig.line('index', source_key, source=source, line_width=1.5, line_alpha=1)
|
|
377
|
-
if relative_equity:
|
|
378
|
-
tooltip_format = f'@{source_key}{{+0,0.[000]%}}'
|
|
379
|
-
tick_format = '0,0.[00]%'
|
|
380
|
-
legend_format = '{:,.0f}%'
|
|
381
|
-
else:
|
|
382
|
-
tooltip_format = f'@{source_key}{{$ 0,0}}'
|
|
383
|
-
tick_format = '$ 0.0 a'
|
|
384
|
-
legend_format = '${:,.0f}'
|
|
385
|
-
set_tooltips(fig, [(yaxis_label, tooltip_format)], renderers=[r])
|
|
386
|
-
fig.yaxis.formatter = NumeralTickFormatter(format=tick_format)
|
|
387
|
-
|
|
388
|
-
# Peaks
|
|
389
|
-
argmax = equity.idxmax()
|
|
390
|
-
fig.scatter(argmax, equity[argmax],
|
|
391
|
-
legend_label='Peak ({})'.format(
|
|
392
|
-
legend_format.format(equity[argmax] * (100 if relative_equity else 1))),
|
|
393
|
-
color='cyan', size=8)
|
|
394
|
-
fig.scatter(index[-1], equity.values[-1],
|
|
395
|
-
legend_label='Final ({})'.format(
|
|
396
|
-
legend_format.format(equity.iloc[-1] * (100 if relative_equity else 1))),
|
|
397
|
-
color='blue', size=8)
|
|
398
|
-
|
|
399
|
-
if not plot_drawdown:
|
|
400
|
-
drawdown = equity_data['DrawdownPct']
|
|
401
|
-
argmax = drawdown.idxmax()
|
|
402
|
-
fig.scatter(argmax, equity[argmax],
|
|
403
|
-
legend_label='Max Drawdown (-{:.1f}%)'.format(100 * drawdown[argmax]),
|
|
404
|
-
color='red', size=8)
|
|
405
|
-
dd_timedelta_label = df['datetime'].iloc[int(round(dd_end))] - df['datetime'].iloc[dd_start]
|
|
406
|
-
fig.line([dd_start, dd_end], equity.iloc[dd_start],
|
|
407
|
-
line_color='red', line_width=2,
|
|
408
|
-
legend_label=f'Max Dd Dur. ({dd_timedelta_label})'
|
|
409
|
-
.replace(' 00:00:00', '')
|
|
410
|
-
.replace('(0 days ', '('))
|
|
411
|
-
|
|
412
|
-
figs_above_ohlc.append(fig)
|
|
413
|
-
|
|
414
|
-
def _plot_drawdown_section():
|
|
415
|
-
"""Drawdown section"""
|
|
416
|
-
fig = new_indicator_figure(y_axis_label="Drawdown", height=80)
|
|
417
|
-
drawdown = equity_data['DrawdownPct']
|
|
418
|
-
argmax = drawdown.idxmax()
|
|
419
|
-
source.add(drawdown, 'drawdown')
|
|
420
|
-
r = fig.line('index', 'drawdown', source=source, line_width=1.3)
|
|
421
|
-
fig.scatter(argmax, drawdown[argmax],
|
|
422
|
-
legend_label='Peak (-{:.1f}%)'.format(100 * drawdown[argmax]),
|
|
423
|
-
color='red', size=8)
|
|
424
|
-
set_tooltips(fig, [('Drawdown', '@drawdown{-0.[0]%}')], renderers=[r])
|
|
425
|
-
fig.yaxis.formatter = NumeralTickFormatter(format="-0.[0]%")
|
|
426
|
-
return fig
|
|
427
|
-
|
|
428
|
-
def _plot_pl_section():
|
|
429
|
-
"""Profit/Loss markers section"""
|
|
430
|
-
fig = new_indicator_figure(y_axis_label="Profit / Loss", height=80)
|
|
431
|
-
fig.add_layout(Span(location=0, dimension='width', line_color='#666666',
|
|
432
|
-
line_dash='dashed', level='underlay', line_width=1))
|
|
433
|
-
trade_source.add(trades['ReturnPct'], 'returns')
|
|
434
|
-
size = trades['Size'].abs()
|
|
435
|
-
size = np.interp(size, (size.min(), size.max()), (8, 20))
|
|
436
|
-
trade_source.add(size, 'marker_size')
|
|
437
|
-
if 'count' in trades:
|
|
438
|
-
trade_source.add(trades['count'], 'count')
|
|
439
|
-
trade_source.add(trades[['EntryBar', 'ExitBar']].values.tolist(), 'lines')
|
|
440
|
-
fig.multi_line(xs='lines',
|
|
441
|
-
ys=transform('returns', CustomJSTransform(v_func='return [...xs].map(i => [0, i]);')),
|
|
442
|
-
source=trade_source, color='#999', line_width=1)
|
|
443
|
-
trade_source.add(np.take(['inverted_triangle', 'triangle'], trades['Size'] > 0), 'triangles')
|
|
444
|
-
r1 = fig.scatter(
|
|
445
|
-
'index', 'returns', source=trade_source, fill_color=cmap,
|
|
446
|
-
marker='triangles', line_color='black', size='marker_size')
|
|
447
|
-
tooltips = [("Size", "@size{0,0}")]
|
|
448
|
-
if 'count' in trades:
|
|
449
|
-
tooltips.append(("Count", "@count{0,0}"))
|
|
450
|
-
set_tooltips(fig, tooltips + [("P/L", "@returns{+0.[000]%}")],
|
|
451
|
-
vline=False, renderers=[r1])
|
|
452
|
-
fig.yaxis.formatter = NumeralTickFormatter(format="0.[00]%")
|
|
453
|
-
return fig
|
|
454
|
-
|
|
455
|
-
def _plot_volume_section():
|
|
456
|
-
"""Volume section"""
|
|
457
|
-
fig = new_indicator_figure(height=70, y_axis_label="Volume")
|
|
458
|
-
fig.yaxis.ticker.desired_num_ticks = 3
|
|
459
|
-
fig.xaxis.formatter = fig_ohlc.xaxis[0].formatter
|
|
460
|
-
fig.xaxis.visible = True
|
|
461
|
-
fig_ohlc.xaxis.visible = False # Show only Volume's xaxis
|
|
462
|
-
r = fig.vbar('index', BAR_WIDTH, 'Volume', source=source, color=inc_cmap)
|
|
463
|
-
set_tooltips(fig, [('Volume', '@Volume{0.00 a}')], renderers=[r])
|
|
464
|
-
fig.yaxis.formatter = NumeralTickFormatter(format="0 a")
|
|
465
|
-
return fig
|
|
466
|
-
|
|
467
|
-
def _plot_superimposed_ohlc():
|
|
468
|
-
"""Superimposed, downsampled vbars"""
|
|
469
|
-
time_resolution = pd.DatetimeIndex(df['datetime']).resolution
|
|
470
|
-
resample_rule = (superimpose if isinstance(superimpose, str) else
|
|
471
|
-
dict(day='ME',
|
|
472
|
-
hour='D',
|
|
473
|
-
minute='h',
|
|
474
|
-
second='min',
|
|
475
|
-
millisecond='s').get(time_resolution))
|
|
476
|
-
if not resample_rule:
|
|
477
|
-
warnings.warn(
|
|
478
|
-
f"'Can't superimpose OHLC data with rule '{resample_rule}'"
|
|
479
|
-
f"(index datetime resolution: '{time_resolution}'). Skipping.",
|
|
480
|
-
stacklevel=4)
|
|
481
|
-
return
|
|
482
|
-
|
|
483
|
-
df2 = (df.assign(_width=1).set_index('datetime')
|
|
484
|
-
.resample(resample_rule, label='left')
|
|
485
|
-
.agg(dict(OHLCV_AGG, _width='count')))
|
|
486
|
-
|
|
487
|
-
# Check if resampling was downsampling; error on upsampling
|
|
488
|
-
orig_freq = _data_period(df['datetime'])
|
|
489
|
-
resample_freq = _data_period(df2.index)
|
|
490
|
-
if resample_freq < orig_freq:
|
|
491
|
-
raise ValueError('Invalid value for `superimpose`: Upsampling not supported.')
|
|
492
|
-
if resample_freq == orig_freq:
|
|
493
|
-
warnings.warn('Superimposed OHLC plot matches the original plot. Skipping.',
|
|
494
|
-
stacklevel=4)
|
|
495
|
-
return
|
|
496
|
-
|
|
497
|
-
df2.index = df2['_width'].cumsum().shift(1).fillna(0)
|
|
498
|
-
df2.index += df2['_width'] / 2 - .5
|
|
499
|
-
df2['_width'] -= .1 # Candles don't touch
|
|
500
|
-
|
|
501
|
-
df2['inc'] = (df2.Close >= df2.Open).astype(int).astype(str)
|
|
502
|
-
df2.index.name = None
|
|
503
|
-
source2 = ColumnDataSource(df2)
|
|
504
|
-
fig_ohlc.segment('index', 'High', 'index', 'Low', source=source2, color='#bbbbbb')
|
|
505
|
-
colors_lighter = [lightness(BEAR_COLOR, .92),
|
|
506
|
-
lightness(BULL_COLOR, .92)]
|
|
507
|
-
fig_ohlc.vbar('index', '_width', 'Open', 'Close', source=source2, line_color=None,
|
|
508
|
-
fill_color=factor_cmap('inc', colors_lighter, ['0', '1']))
|
|
509
|
-
|
|
510
|
-
def _plot_ohlc():
|
|
511
|
-
"""Main OHLC bars"""
|
|
512
|
-
fig_ohlc.segment('index', 'High', 'index', 'Low', source=source, color="black",
|
|
513
|
-
legend_label='OHLC')
|
|
514
|
-
r = fig_ohlc.vbar('index', BAR_WIDTH, 'Open', 'Close', source=source,
|
|
515
|
-
line_color="black", fill_color=inc_cmap, legend_label='OHLC')
|
|
516
|
-
return r
|
|
517
|
-
|
|
518
|
-
def _plot_ohlc_trades():
|
|
519
|
-
"""Trade entry / exit markers on OHLC plot"""
|
|
520
|
-
trade_source.add(trades[['EntryBar', 'ExitBar']].values.tolist(), 'position_lines_xs')
|
|
521
|
-
trade_source.add(trades[['EntryPrice', 'ExitPrice']].values.tolist(), 'position_lines_ys')
|
|
522
|
-
fig_ohlc.multi_line(xs='position_lines_xs', ys='position_lines_ys',
|
|
523
|
-
source=trade_source, line_color=trades_cmap,
|
|
524
|
-
legend_label=f'Trades ({len(trades)})',
|
|
525
|
-
line_width=8, line_alpha=1, line_dash='dotted')
|
|
526
|
-
|
|
527
|
-
def _plot_indicators():
|
|
528
|
-
"""Strategy indicators"""
|
|
529
|
-
|
|
530
|
-
def _too_many_dims(value):
|
|
531
|
-
assert value.ndim >= 2
|
|
532
|
-
if value.ndim > 2:
|
|
533
|
-
warnings.warn(f"Can't plot indicators with >2D ('{value.name}')",
|
|
534
|
-
stacklevel=5)
|
|
535
|
-
return True
|
|
536
|
-
return False
|
|
537
|
-
|
|
538
|
-
class LegendStr(str):
|
|
539
|
-
# The legend string is such a string that only matches
|
|
540
|
-
# itself if it's the exact same object. This ensures
|
|
541
|
-
# legend items are listed separately even when they have the
|
|
542
|
-
# same string contents. Otherwise, Bokeh would always consider
|
|
543
|
-
# equal strings as one and the same legend item.
|
|
544
|
-
def __eq__(self, other):
|
|
545
|
-
return self is other
|
|
546
|
-
|
|
547
|
-
ohlc_colors = colorgen()
|
|
548
|
-
indicator_figs = []
|
|
549
|
-
|
|
550
|
-
for i, value in enumerate(indicators):
|
|
551
|
-
value = np.atleast_2d(value)
|
|
552
|
-
if _too_many_dims(value):
|
|
553
|
-
continue
|
|
554
|
-
|
|
555
|
-
# Use .get()! A user might have assigned a Strategy.data-evolved
|
|
556
|
-
# _Array without Strategy.I()
|
|
557
|
-
is_overlay = value._opts.get('overlay')
|
|
558
|
-
is_scatter = value._opts.get('scatter')
|
|
559
|
-
is_muted = not value._opts.get('plot')
|
|
560
|
-
|
|
561
|
-
# is overlay => show muted, hide legend item. non-overlay => don't show at all
|
|
562
|
-
if is_muted and not is_overlay:
|
|
563
|
-
continue
|
|
564
|
-
|
|
565
|
-
if is_overlay:
|
|
566
|
-
fig = fig_ohlc
|
|
567
|
-
else:
|
|
568
|
-
fig = new_indicator_figure()
|
|
569
|
-
indicator_figs.append(fig)
|
|
570
|
-
tooltips = []
|
|
571
|
-
colors = value._opts['color']
|
|
572
|
-
colors = colors and cycle(_as_list(colors)) or (
|
|
573
|
-
cycle([next(ohlc_colors)]) if is_overlay else colorgen())
|
|
574
|
-
|
|
575
|
-
if isinstance(value.name, str):
|
|
576
|
-
tooltip_label = value.name
|
|
577
|
-
legend_labels = [LegendStr(value.name)] * len(value)
|
|
578
|
-
else:
|
|
579
|
-
tooltip_label = ", ".join(value.name)
|
|
580
|
-
legend_labels = [LegendStr(item) for item in value.name]
|
|
581
|
-
|
|
582
|
-
for j, arr in enumerate(value):
|
|
583
|
-
color = next(colors)
|
|
584
|
-
source_name = f'{legend_labels[j]}_{i}_{j}'
|
|
585
|
-
if arr.dtype == bool:
|
|
586
|
-
arr = arr.astype(int)
|
|
587
|
-
source.add(arr, source_name)
|
|
588
|
-
tooltips.append(f'@{{{source_name}}}{{0,0.0[0000]}}')
|
|
589
|
-
kwargs = {}
|
|
590
|
-
if not is_muted:
|
|
591
|
-
kwargs['legend_label'] = legend_labels[j]
|
|
592
|
-
if is_overlay:
|
|
593
|
-
ohlc_extreme_values[source_name] = arr
|
|
594
|
-
if is_scatter:
|
|
595
|
-
r2 = fig.circle(
|
|
596
|
-
'index', source_name, source=source,
|
|
597
|
-
color=color, line_color='black', fill_alpha=.8,
|
|
598
|
-
radius=BAR_WIDTH / 2 * .9, **kwargs)
|
|
599
|
-
else:
|
|
600
|
-
r2 = fig.line(
|
|
601
|
-
'index', source_name, source=source,
|
|
602
|
-
line_color=color, line_width=1.4 if is_muted else 1.5, **kwargs)
|
|
603
|
-
# r != r2
|
|
604
|
-
r2.muted = is_muted
|
|
605
|
-
else:
|
|
606
|
-
if is_scatter:
|
|
607
|
-
r = fig.circle(
|
|
608
|
-
'index', source_name, source=source,
|
|
609
|
-
color=color, radius=BAR_WIDTH / 2 * .6, **kwargs)
|
|
610
|
-
else:
|
|
611
|
-
r = fig.line(
|
|
612
|
-
'index', source_name, source=source,
|
|
613
|
-
line_color=color, line_width=1.3, **kwargs)
|
|
614
|
-
# Add dashed centerline just because
|
|
615
|
-
mean = try_(lambda: float(pd.Series(arr).mean()), default=np.nan)
|
|
616
|
-
if not np.isnan(mean) and (abs(mean) < .1 or
|
|
617
|
-
round(abs(mean), 1) == .5 or
|
|
618
|
-
round(abs(mean), -1) in (50, 100, 200)):
|
|
619
|
-
fig.add_layout(Span(location=float(mean), dimension='width',
|
|
620
|
-
line_color='#666666', line_dash='dashed',
|
|
621
|
-
level='underlay', line_width=.5))
|
|
622
|
-
if is_overlay:
|
|
623
|
-
ohlc_tooltips.append((tooltip_label, NBSP.join(tooltips)))
|
|
624
|
-
else:
|
|
625
|
-
set_tooltips(fig, [(tooltip_label, NBSP.join(tooltips))], vline=True, renderers=[r])
|
|
626
|
-
# If the sole indicator line on this figure,
|
|
627
|
-
# have the legend only contain text without the glyph
|
|
628
|
-
if len(value) == 1:
|
|
629
|
-
fig.legend.glyph_width = 0
|
|
630
|
-
return indicator_figs
|
|
631
|
-
|
|
632
|
-
# Construct figure ...
|
|
633
|
-
|
|
634
|
-
if plot_equity:
|
|
635
|
-
_plot_equity_section()
|
|
636
|
-
|
|
637
|
-
if plot_return:
|
|
638
|
-
_plot_equity_section(is_return=True)
|
|
639
|
-
|
|
640
|
-
if plot_drawdown:
|
|
641
|
-
figs_above_ohlc.append(_plot_drawdown_section())
|
|
642
|
-
|
|
643
|
-
if plot_pl:
|
|
644
|
-
figs_above_ohlc.append(_plot_pl_section())
|
|
645
|
-
|
|
646
|
-
if plot_volume:
|
|
647
|
-
fig_volume = _plot_volume_section()
|
|
648
|
-
figs_below_ohlc.append(fig_volume)
|
|
649
|
-
|
|
650
|
-
if superimpose and is_datetime_index:
|
|
651
|
-
_plot_superimposed_ohlc()
|
|
652
|
-
|
|
653
|
-
ohlc_bars = _plot_ohlc()
|
|
654
|
-
if plot_trades:
|
|
655
|
-
_plot_ohlc_trades()
|
|
656
|
-
indicator_figs = _plot_indicators()
|
|
657
|
-
if reverse_indicators:
|
|
658
|
-
indicator_figs = indicator_figs[::-1]
|
|
659
|
-
figs_below_ohlc.extend(indicator_figs)
|
|
660
|
-
|
|
661
|
-
_watermark(fig_ohlc)
|
|
662
|
-
|
|
663
|
-
set_tooltips(fig_ohlc, ohlc_tooltips, vline=True, renderers=[ohlc_bars])
|
|
664
|
-
|
|
665
|
-
source.add(ohlc_extreme_values.min(1), 'ohlc_low')
|
|
666
|
-
source.add(ohlc_extreme_values.max(1), 'ohlc_high')
|
|
667
|
-
|
|
668
|
-
custom_js_args = dict(ohlc_range=fig_ohlc.y_range,
|
|
669
|
-
source=source)
|
|
670
|
-
if plot_volume:
|
|
671
|
-
custom_js_args.update(volume_range=fig_volume.y_range)
|
|
672
|
-
|
|
673
|
-
fig_ohlc.x_range.js_on_change('end', CustomJS(args=custom_js_args,
|
|
674
|
-
code=_AUTOSCALE_JS_CALLBACK))
|
|
675
|
-
|
|
676
|
-
figs = figs_above_ohlc + [fig_ohlc] + figs_below_ohlc
|
|
677
|
-
linked_crosshair = CrosshairTool(
|
|
678
|
-
dimensions='both', line_color='lightgrey',
|
|
679
|
-
overlay=(Span(dimension="width", line_dash="dotted", line_width=1),
|
|
680
|
-
Span(dimension="height", line_dash="dotted", line_width=1)),
|
|
681
|
-
)
|
|
682
|
-
|
|
683
|
-
for f in figs:
|
|
684
|
-
if f.legend:
|
|
685
|
-
f.legend.visible = show_legend
|
|
686
|
-
f.legend.location = 'top_left'
|
|
687
|
-
f.legend.border_line_width = 1
|
|
688
|
-
f.legend.border_line_color = '#333333'
|
|
689
|
-
f.legend.padding = 5
|
|
690
|
-
f.legend.spacing = 0
|
|
691
|
-
f.legend.margin = 0
|
|
692
|
-
f.legend.label_text_font_size = '8pt'
|
|
693
|
-
f.legend.click_policy = "hide"
|
|
694
|
-
f.legend.background_fill_alpha = .9
|
|
695
|
-
f.min_border_left = 0
|
|
696
|
-
f.min_border_top = 3
|
|
697
|
-
f.min_border_bottom = 6
|
|
698
|
-
f.min_border_right = 10
|
|
699
|
-
f.outline_line_color = '#666666'
|
|
700
|
-
|
|
701
|
-
f.add_tools(linked_crosshair)
|
|
702
|
-
wheelzoom_tool = next(wz for wz in f.tools if isinstance(wz, WheelZoomTool))
|
|
703
|
-
wheelzoom_tool.maintain_focus = False
|
|
704
|
-
|
|
705
|
-
kwargs = {}
|
|
706
|
-
if plot_width is None:
|
|
707
|
-
kwargs['sizing_mode'] = 'stretch_width'
|
|
708
|
-
|
|
709
|
-
fig = gridplot(
|
|
710
|
-
figs,
|
|
711
|
-
ncols=1,
|
|
712
|
-
toolbar_location='right',
|
|
713
|
-
toolbar_options=dict(logo=None),
|
|
714
|
-
merge_tools=True,
|
|
715
|
-
**kwargs # type: ignore
|
|
716
|
-
)
|
|
717
|
-
show(fig, browser=None if open_browser else 'none')
|
|
718
|
-
return fig
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
def plot_heatmaps(heatmap: pd.Series, agg: Union[Callable, str], ncols: int,
|
|
722
|
-
filename: str = '', plot_width: int = 1200, open_browser: bool = True):
|
|
723
|
-
if not (isinstance(heatmap, pd.Series) and
|
|
724
|
-
isinstance(heatmap.index, pd.MultiIndex)):
|
|
725
|
-
raise ValueError('heatmap must be heatmap Series as returned by '
|
|
726
|
-
'`Backtest.optimize(..., return_heatmap=True)`')
|
|
727
|
-
if len(heatmap.index.levels) < 2:
|
|
728
|
-
raise ValueError('`plot_heatmap()` requires at least two optimization '
|
|
729
|
-
'variables to plot')
|
|
730
|
-
|
|
731
|
-
_bokeh_reset(filename)
|
|
732
|
-
|
|
733
|
-
param_combinations = combinations(heatmap.index.names, 2)
|
|
734
|
-
dfs = [heatmap.groupby(list(dims)).agg(agg).to_frame(name='_Value')
|
|
735
|
-
for dims in param_combinations]
|
|
736
|
-
figs: list[_figure] = []
|
|
737
|
-
cmap = LinearColorMapper(palette='Viridis256',
|
|
738
|
-
low=min(df.min().min() for df in dfs),
|
|
739
|
-
high=max(df.max().max() for df in dfs),
|
|
740
|
-
nan_color='white')
|
|
741
|
-
for df in dfs:
|
|
742
|
-
name1, name2 = df.index.names
|
|
743
|
-
level1 = df.index.levels[0].astype(str).tolist()
|
|
744
|
-
level2 = df.index.levels[1].astype(str).tolist()
|
|
745
|
-
df = df.reset_index()
|
|
746
|
-
df[name1] = df[name1].astype('str')
|
|
747
|
-
df[name2] = df[name2].astype('str')
|
|
748
|
-
|
|
749
|
-
fig = _figure(x_range=level1, # type: ignore[call-arg]
|
|
750
|
-
y_range=level2,
|
|
751
|
-
x_axis_label=name1,
|
|
752
|
-
y_axis_label=name2,
|
|
753
|
-
width=plot_width // ncols,
|
|
754
|
-
height=plot_width // ncols,
|
|
755
|
-
tools='box_zoom,reset,save',
|
|
756
|
-
tooltips=[(name1, '@' + name1),
|
|
757
|
-
(name2, '@' + name2),
|
|
758
|
-
('Value', '@_Value{0.[000]}')])
|
|
759
|
-
fig.grid.grid_line_color = None # type: ignore[attr-defined]
|
|
760
|
-
fig.axis.axis_line_color = None # type: ignore[attr-defined]
|
|
761
|
-
fig.axis.major_tick_line_color = None # type: ignore[attr-defined]
|
|
762
|
-
fig.axis.major_label_standoff = 0 # type: ignore[attr-defined]
|
|
763
|
-
|
|
764
|
-
if not len(figs):
|
|
765
|
-
_watermark(fig)
|
|
766
|
-
|
|
767
|
-
fig.rect(x=name1,
|
|
768
|
-
y=name2,
|
|
769
|
-
width=1,
|
|
770
|
-
height=1,
|
|
771
|
-
source=df,
|
|
772
|
-
line_color=None,
|
|
773
|
-
fill_color=dict(field='_Value',
|
|
774
|
-
transform=cmap))
|
|
775
|
-
figs.append(fig)
|
|
776
|
-
|
|
777
|
-
fig = gridplot(
|
|
778
|
-
figs, # type: ignore
|
|
779
|
-
ncols=ncols,
|
|
780
|
-
toolbar_options=dict(logo=None),
|
|
781
|
-
toolbar_location='above',
|
|
782
|
-
merge_tools=True,
|
|
783
|
-
)
|
|
784
|
-
show(fig, browser=None if open_browser else 'none')
|
|
785
|
-
return fig
|