diff --git a/backtesting/_plotting.py b/backtesting/_plotting.py index 368b14f6..6b628370 100644 --- a/backtesting/_plotting.py +++ b/backtesting/_plotting.py @@ -513,6 +513,7 @@ def __eq__(self, other): is_overlay = value._opts['overlay'] is_scatter = value._opts['scatter'] + is_histogram = value._opts['histogram'] if is_overlay: fig = fig_ohlc else: @@ -526,6 +527,10 @@ def __eq__(self, other): legends = legends and cycle(_as_list(legends)) indicator_name = value.name legend_label = LegendStr(indicator_name) + indicator_max = value.df.max(axis='columns') + indicator_min = value.df.min(axis='columns') + source.add(indicator_max, f'indicator_{i}_range_max') + source.add(indicator_min, f'indicator_{i}_range_min') for j, arr in enumerate(value, 1): color = next(colors) legend_label = next(legends) if legends is not None else legend_label @@ -536,7 +541,10 @@ def __eq__(self, other): tooltips.append(f'@{{{source_name}}}{{0,0.0[0000]}}') if is_overlay: ohlc_extreme_values[source_name] = arr - if is_scatter: + if is_histogram: + fig.vbar('index', BAR_WIDTH, source_name, source=source, + legend_label=legend_label, color=color) + elif is_scatter: fig.scatter( 'index', source_name, source=source, legend_label=legend_label, color=color, @@ -548,7 +556,10 @@ def __eq__(self, other): legend_label=legend_label, line_color=color, line_width=1.3) else: - if is_scatter: + if is_histogram: + r = fig.vbar('index', BAR_WIDTH, source_name, source=source, + legend_label=LegendStr(legend_label), color=color) + elif is_scatter: r = fig.scatter( 'index', source_name, source=source, legend_label=LegendStr(legend_label), color=color, @@ -589,7 +600,8 @@ def __eq__(self, other): figs_above_ohlc.append(_plot_drawdown_section()) if plot_pl: - figs_above_ohlc.append(_plot_pl_section()) + fig_pl = _plot_pl_section() + figs_above_ohlc.append(fig_pl) if plot_volume: fig_volume = _plot_volume_section() @@ -612,9 +624,15 @@ def __eq__(self, other): custom_js_args = dict(ohlc_range=fig_ohlc.y_range, source=source) + if plot_pl: + custom_js_args.update(pl_range=fig_pl.y_range) if plot_volume: custom_js_args.update(volume_range=fig_volume.y_range) - + indicator_ranges = {} + for idx, indicator in enumerate(indicator_figs): + indicator_range_key = f'indicator_{idx}_range' + indicator_ranges.update({indicator_range_key: indicator.y_range}) + custom_js_args.update({'indicator_ranges': indicator_ranges}) fig_ohlc.x_range.js_on_change('end', CustomJS(args=custom_js_args, code=_AUTOSCALE_JS_CALLBACK)) diff --git a/backtesting/autoscale_cb.js b/backtesting/autoscale_cb.js index da888ecf..63135615 100644 --- a/backtesting/autoscale_cb.js +++ b/backtesting/autoscale_cb.js @@ -31,5 +31,18 @@ window._bt_autoscale_timeout = setTimeout(function () { max = Math.max.apply(null, source.data['Volume'].slice(i, j)); _bt_scale_range(volume_range, 0, max * 1.03, false); } + + if(indicator_ranges){ + let keys = Object.keys(indicator_ranges); + for(var count=0;count np.ndarray: """ Declare indicator. An indicator is just an array of values, @@ -109,6 +109,10 @@ def I(self, # noqa: E741, E743 legends on your indicator chart. By default it's set to None, and `name` is used as legends. + If `histogram` is `True`, the indicator values will be plotted + as a histogram instead of line or circle. When `histogram` is + `True`, 'scatter' value will be ignored even if it's set. + Additional `*args` and `**kwargs` are passed to `func` and can be used for parameters. @@ -155,9 +159,9 @@ def init(): overlay = ((x < 1.4) & (x > .6)).mean() > .6 value = _Indicator(value, name=name, plot=plot, overlay=overlay, - color=color, scatter=scatter, legends=legends, + color=color, scatter=scatter, histogram=histogram, # _Indicator.s Series accessor uses this: - index=self.data.index) + legends=legends, index=self.data.index) self._indicators.append(value) return value diff --git a/backtesting/lib.py b/backtesting/lib.py index f7f61e74..f7715e20 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -452,6 +452,43 @@ def next(self): self.data.Close[index] + self.__atr[index] * self.__n_atr) +class PercentageTrailingStrategy(Strategy): + """ + A strategy with automatic trailing stop-loss, trailing the current + price at distance of some percentage. Call + `PercentageTrailingStrategy.set_trailing_sl()` to set said percentage + (`5` by default). See [tutorials] for usage examples. + + [tutorials]: index.html#tutorials + + Remember to call `super().init()` and `super().next()` in your + overridden methods. + """ + _sl_percent = 5. + + def init(self): + super().init() + + def set_trailing_sl(self, percentage: float = 5): + assert percentage > 0, "percentage must be greater than 0" + """ + Sets the future trailing stop-loss as some (`percentage`) + percentage away from the current price. + """ + self._sl_percent = percentage + + def next(self): + super().next() + index = len(self.data)-1 + for trade in self.trades: + if trade.is_long: + trade.sl = max(trade.sl or -np.inf, + self.data.Close[index]*(1-(self._sl_percent/100))) + else: + trade.sl = min(trade.sl or np.inf, + self.data.Close[index]*(1+(self._sl_percent/100))) + + # Prevent pdoc3 documenting __init__ signature of Strategy subclasses for cls in list(globals().values()): if isinstance(cls, type) and issubclass(cls, Strategy): diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 1ca822cf..cec7b6e2 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -24,6 +24,7 @@ quantile, SignalStrategy, TrailingStrategy, + PercentageTrailingStrategy, resample_apply, plot_heatmaps, random_ohlc_data, @@ -789,6 +790,24 @@ def next(self): # Give browser time to open before tempfile is removed time.sleep(1) + def test_indicator_histogram(self): + class S(Strategy): + def init(self): + self.I(SMA, self.data.Close, 5, overlay=True, scatter=False, histogram=True) + self.I(SMA, self.data.Close, 10, overlay=False, scatter=False, histogram=True) + + def next(self): + pass + + bt = Backtest(GOOG, S) + bt.run() + with _tempfile() as f: + bt.plot(filename=f, + plot_drawdown=False, plot_equity=False, plot_pl=False, plot_volume=False, + open_browser=True) + # Give browser time to open before tempfile is removed + time.sleep(1) + class TestLib(TestCase): def test_barssince(self): @@ -880,6 +899,21 @@ def next(self): stats = Backtest(GOOG, S).run() self.assertEqual(stats['# Trades'], 57) + def test_PercentageTrailingStrategy(self): + class S(PercentageTrailingStrategy): + def init(self): + super().init() + self.set_trailing_sl(5) + self.sma = self.I(lambda: self.data.Close.s.rolling(10).mean()) + + def next(self): + super().next() + if not self.position and self.data.Close > self.sma: + self.buy() + + stats = Backtest(GOOG, S).run() + self.assertEqual(stats['# Trades'], 91) + class TestUtil(TestCase): def test_as_str(self):