This tutorial series is based off a article written by Backtrader https://www.backtrader.com/blog/2019-06-13-buy-and-hold/buy-and-hold/.
Here we will evaluate the difference between dollar cost averaging strategy and lump-sum investing. First, let’s import the dependencies and get the data in a format backtrader can use.
import datetime import math import numpy as np import pandas as pd import matplotlib.pyplot as plt from pandas_datareader import data as pdr import backtrader as bt # import data def get_data(stocks, start, end): stockData = pdr.get_data_yahoo(stocks, start, end) return stockData stockList = ['VTS.AX'] endDate = datetime.datetime.now() startDate = endDate - datetime.timedelta(days=1800) stockData = get_data(stockList[0], startDate, endDate) actualStart = stockData.index[0] data = bt.feeds.PandasData(dataname=stockData)
Next we have to define a valid strategy tha backtrader can interpret. This requires the following functions; start, nextstart and end.
class BuyAndHold(bt.Strategy): def start(self): self.val_start = self.broker.get_cash() def nextstart(self): size = math.floor( (self.broker.get_cash() - 10) / self.data[0] ) self.buy(size=size) def stop(self): # calculate actual returns self.roi = (self.broker.get_value() / self.val_start) - 1 print('-'*50) print('BUY & HOLD') print('Starting Value: ${:,.2f}'.format(self.val_start)) print('ROI: {:.2f}%'.format(self.roi * 100.0)) print('Annualised: {:.2f}%'.format(100*((1+self.roi)**(365/(endDate-actualStart).days) -1))) print('Gross Return: ${:,.2f}'.format(self.broker.get_value() - self.val_start))
BackTrader also requires a class to be defined in order to evaluate commission of each trade. This can be customised, example below for Fixed Commission Scheme.
lass FixedCommisionScheme(bt.CommInfoBase): paras = ( ('commission', 10), ('stocklike', True), ('commtype', bt.CommInfoBase.COMM_FIXED) ) def _getcommission(self, size, price, pseudoexec): return self.p.commission
Next we define a strategy to periodically (twice a month) buy $1,000 worth of the ETF. Here we have added logging functionality and are diving within the orders to ensure information is being calculated correctly by BackTrader.
class BuyAndHold_More_Fund(bt.Strategy): params = dict( monthly_cash=1000, monthly_range=[5,20] ) def __init__(self): self.order = None self.totalcost = 0 self.cost_wo_bro = 0 self.units = 0 self.times = 0 def log(self, txt, dt=None): dt = dt or self.datas[0].datetime.date(0) print('%s, %s' % (dt.isoformat(), txt)) def start(self): self.broker.set_fundmode(fundmode=True, fundstartval=100.0) self.cash_start = self.broker.get_cash() self.val_start = 100.0 # ADD A TIMER self.add_timer( when=bt.timer.SESSION_START, monthdays=[i for i in self.p.monthly_range], monthcarry=True # timername='buytimer', ) def notify_timer(self, timer, when, *args): self.broker.add_cash(self.p.monthly_cash) target_value = self.broker.get_value() + self.p.monthly_cash - 10 self.order_target_value(target=target_value) def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: return if order.status in [order.Completed]: if order.isbuy(): self.log( 'BUY EXECUTED, Price %.2f, Cost %.2f, Comm %.2f, Size %.0f' % (order.executed.price, order.executed.value, order.executed.comm, order.executed.size) ) self.units += order.executed.size self.totalcost += order.executed.value + order.executed.comm self.cost_wo_bro += order.executed.value self.times += 1 elif order.status in [order.Canceled, order.Margin, order.Rejected]: self.log('Order Canceled/Margin/Rejected') print(order.status, [order.Canceled, order.Margin, order.Rejected]) self.order = None def stop(self): # calculate actual returns self.roi = (self.broker.get_value() / self.cash_start) - 1 self.froi = (self.broker.get_fundvalue() - self.val_start) value = self.datas[0].close * self.units + self.broker.get_cash() print('-'*50) print('BUY & BUY MORE') print('Time in Market: {:.1f} years'.format((endDate - actualStart).days/365)) print('#Times: {:.0f}'.format(self.times)) print('Value: ${:,.2f}'.format(value)) print('Cost: ${:,.2f}'.format(self.totalcost)) print('Gross Return: ${:,.2f}'.format(value - self.totalcost)) print('Gross %: {:.2f}%'.format((value/self.totalcost - 1) * 100)) print('ROI: {:.2f}%'.format(100.0 * self.roi)) print('Fund Value: {:.2f}%'.format(self.froi)) print('Annualised: {:.2f}%'.format(100*((1+self.froi/100)**(365/(endDate - actualStart).days) - 1))) print('-'*50)
The final step to the puzzle is to define a run method that establishes BackTrader class Cerebro and adds in strategy, brokerage considerations and cash amount at the start of trading. Then you can run and plot results.
def run(data): # BUY and HOLD cerebro = bt.Cerebro() cerebro.adddata(data) cerebro.addstrategy(BuyAndHold) # Broker Information broker_args = dict(coc=True) cerebro.broker = bt.brokers.BackBroker(**broker_args) comminfo = FixedCommisionScheme() cerebro.broker.addcommissioninfo(comminfo) cerebro.broker.set_cash(100000) # BUY and BUY MORE cerebro1 = bt.Cerebro() cerebro1.adddata(data) cerebro1.addstrategy(BuyAndHold_More_Fund) # Broker Information broker_args = dict(coc=True) cerebro1.broker = bt.brokers.BackBroker(**broker_args) comminfo = FixedCommisionScheme() cerebro1.broker.addcommissioninfo(comminfo) cerebro1.broker.set_cash(1000) cerebro1.run() cerebro.run() cerebro.plot(iplot=False, style='candlestick') cerebro1.plot(iplot=False, style='candlestick') if __name__ == '__main__': run(data)