Skip to content

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 current close 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 the size

      All the available cash is used to buy a fixed amount of units of the asset. Notice it is being truncated to be an int. 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 Hold

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 over 3000

  • 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 during stop 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

Buy and Hold - More - 1000

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

Buy and Hold - More - 5000

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 will 100

  • 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 the perf_value remains the same.

  • The cash will be sometimes invested and the daily value will be updated as in perf_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 is 100.0 the operation is rather simple

        def 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 is 1460.99%

  • The actual ROI when considering it as Fund is a more modest and realistic 37.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)