Buy and Hold with backtrader
This is sometimes one of the baselines which is used to test the performance of a given strategy, i.e.: "if the carefully crafted logic cannot beat a simple buy and hold approach, the strategy is probably not worth a dime"
A simple "buy and hold" strategy, would simply buy with the first incoming data point and see what the portfolio value is available with the last data point.
Tip
The snippets below forego the imports and set-up boilerplate. A complete script is available at the end.
Cheating On Close
In many cases, an approach like Buy and Hold is not meant to yield an exact
reproduction of order execution and price matching. It is about evaluating the
large numbers. That is why, the cheat-on-close
mode of the default broker in
backtrader is going to be activated. This means
-
As only
Market
orders will be issued, execution will be done against the currentclose
price. -
Take into account that when a price is available for the trading logic (in this case the
close
), that price is GONE. It may or may not be available in a while and in reality execution cannot be guaranteed against it.
Buy and Forget
class BuyAndHold_1(bt.Strategy): def start(self): self.val_start = self.broker.get_cash() # keep the starting cash def nextstart(self): # Buy all the available cash size = int(self.broker.get_cash() / self.data) self.buy(size=size) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.val_start) - 1.0 print('ROI: {:.2f}%'.format(100.0 * self.roi))
class BuyAndHold_1(bt.Strategy): def start(self): self.val_start = self.broker.get_cash() # keep the starting cash def nextstart(self): # Buy all the available cash self.order_target_value(target=self.broker.get_cash()) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.val_start) - 1.0 print('ROI: {:.2f}%'.format(100.0 * self.roi))
The following is happening here:
-
A single go long operation to enter the market is being issued. Either with
-
buy
and a manual calculation of thesize
All the available
cash
is used to buy a fixed amount of units of the asset. Notice it is being truncated to be anint
. This is appropriate for things like stocks, futures.
or
order_target_value
and letting the system know we want to use all the cash. The method will take care of automatically calculating the size.
-
-
In the
start
method, the initial amount of cash is being saved -
In the
stop
method, the returns are calculated, using the current value of the portfolio and the initial amount of cash
Note
In backtrader the nextstart
method is called exactly once, when the
data/indicator buffers can deliver. The default behavior is to delegate the
work to next
. But because we want to buy exactly once and do it with
the first available data, it is the right point to do it.
Tip
As only 1 data feed is being considered, there is no need to specify the target data feed. The first (and only) data feed in the system will be used as the target.
If more than one data feed is present, the target can be selected by using
the named argument data
as in
self.buy(data=the_desired_data, size=calculated_size)
The sample script below can be executed as follows
$ ./buy-and-hold.py --bh-buy --plot ROI: 34.50%
$ ./buy-and-hold.py --bh-target --plot ROI: 34.50%
The graphical output is the same for both
Buy and Buy More
But an actual regular person does usually have a day job and can put an amount of money into the stock market each and every month. This person is not bothered with trends, technical analysis and the likes. The only actual concern is to put the money in the market the 1st day of the month.
Given that the Romans left us with a calendar which has months which differ in
the number of days (28
, 29
, 30
, 31
) and taking into account non-trading
days, one cannot for sure use the following simple approach:
- Buy each X days
A method to identify the first trading day of the month needs to be used. This can be done with Timers in backtrader
Note
Only the order_target_value
method is used in the next examples.
class BuyAndHold_More(bt.Strategy): params = dict( monthly_cash=1000.0, # amount of cash to buy every month ) def start(self): self.cash_start = self.broker.get_cash() self.val_start = 100.0 # Add a timer which will be called on the 1st trading day of the month self.add_timer( bt.timer.SESSION_END, # when it will be called monthdays=[1], # called on the 1st day of the month monthcarry=True, # called on the 2nd day if the 1st is holiday ) def notify_timer(self, timer, when, *args, **kwargs): # Add the influx of monthly cash to the broker self.broker.add_cash(self.p.monthly_cash) # buy available cash target_value = self.broker.get_value() + self.p.monthly_cash self.order_target_value(target=target_value) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.cash_start) - 1.0 print('ROI: {:.2f}%'.format(self.roi))
During the start
phase a timer is added
# Add a timer which will be called on the 1st trading day of the month self.add_timer( bt.timer.SESSION_END, # when it will be called monthdays=[1], # called on the 1st day of the month monthcarry=True, # called on the 2nd day if the 1st is holiday )
-
Timer which will be called at the end of the session (
bt.timer.SESSION_END
)Note
For daily bars this is obviously not relevant, because the entire bar is delivered in a single shot.
-
The timer lists only day
1
of the month as the one in which the timer has to be called -
In case day
1
happens to be a non-trading day,monthcarry=True
ensures that the timer will still be called on the first trading day of the month.
The timer received during the notify_timer
method, which is overridden to
perform the market operations.
def notify_timer(self, timer, when, *args, **kwargs): # Add the influx of monthly cash to the broker self.broker.add_cash(self.p.monthly_cash) # buy available cash target_value = self.broker.get_value() + self.p.monthly_cash self.order_target_value(target=target_value)
Tip
Notice that what is bought is not the monthly cash influx, but the total value of the account, which comprises the current portfolio, plus the money we have added. The reasons
-
There can be some initial cash to be consumed
-
The monthly operation may not consume all the cash, because a single month may not be enough to buy the stock and because there will be a rest after acquiring the stock
In our example it is actually so, because the default monthly cash inflow is
1000
and the asset has a value of over3000
-
If the target were to be the available cash, this could be smaller than the actual value
Execution
$ ./buy-and-hold.py --bh-more --plot ROI: 320.96%
$ ./buy-and-hold.py --bh-more --strat monthly_cash=5000.0 ROI: 1460.99%
Blistering Barnacles!!! a ROI
of 320.96%
for the default 1000
money units and an even greater ROI
of 1460.99%
for 5000
monetary
units. We have probably found a money printing machine ...
- The more money we add each month ... the more we win ... regardless of what the market does.
Of course not ...
- The calculation stored in
self.roi
duringstop
is NO longer valid. The simple monhtly addition of cash to the broker changes the scales (even if the money were not used for anything, it would still count as an increment)
The graphical output with 1000 money units
Notice the interval between actual operations in the market, because the 1000
money units are not enough to buy 1
unit of the asset and money has to be
accumulated until an operation can succeed.
The graphical output with 5000 money units
In this case, 5000
monetary units can always buy 1
unit of the asset and
the market operations take place each and every month.
Performance Tracking for Buy and Buy More
As pointed out above, hen money is added to (and sometimes taken out of) the system, performance has to measured in a different way. There is no need to invent anything, because it was invented a long time ago and it is what is done for Fund Management.
-
A
perf_value
is set as the reference to track the performance. More often than not this will100
-
Using that peformance value and the initial amount of cash, a number of
shares
is calculated, i.e.:shares = cash / perf_value
-
Whenever cash is added to/subsctracted from the system, the number of
shares
changes, but theperf_value
remains the same. -
The cash will be sometimes invested and the daily
value
will be updated as inperf_value = portfolio_value / shares
With that approach the actual perfomance can be calculated and it is independent of cash additions to/withdrawals from the system.
Luckily enough, backtrader can already do all of that automatically.
class BuyAndHold_More_Fund(bt.Strategy): params = dict( monthly_cash=1000.0, # amount of cash to buy every month ) def start(self): # Activate the fund mode and set the default value at 100 self.broker.set_fundmode(fundmode=True, fundstartval=100.00) self.cash_start = self.broker.get_cash() self.val_start = 100.0 # Add a timer which will be called on the 1st trading day of the month self.add_timer( bt.timer.SESSION_END, # when it will be called monthdays=[1], # called on the 1st day of the month monthcarry=True, # called on the 2nd day if the 1st is holiday ) def notify_timer(self, timer, when, *args, **kwargs): # Add the influx of monthly cash to the broker self.broker.add_cash(self.p.monthly_cash) # buy available cash target_value = self.broker.get_value() + self.p.monthly_cash self.order_target_value(target=target_value) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() - self.cash_start) - 1.0 self.froi = self.broker.get_fundvalue() - self.val_start print('ROI: {:.2f}%'.format(self.roi)) print('Fund Value: {:.2f}%'.format(self.froi))
During start
-
The fund mode is activated with a default start value of
100.0
def start(self): # Activate the fund mode and set the default value at 100 self.broker.set_fundmode(fundmode=True, fundstartval=100.00)
During stop
-
The fund
ROI
is calculated. Because the start value is100.0
the operation is rather simpledef stop(self): # calculate the actual returns ... self.froi = self.broker.get_fundvalue() - self.val_start
The execution
$ ./buy-and-hold.py --bh-more-fund --strat monthly_cash=5000 --plot ROI: 1460.99% Fund Value: 37.31%
In this case:
-
The same incredible plain
ROI
as before is achieved which is1460.99%
-
The actual
ROI
when considering it as Fund is a more modest and realistic37.31%
, given the sample data.
Note
The output chart is the same as in the previous execution with 5000
money
units.
The sample script
import argparse import datetime import backtrader as bt class BuyAndHold_Buy(bt.Strategy): def start(self): self.val_start = self.broker.get_cash() # keep the starting cash def nextstart(self): # Buy all the available cash size = int(self.broker.get_cash() / self.data) self.buy(size=size) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.val_start) - 1.0 print('ROI: {:.2f}%'.format(100.0 * self.roi)) class BuyAndHold_Target(bt.Strategy): def start(self): self.val_start = self.broker.get_cash() # keep the starting cash def nextstart(self): # Buy all the available cash size = int(self.broker.get_cash() / self.data) self.buy(size=size) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.val_start) - 1.0 print('ROI: {:.2f}%'.format(100.0 * self.roi)) class BuyAndHold_More(bt.Strategy): params = dict( monthly_cash=1000.0, # amount of cash to buy every month ) def start(self): self.cash_start = self.broker.get_cash() self.val_start = 100.0 # Add a timer which will be called on the 1st trading day of the month self.add_timer( bt.timer.SESSION_END, # when it will be called monthdays=[1], # called on the 1st day of the month monthcarry=True, # called on the 2nd day if the 1st is holiday ) def notify_timer(self, timer, when, *args, **kwargs): # Add the influx of monthly cash to the broker self.broker.add_cash(self.p.monthly_cash) # buy available cash target_value = self.broker.get_value() + self.p.monthly_cash self.order_target_value(target=target_value) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.cash_start) - 1.0 print('ROI: {:.2f}%'.format(100.0 * self.roi)) class BuyAndHold_More_Fund(bt.Strategy): params = dict( monthly_cash=1000.0, # amount of cash to buy every month ) def start(self): # Activate the fund mode and set the default value at 100 self.broker.set_fundmode(fundmode=True, fundstartval=100.00) self.cash_start = self.broker.get_cash() self.val_start = 100.0 # Add a timer which will be called on the 1st trading day of the month self.add_timer( bt.timer.SESSION_END, # when it will be called monthdays=[1], # called on the 1st day of the month monthcarry=True, # called on the 2nd day if the 1st is holiday ) def notify_timer(self, timer, when, *args, **kwargs): # Add the influx of monthly cash to the broker self.broker.add_cash(self.p.monthly_cash) # buy available cash target_value = self.broker.get_value() + self.p.monthly_cash self.order_target_value(target=target_value) def stop(self): # calculate the actual returns self.roi = (self.broker.get_value() / self.cash_start) - 1.0 self.froi = self.broker.get_fundvalue() - self.val_start print('ROI: {:.2f}%'.format(100.0 * self.roi)) print('Fund Value: {:.2f}%'.format(self.froi)) def run(args=None): args = parse_args(args) cerebro = bt.Cerebro() # Data feed kwargs kwargs = dict(**eval('dict(' + args.dargs + ')')) # Parse from/to-date dtfmt, tmfmt = '%Y-%m-%d', 'T%H:%M:%S' for a, d in ((getattr(args, x), x) for x in ['fromdate', 'todate']): if a: strpfmt = dtfmt + tmfmt * ('T' in a) kwargs[d] = datetime.datetime.strptime(a, strpfmt) data = bt.feeds.BacktraderCSVData(dataname=args.data, **kwargs) cerebro.adddata(data) # Strategy if args.bh_buy: stclass = BuyAndHold_Buy elif args.bh_target: stclass = BuyAndHold_Target elif args.bh_more: stclass = BuyAndHold_More elif args.bh_more_fund: stclass = BuyAndHold_More_Fund cerebro.addstrategy(stclass, **eval('dict(' + args.strat + ')')) # Broker broker_kwargs = dict(coc=True) # default is cheat-on-close active broker_kwargs.update(eval('dict(' + args.broker + ')')) cerebro.broker = bt.brokers.BackBroker(**broker_kwargs) # Sizer cerebro.addsizer(bt.sizers.FixedSize, **eval('dict(' + args.sizer + ')')) # Execute cerebro.run(**eval('dict(' + args.cerebro + ')')) if args.plot: # Plot if requested to cerebro.plot(**eval('dict(' + args.plot + ')')) def parse_args(pargs=None): parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, description=( 'Backtrader Basic Script' ) ) parser.add_argument('--data', default='../../datas/2005-2006-day-001.txt', required=False, help='Data to read in') parser.add_argument('--dargs', required=False, default='', metavar='kwargs', help='kwargs in key=value format') # Defaults for dates parser.add_argument('--fromdate', required=False, default='', help='Date[time] in YYYY-MM-DD[THH:MM:SS] format') parser.add_argument('--todate', required=False, default='', help='Date[time] in YYYY-MM-DD[THH:MM:SS] format') parser.add_argument('--cerebro', required=False, default='', metavar='kwargs', help='kwargs in key=value format') parser.add_argument('--broker', required=False, default='', metavar='kwargs', help='kwargs in key=value format') parser.add_argument('--sizer', required=False, default='', metavar='kwargs', help='kwargs in key=value format') parser.add_argument('--strat', '--strategy', required=False, default='', metavar='kwargs', help='kwargs in key=value format') parser.add_argument('--plot', required=False, default='', nargs='?', const='{}', metavar='kwargs', help='kwargs in key=value format') pgroup = parser.add_mutually_exclusive_group(required=True) pgroup.add_argument('--bh-buy', required=False, action='store_true', help='Buy and Hold with buy method') pgroup.add_argument('--bh-target', required=False, action='store_true', help='Buy and Hold with order_target method') pgroup.add_argument('--bh-more', required=False, action='store_true', help='Buy and Hold More') pgroup.add_argument('--bh-more-fund', required=False, action='store_true', help='Buy and Hold More with Fund ROI') return parser.parse_args(pargs) if __name__ == '__main__': run()
$ ./buy-and-hold.py --help usage: buy-and-hold.py [-h] [--data DATA] [--dargs kwargs] [--fromdate FROMDATE] [--todate TODATE] [--cerebro kwargs] [--broker kwargs] [--sizer kwargs] [--strat kwargs] [--plot [kwargs]] (--bh-buy | --bh-target | --bh-more | --bh-more-fund) Backtrader Basic Script optional arguments: -h, --help show this help message and exit --data DATA Data to read in (default: ../../datas/2005-2006-day-001.txt) --dargs kwargs kwargs in key=value format (default: ) --fromdate FROMDATE Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: ) --todate TODATE Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: ) --cerebro kwargs kwargs in key=value format (default: ) --broker kwargs kwargs in key=value format (default: ) --sizer kwargs kwargs in key=value format (default: ) --strat kwargs, --strategy kwargs kwargs in key=value format (default: ) --plot [kwargs] kwargs in key=value format (default: ) --bh-buy Buy and Hold with buy method (default: False) --bh-target Buy and Hold with order_target method (default: False) --bh-more Buy and Hold More (default: False) --bh-more-fund Buy and Hold More with Fund ROI (default: False)