Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
polakowo
GitHub Repository: polakowo/vectorbt
Path: blob/master/examples/PortingBTStrategy.ipynb
1071 views
Kernel: Python 3 (ipykernel)

Porting a backtrader strategy

Abstract

In this notebook we'll see how to port a strategy from Backtrader to Vectorbt. We'll also see how to compare and troubleshoot the two versions

Variables Setup

We'll setup some parameters here. Remember that you have to use a symbol available from the broker you're going to download data from (Binance in this case). As instance Binance provides a BTC/USDT symbol but doesn't provide an BTC/USD

import vectorbt as vbt import backtrader as bt from backtrader.sizers import PercentSizer import numpy as np import pandas as pd import matplotlib.pyplot as plt from datetime import datetime, timedelta, timezone import pytz %matplotlib inline
# Enter your parameters here coin_target = "BTC" coin_refer = "USDT" symbol = '%s-%s' % (coin_target, coin_refer) init_cash = 100 fees = 0.075 # in % start_date = datetime(2021, 3, 4, tzinfo=pytz.utc) end_date = datetime(2021, 3, 11, tzinfo=pytz.utc) freq = '1m' rsi_bottom = 35 rsi_top = 70 fast_window = 10 slow_window = 100 vbt.settings.portfolio['freq'] = freq vbt.settings.portfolio['init_cash'] = init_cash vbt.settings.portfolio['fees'] = fees / 100 vbt.settings.portfolio['slippage'] = 0

Download Data

You can either download data using the binance client...

binance_data = vbt.BinanceData.download( '%s%s' % (coin_target, coin_refer), start=start_date, end=end_date, interval='1m', tqdm_kwargs=dict(ncols='100%') ) data = binance_data.get()
0it [00:00, ?it/s]

