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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -0,0 +1,785 @@
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