4
4
import warnings
5
5
from itertools import cycle , combinations
6
6
from functools import partial
7
+ from typing import List
7
8
8
9
import numpy as np
9
10
import pandas as pd
31
32
from bokeh .palettes import Category10
32
33
from bokeh .transform import factor_cmap
33
34
34
- from backtesting ._util import _data_period , _as_list
35
-
35
+ from backtesting ._util import _data_period , _as_list , _Indicator
36
36
37
37
with open (os .path .join (os .path .dirname (__file__ ), 'autoscale_cb.js' ),
38
38
encoding = 'utf-8' ) as _f :
@@ -85,10 +85,13 @@ def lightness(color, lightness=.94):
85
85
return color .to_rgb ()
86
86
87
87
88
- def plot (* , results , df , indicators , filename = '' , plot_width = None ,
88
+ def plot (* , results : pd .Series ,
89
+ df : pd .DataFrame ,
90
+ indicators : List [_Indicator ],
91
+ filename = '' , plot_width = None ,
89
92
plot_equity = True , plot_pl = True ,
90
93
plot_volume = True , plot_drawdown = False ,
91
- smooth_equity = False , relative_equity = True , omit_missing = True ,
94
+ smooth_equity = False , relative_equity = True ,
92
95
superimpose = True , show_legend = True , open_browser = True ):
93
96
"""
94
97
Like much of GUI code everywhere, this is a mess.
@@ -101,41 +104,29 @@ def plot(*, results, df, indicators, filename='', plot_width=None,
101
104
_bokeh_reset (filename )
102
105
103
106
COLORS = [BEAR_COLOR , BULL_COLOR ]
107
+ BAR_WIDTH = .8
104
108
105
- equity_data = results ['_equity_curve' ].copy (False )
109
+ assert df .index .equals (results ['_equity_curve' ].index )
110
+ equity_data = results ['_equity_curve' ].copy (deep = False )
106
111
trades = results ['_trades' ]
107
112
108
- orig_df = df = df .copy (False )
109
- df .index .name = None # Provides source name @index
110
- index = df .index
111
- assert df .index .equals (equity_data .index )
112
- time_resolution = getattr (index , 'resolution' , None )
113
- is_datetime_index = index .is_all_dates
114
-
115
- # If all Volume is NaN, don't plot volume
116
113
plot_volume = plot_volume and not df .Volume .isnull ().all ()
114
+ time_resolution = getattr (df .index , 'resolution' , None )
115
+ is_datetime_index = df .index .is_all_dates
117
116
118
- # OHLC vbar width in msec.
119
- # +1 will work in case of non-datetime index where vbar width should just be =1
120
- bar_width = 1 + dict (day = 86400 ,
121
- hour = 3600 ,
122
- minute = 60 ,
123
- second = 1 ).get (time_resolution , 0 ) * 1000 * .85
124
-
125
- if is_datetime_index :
126
- # Add index as a separate data source column because true .index is offset to align vbars
127
- df ['datetime' ] = index
128
- df .index = df .index + pd .Timedelta (bar_width / 2 , unit = 'ms' )
117
+ from .lib import OHLCV_AGG
118
+ # ohlc df may contain many columns. We're only interested in, and pass on to Bokeh, these
119
+ df = df [list (OHLCV_AGG .keys ())].copy (deep = False )
120
+ df .index .name = None # Provides source name @index
121
+ df ['datetime' ] = df .index # Save original, maybe datetime index
129
122
130
- if omit_missing :
131
- bar_width = .8
132
- df = df .reset_index (drop = True )
133
- equity_data = equity_data .reset_index (drop = True )
134
- index = df .index
123
+ df = df .reset_index (drop = True )
124
+ equity_data = equity_data .reset_index (drop = True )
125
+ index = df .index
135
126
136
127
new_bokeh_figure = partial (
137
128
_figure ,
138
- x_axis_type = 'datetime' if is_datetime_index and not omit_missing else ' linear' ,
129
+ x_axis_type = 'linear' ,
139
130
plot_width = plot_width ,
140
131
plot_height = 400 ,
141
132
tools = "xpan,xwheel_zoom,box_zoom,undo,redo,reset,crosshair,save" ,
@@ -152,11 +143,8 @@ def plot(*, results, df, indicators, filename='', plot_width=None,
152
143
153
144
source = ColumnDataSource (df )
154
145
source .add ((df .Close >= df .Open ).values .astype (np .uint8 ).astype (str ), 'inc' )
155
- trades_index = trades ['ExitBar' ]
156
- if not omit_missing :
157
- trades_index = index [trades_index .astype (int )]
158
146
trade_source = ColumnDataSource (dict (
159
- index = trades_index ,
147
+ index = trades [ 'ExitBar' ] ,
160
148
datetime = trades ['ExitTime' ],
161
149
exit_price = trades ['ExitPrice' ],
162
150
returns_positive = (trades ['ReturnPct' ] > 0 ).astype (int ).astype (str ),
@@ -168,7 +156,7 @@ def plot(*, results, df, indicators, filename='', plot_width=None,
168
156
lightness (BULL_COLOR , .35 )]
169
157
trades_cmap = factor_cmap ('returns_positive' , colors_darker , ['0' , '1' ])
170
158
171
- if is_datetime_index and omit_missing :
159
+ if is_datetime_index :
172
160
fig_ohlc .xaxis .formatter = FuncTickFormatter (
173
161
args = dict (axis = fig_ohlc .xaxis [0 ],
174
162
formatter = DatetimeTickFormatter (days = ['%d %b' , '%a %d' ],
@@ -182,7 +170,7 @@ def plot(*, results, df, indicators, filename='', plot_width=None,
182
170
''' )
183
171
184
172
NBSP = ' ' * 4
185
- ohlc_extreme_values = df [['High' , 'Low' ]].copy (False )
173
+ ohlc_extreme_values = df [['High' , 'Low' ]].copy (deep = False )
186
174
ohlc_tooltips = [
187
175
('x, y' , NBSP .join (('$index' ,
188
176
'$y{0,0.0[0000]}' ))),
@@ -220,39 +208,36 @@ def set_tooltips(fig, tooltips=(), vline=True, renderers=(), show_arrow=True):
220
208
def _plot_equity_section ():
221
209
"""Equity section"""
222
210
# Max DD Dur. line
223
- equity = equity_data ['Equity' ].reset_index (drop = True )
224
- argmax = equity_data ['DrawdownDuration' ].reset_index (drop = True ).idxmax ()
225
- try :
226
- dd_start = equity [:argmax ].idxmax ()
227
- except Exception : # ValueError: attempt to get argmax of an empty sequence
211
+ equity = equity_data ['Equity' ].copy ()
212
+ max_dd_loc = equity_data ['DrawdownPct' ].idxmax ()
213
+ dd_end = equity_data ['DrawdownDuration' ].idxmax ()
214
+ if np .isnan (dd_end ):
228
215
dd_start = dd_end = equity .index [0 ]
229
- timedelta = 0
216
+ dd_timedelta_label = 0
230
217
else :
231
- dd_end = argmax
232
- if is_datetime_index and omit_missing :
233
- # "Calendar" duration
234
- timedelta = df .datetime .iloc [dd_end ] - df .datetime .iloc [dd_start ]
235
- else :
236
- timedelta = dd_end - dd_start
218
+ dd_start = equity [:dd_end ].idxmax ()
219
+ dd_timedelta_label = df ['datetime' ].iloc [dd_end ] - df ['datetime' ].iloc [dd_start ]
237
220
# Get point intersection
238
221
if dd_end != equity .index [- 1 ]:
239
222
x1 , x2 = dd_end - 1 , dd_end
240
223
y , y1 , y2 = equity [dd_start ], equity [x1 ], equity [x2 ]
241
224
dd_end -= (1 - (y - y1 ) / (y2 - y1 )) * (dd_end - x1 ) # y = a x + b
242
225
243
226
if smooth_equity :
244
- select = (pd .Index (trades ['ExitBar' ]) |
245
- # Include beginning and end
246
- equity .index [:1 ] | equity .index [- 1 :] |
247
- # Include peak equity and peak DD
248
- pd .Index ([equity .idxmax (), argmax ]) |
249
- # Include max dd end points. Otherwise the MaxDD line looks amiss.
250
- pd .Index ([dd_start , int (dd_end ), min (equity .size - 1 , int (dd_end + 1 ))]))
227
+ interest_points = pd .Index ([
228
+ # Beginning and end
229
+ equity .index [0 ], equity .index [- 1 ],
230
+ # Peak equity and peak DD
231
+ equity .idxmax (), max_dd_loc ,
232
+ # Include max dd end points. Otherwise the MaxDD line looks amiss.
233
+ dd_start , int (dd_end ), min (int (dd_end + 1 ), equity .size - 1 ),
234
+ ])
235
+ select = pd .Index (trades ['ExitBar' ]) | interest_points
251
236
select = select .unique ().dropna ()
252
237
equity = equity .iloc [select ].reindex (equity .index )
253
238
equity .interpolate (inplace = True )
254
239
255
- equity .index = equity_data .index
240
+ assert equity .index . equals ( equity_data .index )
256
241
257
242
if relative_equity :
258
243
equity /= equity .iloc [0 ]
@@ -302,7 +287,7 @@ def _plot_equity_section():
302
287
color = 'red' , size = 8 )
303
288
fig .line ([index [dd_start ], index [int (dd_end )]], equity .iloc [dd_start ],
304
289
line_color = 'red' , line_width = 2 ,
305
- legend_label = 'Max Dd Dur. ({})' .format (timedelta )
290
+ legend_label = 'Max Dd Dur. ({})' .format (dd_timedelta_label )
306
291
.replace (' 00:00:00' , '' )
307
292
.replace ('(0 days ' , '(' ))
308
293
@@ -347,7 +332,7 @@ def _plot_volume_section():
347
332
fig .xaxis .formatter = fig_ohlc .xaxis [0 ].formatter
348
333
fig .xaxis .visible = True
349
334
fig_ohlc .xaxis .visible = False # Show only Volume's xaxis
350
- r = fig .vbar ('index' , bar_width , 'Volume' , source = source , color = inc_cmap )
335
+ r = fig .vbar ('index' , BAR_WIDTH , 'Volume' , source = source , color = inc_cmap )
351
336
set_tooltips (fig , [('Volume' , '@Volume{0.00 a}' )], renderers = [r ])
352
337
fig .yaxis .formatter = NumeralTickFormatter (format = "0 a" )
353
338
return fig
@@ -367,48 +352,37 @@ def _plot_superimposed_ohlc():
367
352
stacklevel = 4 )
368
353
return
369
354
370
- orig_df [ '_width' ] = 1
371
- from . lib import OHLCV_AGG
372
- df2 = orig_df . resample ( resample_rule , label = 'left' ) .agg (dict (OHLCV_AGG , _width = 'count' ))
355
+ df2 = ( df . assign ( _width = 1 ). set_index ( 'datetime' )
356
+ . resample ( resample_rule , label = 'left' )
357
+ .agg (dict (OHLCV_AGG , _width = 'count' ) ))
373
358
374
359
# Check if resampling was downsampling; error on upsampling
375
- orig_freq = _data_period (orig_df )
376
- resample_freq = _data_period (df2 )
360
+ orig_freq = _data_period (df [ 'datetime' ] )
361
+ resample_freq = _data_period (df2 . index )
377
362
if resample_freq < orig_freq :
378
363
raise ValueError ('Invalid value for `superimpose`: Upsampling not supported.' )
379
364
if resample_freq == orig_freq :
380
365
warnings .warn ('Superimposed OHLC plot matches the original plot. Skipping.' ,
381
366
stacklevel = 4 )
382
367
return
383
368
384
- if omit_missing :
385
- width2 = '_width'
386
- df2 .index = df2 ['_width' ].cumsum ().shift (1 ).fillna (0 )
387
- df2 .index += df2 ['_width' ] / 2 - .5
388
- df2 ['_width' ] -= .1 # Candles don't touch
389
- else :
390
- del df ['_width' ]
391
- width2 = dict (day = 86400 * 5 ,
392
- hour = 86400 ,
393
- minute = 3600 ,
394
- second = 60 )[time_resolution ] * 1000
395
- df2 .index += pd .Timedelta (
396
- width2 / 2 +
397
- (width2 / 5 if resample_rule == 'W' else 0 ), # Sunday week start
398
- unit = 'ms' )
399
- df2 ['inc' ] = (df2 .Close >= df2 .Open ).astype (np .uint8 ).astype (str )
369
+ df2 .index = df2 ['_width' ].cumsum ().shift (1 ).fillna (0 )
370
+ df2 .index += df2 ['_width' ] / 2 - .5
371
+ df2 ['_width' ] -= .1 # Candles don't touch
372
+
373
+ df2 ['inc' ] = (df2 .Close >= df2 .Open ).astype (int ).astype (str )
400
374
df2 .index .name = None
401
375
source2 = ColumnDataSource (df2 )
402
376
fig_ohlc .segment ('index' , 'High' , 'index' , 'Low' , source = source2 , color = '#bbbbbb' )
403
377
colors_lighter = [lightness (BEAR_COLOR , .92 ),
404
378
lightness (BULL_COLOR , .92 )]
405
- fig_ohlc .vbar ('index' , width2 , 'Open' , 'Close' , source = source2 , line_color = None ,
379
+ fig_ohlc .vbar ('index' , '_width' , 'Open' , 'Close' , source = source2 , line_color = None ,
406
380
fill_color = factor_cmap ('inc' , colors_lighter , ['0' , '1' ]))
407
381
408
382
def _plot_ohlc ():
409
383
"""Main OHLC bars"""
410
384
fig_ohlc .segment ('index' , 'High' , 'index' , 'Low' , source = source , color = "black" )
411
- r = fig_ohlc .vbar ('index' , bar_width , 'Open' , 'Close' , source = source ,
385
+ r = fig_ohlc .vbar ('index' , BAR_WIDTH , 'Open' , 'Close' , source = source ,
412
386
line_color = "black" , fill_color = inc_cmap )
413
387
return r
414
388
@@ -477,7 +451,7 @@ def __eq__(self, other):
477
451
'index' , source_name , source = source ,
478
452
legend_label = legend_label , color = color ,
479
453
line_color = 'black' , fill_alpha = .8 ,
480
- marker = 'circle' , radius = bar_width / 2 * 1.5 )
454
+ marker = 'circle' , radius = BAR_WIDTH / 2 * 1.5 )
481
455
else :
482
456
fig .line (
483
457
'index' , source_name , source = source ,
@@ -488,7 +462,7 @@ def __eq__(self, other):
488
462
r = fig .scatter (
489
463
'index' , source_name , source = source ,
490
464
legend_label = LegendStr (legend_label ), color = color ,
491
- marker = 'circle' , radius = bar_width / 2 * .9 )
465
+ marker = 'circle' , radius = BAR_WIDTH / 2 * .9 )
492
466
else :
493
467
r = fig .line (
494
468
'index' , source_name , source = source ,
0 commit comments