...or using CCXT (here we're using both for demonstration purposes but you can just pick the one you prefer)

ccxt_data = vbt.CCXTData.download( '%s/%s' % (coin_target, coin_refer), start=start_date, end=end_date, timeframe='1m', tqdm_kwargs=dict(ncols='100%') ) data = ccxt_data.get()
0it [00:00, ?it/s]

Let's keep only the columns we're interested into

cols = ['Open', 'High', 'Low', 'Close', 'Volume'] ohlcv_wbuf = data[cols] ohlcv_wbuf = ohlcv_wbuf.astype(np.float64) print(ohlcv_wbuf.shape) print(ohlcv_wbuf.columns)
(9990, 5) Index(['Open', 'High', 'Low', 'Close', 'Volume'], dtype='object')

And make sure the timeframe is the one we specified

wobuf_mask = (ohlcv_wbuf.index >= start_date) & (ohlcv_wbuf.index <= end_date) # mask without buffer ohlcv = ohlcv_wbuf.loc[wobuf_mask, :] print(ohlcv.shape)
(9990, 5)

Let's plot the price graph!

# Plot the OHLC data ohlcv_wbuf.vbt.ohlcv.plot().show_svg() # remove show_svg() to display interactive chart!
Image in a Jupyter notebook

Backtrader Strategy

We start by backtasting a simple RSI strategy which buys when < 35 and sell when > 70. We'll use backtrader as starting point.

class FullMoney(PercentSizer): params = ( ('percents', 100 - fees), ) data_bt = bt.feeds.PandasData( dataname=ohlcv_wbuf, openinterest=-1, datetime=None, timeframe=bt.TimeFrame.Minutes, compression=1 ) cerebro = bt.Cerebro(quicknotify=True) cerebro.adddata(data_bt) broker = cerebro.getbroker() broker.set_coc(True) # cheat-on-close broker.setcommission(commission=fees/100)#, name=coin_target) broker.setcash(init_cash) cerebro.addsizer(FullMoney) cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="ta") cerebro.addanalyzer(bt.analyzers.SQN, _name="sqn") cerebro.addanalyzer(bt.analyzers.Transactions, _name="transactions")

The StrategyBase will deal with most of the routine most common logic. The real trading decisions will be taken in the BasicRSI class.

class StrategyBase(bt.Strategy): def __init__(self): self.order = None self.last_operation = "SELL" self.status = "DISCONNECTED" self.buy_price_close = None self.pending_order = False self.commissions = [] def notify_data(self, data, status, *args, **kwargs): self.status = data._getstatusname(status) def short(self): self.sell() def long(self): self.buy_price_close = self.data0.close[0] self.buy() def notify_order(self, order): self.pending_order = False if order.status in [order.Submitted, order.Accepted]: self.order = order return elif order.status in [order.Completed]: self.commissions.append(order.executed.comm) if order.isbuy(): self.last_operation = "BUY" else: # Sell self.buy_price_close = None self.last_operation = "SELL" self.order = None class BasicRSI(StrategyBase): params = dict( period_ema_fast=fast_window, period_ema_slow=slow_window, rsi_bottom_threshold=rsi_bottom, rsi_top_threshold=rsi_top ) def __init__(self): StrategyBase.__init__(self) self.ema_fast = bt.indicators.EMA(period=self.p.period_ema_fast) self.ema_slow = bt.indicators.EMA(period=self.p.period_ema_slow) self.rsi = bt.talib.RSI(self.data, timeperiod=14) #self.rsi = bt.indicators.RelativeStrengthIndex() self.profit = 0 self.stop_loss_flag = True def update_indicators(self): self.profit = 0 if self.buy_price_close and self.buy_price_close > 0: self.profit = float( self.data0.close[0] - self.buy_price_close) / self.buy_price_close def next(self): self.update_indicators() if self.order: # waiting for pending order return # stop Loss ''' if self.profit < -0.03: self.short() ''' # take Profit ''' if self.profit > 0.03: self.short() ''' # reset stop loss flag if self.rsi > self.p.rsi_bottom_threshold: self.stop_loss_flag = False if self.last_operation != "BUY": # if self.rsi < 30 and self.ema_fast > self.ema_slow: if self.rsi < self.p.rsi_bottom_threshold: # and not self.stop_loss_flag: self.long() if self.last_operation != "SELL": if self.rsi > self.p.rsi_top_threshold: self.short()

Remember to add the strategy to cerebro

cerebro.addstrategy(BasicRSI)
0

Run the backtesting

initial_value = cerebro.broker.getvalue() print('Starting Portfolio Value: %.2f' % initial_value) result = cerebro.run()
Starting Portfolio Value: 100.00

Define some convenience functions for printing a report. Let's also print the actual report and plot a graph

def print_trade_analysis(analyzer): # Get the results we are interested in if not analyzer.get("total"): return total_open = analyzer.total.open total_closed = analyzer.total.closed total_won = analyzer.won.total total_lost = analyzer.lost.total win_streak = analyzer.streak.won.longest lose_streak = analyzer.streak.lost.longest pnl_net = round(analyzer.pnl.net.total, 2) strike_rate = round((total_won / total_closed) * 2) # Designate the rows h1 = ['Total Open', 'Total Closed', 'Total Won', 'Total Lost'] h2 = ['Strike Rate', 'Win Streak', 'Losing Streak', 'PnL Net'] r1 = [total_open, total_closed, total_won, total_lost] r2 = [strike_rate, win_streak, lose_streak, pnl_net] # Check which set of headers is the longest. if len(h1) > len(h2): header_length = len(h1) else: header_length = len(h2) # Print the rows print_list = [h1, r1, h2, r2] row_format = "{:<15}" * (header_length + 1) print("Trade Analysis Results:") for row in print_list: print(row_format.format('', *row)) def print_sqn(analyzer): sqn = round(analyzer.sqn, 2) print('SQN: {}'.format(sqn)) # Print analyzers - results final_value = cerebro.broker.getvalue() print('Final Portfolio Value: %.2f' % final_value) print('Profit %.3f%%' % ((final_value - initial_value) / initial_value * 100)) print_trade_analysis(result[0].analyzers.ta.get_analysis()) print_sqn(result[0].analyzers.sqn.get_analysis()) data = result[0].analyzers.transactions.get_analysis() df = pd.DataFrame.from_dict(data, orient='index', columns=['data']) bt_transactions = pd.DataFrame(df.data.values.tolist(), df.index.tz_localize(tz='UTC'), columns=[ 'amount', 'price', 'sid', 'symbol', 'value'])
Final Portfolio Value: 103.75 Profit 3.753% Trade Analysis Results: Total Open Total Closed Total Won Total Lost 1 39 27 12 Strike Rate Win Streak Losing Streak PnL Net 1 6 3 5.25 SQN: 0.73
%matplotlib inline import matplotlib.pyplot as plt plt.rcParams["figure.figsize"] = (13, 8) cerebro.plot(style='bar', iplot=False)
Image in a Jupyter notebook
[[<Figure size 936x576 with 5 Axes>]]

Let's create a vectorbt portfolio using the entries and exits from backtrader...

bt_entries_mask = bt_transactions[bt_transactions.amount > 0] bt_entries_mask.index = bt_entries_mask.index bt_exits_mask = bt_transactions[bt_transactions.amount < 0] bt_exits_mask.index = bt_exits_mask.index bt_entries = pd.Series.vbt.signals.empty_like(ohlcv['Close']) bt_entries.loc[bt_entries_mask.index] = True bt_exits = pd.Series.vbt.signals.empty_like(ohlcv['Close']) bt_exits.loc[bt_exits_mask.index] = True vbt.settings.portfolio['fees'] = 0.075 / 100 #0.0025 # in % bt_pf = vbt.Portfolio.from_signals(ohlcv['Close'], bt_entries, bt_exits, price=ohlcv['Close'].vbt.fshift(1))

...and compare the commissions.

bt_commissions = pd.Series(result[0].commissions, index=bt_transactions.index) vbt_commissions = bt_pf.orders.records_readable.Fees vbt_commissions.index = bt_pf.orders.records_readable.Timestamp commissions_delta = bt_commissions - vbt_commissions commissions_delta.rename('Commissions (Delta)').vbt.plot().show_svg()
Image in a Jupyter notebook

We can see they are extremely close (zero dot, followed by 40 other zeros and finally some meaningful digits). This is mostly because of rounding errors for example due to the order calculations are performed but is mostly negligible for our purposes. In fact if we print the portfolio report from both vectorbt and backtrader and we can see the result is almost identical

print('Final Portfolio Value: %.5f' % final_value) print('Profit %.3f%%' % ((final_value - initial_value) / initial_value * 100)) print_trade_analysis(result[0].analyzers.ta.get_analysis()) print(bt_pf.stats())
Final Portfolio Value: 103.75268 Profit 3.753% Trade Analysis Results: Total Open Total Closed Total Won Total Lost 1 39 27 12 Strike Rate Win Streak Losing Streak PnL Net 1 6 3 5.25 Start 2021-03-04 00:00:00+00:00 End 2021-03-10 23:59:00+00:00 Period 6 days 22:30:00 Start Value 100.0 End Value 103.752684 Total Return [%] 3.752684 Benchmark Return [%] 11.452034 Max Gross Exposure [%] 100.0 Total Fees Paid 6.003376 Max Drawdown [%] 6.334598 Max Drawdown Duration 1 days 14:59:00 Total Trades 40 Total Closed Trades 39 Total Open Trades 1 Open Trade PnL -1.496004 Win Rate [%] 69.230769 Best Trade [%] 2.153345 Worst Trade [%] -2.796592 Avg Winning Trade [%] 0.781395 Avg Losing Trade [%] -1.309515 Avg Winning Trade Duration 0 days 01:48:00 Avg Losing Trade Duration 0 days 04:31:10 Profit Factor 1.330172 Expectancy 0.134582 Sharpe Ratio 3.04149 Calmar Ratio 93.873628 Omega Ratio 1.014286 Sortino Ratio 4.268353 Name: Close, dtype: object

Let's plot a graph with the entries and exits

fig = vbt.make_subplots(specs=[[{"secondary_y": True}]]) fig = ohlcv['Close'].vbt.plot(trace_kwargs=dict(name='Price'), fig=fig) fig = bt_entries.vbt.signals.plot_as_entry_markers(ohlcv['Close'], fig=fig) fig = bt_exits.vbt.signals.plot_as_exit_markers(ohlcv['Close'], fig=fig) fig.show_svg()
Image in a Jupyter notebook

Pure vectorbt strategy

We'll try now to execute the same strategy but using only vectorbt. Let's start by creating an RSI indicator. It'll be of course on a rolling window, meaning it will represent the value which the RSI would have been at that point in time (as opposed of being calculated considering the current timestamp or the last one as you would normally do in a realtime scenario)

RSI = vbt.IndicatorFactory.from_talib('RSI') rsi = RSI.run(ohlcv_wbuf['Open'], timeperiod=[14]) print(rsi.real.shape)
(9990,)

We plot entries and exists as before...

vbt_entries = rsi.real_crossed_below(rsi_bottom) vbt_exits = rsi.real_crossed_above(rsi_top) vbt_entries, vbt_exits = pd.DataFrame.vbt.signals.clean(vbt_entries, vbt_exits) fig = vbt.make_subplots(specs=[[{"secondary_y": True}]]) fig = ohlcv['Open'].vbt.plot(trace_kwargs=dict(name='Price'), fig=fig) fig = vbt_entries.vbt.signals.plot_as_entry_markers(ohlcv['Open'], fig=fig) fig = vbt_exits.vbt.signals.plot_as_exit_markers(ohlcv['Open'], fig=fig) fig.show_svg()
Image in a Jupyter notebook

...and print the Final Portfolio Value

vbt_pf = vbt.Portfolio.from_signals(ohlcv['Close'], vbt_entries, vbt_exits, price=ohlcv['Close'].vbt.fshift(1)) print('Final Portfolio Value (Vectorbt): %.5f' % vbt_pf.final_value()) print('Final Portfolio Value (Backtrader): %.5f' % final_value)
Final Portfolio Value (Vectorbt): 103.46965 Final Portfolio Value (Backtrader): 103.75268

Something's wrong! Why do we get a different result when using backtrader and vectorbt? Let's start by comparing the entries and exits ('^' means XOR logical operator: It returns true when the two inputs are different)

(vbt_entries ^ bt_entries).rename('Entries (Delta)').vbt.signals.plot().show_svg() (vbt_exits ^ bt_exits).rename('Exits (Delta)').vbt.signals.plot().show_svg()
Image in a Jupyter notebookImage in a Jupyter notebook
# create a selection mask for showing values which are different mask = vbt_exits ^ bt_exits print(vbt_exits[mask]) # show the different ones in vbt_exits print(bt_exits[mask]) # show the different ones in bt_exits print(rsi.real[mask]) # show the RSI value
Open time 2021-03-08 19:15:00+00:00 True 2021-03-08 19:16:00+00:00 False Name: (14, Open), dtype: bool Open time 2021-03-08 19:15:00+00:00 False 2021-03-08 19:16:00+00:00 True Name: Close, dtype: bool Open time 2021-03-08 19:15:00+00:00 70.122480 2021-03-08 19:16:00+00:00 73.657727 Name: (14, Open), dtype: float64

Definitely some values are different and why backtrader did not exit with an RSI greater than 70?

vectorbt strategy with backtrader indicators

Let's dig deeper: We can compare the vectorbt and backtrader RSI indicator and see if there's any difference. We start by importing them as a dataframe

rsi_bt_df = pd.DataFrame({ 'rsi': result[0].rsi.get(size=len(result[0])) }, index=[result[0].datas[0].num2date(x) for x in result[0].data.datetime.get(size=len(result[0]))]) rsi_bt_df.index = rsi_bt_df.index.tz_localize(tz='UTC') rsi_bt_df.rsi = rsi_bt_df.rsi.shift(1) rsi_vbt_df = pd.DataFrame({ 'rsi': rsi.real.values }, index=rsi.real.index) rsi_vbt_df_mask = (rsi_vbt_df.index >= start_date) & (rsi_vbt_df.index <= end_date) # mask without buffer rsi_vbt_df = rsi_vbt_df.loc[rsi_vbt_df_mask, :] print(rsi_bt_df.shape) print(rsi_vbt_df.shape) #rsi_bt_df.head(20) #rsi_vbt_df.head(20)
(9990, 1) (9990, 1)

And plot the difference results

rsi_delta = rsi_bt_df - rsi_vbt_df #rsi_delta.head(20) rsi_delta.rsi.rename('RSI (Delta)').vbt.plot().show_svg()
Image in a Jupyter notebook

We can clearly see there's a ± ~2 difference in value overall. The initial spike is due to backtrader not having enough price info at the beginning, which are instead available to vectorbt as we trimmed the data after generating the series of points. We can print the signals both separately and overlapped

# Overlapped pd.DataFrame({'RSI (VBT)': rsi_vbt_df['rsi'], 'RSI (BT)': rsi_bt_df['rsi']}).vbt.plot().show_svg() # RSI signal from Backtrader rsi_bt_df.rsi.rename('RSI (BT)').vbt.plot().show_svg() # RSI signal from Vectorbt rsi_vbt_df.rsi.rename('RSI (VBT)').vbt.plot().show_svg()
Image in a Jupyter notebookImage in a Jupyter notebookImage in a Jupyter notebook

No appreciable difference though. So, how can we achieve the same results? We could try to feed backtrader's RSI signal to vectorbt's strategy

vbt_bt_entries = rsi_bt_df.rsi < rsi_bottom vbt_bt_exits = rsi_bt_df.rsi > rsi_top vbt_bt_entries, vbt_bt_exits = pd.DataFrame.vbt.signals.clean(vbt_bt_entries, vbt_bt_exits) fig = vbt.make_subplots(specs=[[{"secondary_y": True}]]) fig = ohlcv['Open'].vbt.plot(trace_kwargs=dict(name='Price'), fig=fig) fig = vbt_bt_entries.vbt.signals.plot_as_entry_markers(ohlcv['Open'], fig=fig) fig = vbt_bt_exits.vbt.signals.plot_as_exit_markers(ohlcv['Open'], fig=fig) fig.show_svg()
Image in a Jupyter notebook

and print the difference between entries and exits

(vbt_bt_entries ^ bt_entries).rename('Entries (Delta)').vbt.signals.plot().show_svg() (vbt_bt_exits ^ bt_exits).rename('Exits (Delta)').vbt.signals.plot().show_svg()
Image in a Jupyter notebookImage in a Jupyter notebook

Nice! no difference in entries and exits events.

# create a selection mask for showing values which are different mask = vbt_bt_exits ^ bt_exits print(vbt_bt_exits[mask]) # show the different ones in vbt_bt_exits print(bt_exits[mask]) # show the different ones in bt_exits print(rsi_bt_df.rsi[mask]) # show the RSI value
Series([], Name: rsi, dtype: bool) Series([], Name: Close, dtype: bool) Series([], Name: rsi, dtype: float64)

We can print them on a binary Y axis as well

fig = vbt_bt_entries.vbt.signals.plot(trace_kwargs=dict(name='Entries')) vbt_bt_exits.vbt.signals.plot(trace_kwargs=dict(name='Exits'), fig=fig).show_svg()
Image in a Jupyter notebook

So now when we go and print the Final Portfolio Value...

vbt_bt_pf = vbt.Portfolio.from_signals(ohlcv['Close'], vbt_bt_entries, vbt_bt_exits, price=ohlcv['Close'].vbt.fshift(1)) print('Final Portfolio Value (Vectorbt): %.5f' % vbt_bt_pf.final_value()) print('Final Portfolio Value (Backtrader): %.5f' % final_value)
Final Portfolio Value (Vectorbt): 103.75268 Final Portfolio Value (Backtrader): 103.75268

...we can indeed see the results are matching! Let's also plot the position trading windows

#print(vbt_bt_pf.trades.records) vbt_bt_pf.trades.plot().show_svg()
Image in a Jupyter notebook

Conclusions

If we now create a portfolio from a simple holding strategy

hold_pf = vbt.Portfolio.from_holding(ohlcv['Close'])

and plot the portfolio value on the same graph

fig = vbt_pf.value().vbt.plot(trace_kwargs=dict(name='Value (pure vectorbt)')) fig = vbt_bt_pf.value().vbt.plot(trace_kwargs=dict(name='Value (vectorbt w/ BT Ind.)'), fig=fig) fig = bt_pf.value().vbt.plot(trace_kwargs=dict(name='Value (Backtrader)'), fig=fig) hold_pf.value().vbt.plot(trace_kwargs=dict(name='Value (Hold)'), fig=fig).show_svg()
Image in a Jupyter notebook

We can see the portfolio generated with the vectorbt + backtrader RSI signal exactly overlaps with the portfolio we generated from pure backtrader strategy. The pure vectorbt portfolio is slightly off though as we find out. This should remind you that tiny differences in the ways signal algorithms are implemented, can even generate different entries and exits events in your strategy!

Bonus debugging snippets

here are some snippets which might come in handy when debugging or troubleshooting strategies

vbt_pf.orders.records_readable
bt_pf.orders.records_readable
bt_pf.value().iloc[150:].head(20)
Open time 2021-03-04 02:30:00+00:00 100.000000 2021-03-04 02:31:00+00:00 100.000000 2021-03-04 02:32:00+00:00 100.000000 2021-03-04 02:33:00+00:00 100.000000 2021-03-04 02:34:00+00:00 100.000000 2021-03-04 02:35:00+00:00 100.000000 2021-03-04 02:36:00+00:00 99.922910 2021-03-04 02:37:00+00:00 99.902525 2021-03-04 02:38:00+00:00 99.893279 2021-03-04 02:39:00+00:00 99.923983 2021-03-04 02:40:00+00:00 99.870494 2021-03-04 02:41:00+00:00 99.973942 2021-03-04 02:42:00+00:00 100.044188 2021-03-04 02:43:00+00:00 100.111742 2021-03-04 02:44:00+00:00 99.982857 2021-03-04 02:45:00+00:00 100.078697 2021-03-04 02:46:00+00:00 100.170323 2021-03-04 02:47:00+00:00 100.105188 2021-03-04 02:48:00+00:00 100.087651 2021-03-04 02:49:00+00:00 100.007280 Name: Close, dtype: float64
vbt_pf.value().iloc[150:].head(20)
Open time 2021-03-04 02:30:00+00:00 100.000000 2021-03-04 02:31:00+00:00 100.000000 2021-03-04 02:32:00+00:00 100.000000 2021-03-04 02:33:00+00:00 100.000000 2021-03-04 02:34:00+00:00 100.000000 2021-03-04 02:35:00+00:00 100.000000 2021-03-04 02:36:00+00:00 99.922910 2021-03-04 02:37:00+00:00 99.902525 2021-03-04 02:38:00+00:00 99.893279 2021-03-04 02:39:00+00:00 99.923983 2021-03-04 02:40:00+00:00 99.870494 2021-03-04 02:41:00+00:00 99.973942 2021-03-04 02:42:00+00:00 100.044188 2021-03-04 02:43:00+00:00 100.111742 2021-03-04 02:44:00+00:00 99.982857 2021-03-04 02:45:00+00:00 100.078697 2021-03-04 02:46:00+00:00 100.170323 2021-03-04 02:47:00+00:00 100.105188 2021-03-04 02:48:00+00:00 100.087651 2021-03-04 02:49:00+00:00 100.007280 dtype: float64