Backtest Your Dollar Cost Average Strategy Easily in Python

Backtest Your Dollar Cost Average Strategy Easily in Python

Dollar Cost Averaging is a very common technique to invest your money over time into the market. With backtesting.py we can try many variations of a DCA strategy to try and juice our returns. Or perhaps we'll just find out that blindly averaging is best.

If you're completely new to backtesting.py I'd suggest taking a look at my quickstart guide before continuing.

Backtesting.py Basics

We'll need to start off by importing our data and getting set up with our strategy class. Thankfully backtesting.py comes with a test dataset that we can use for this tutorial. For crypto price data, I have another tutorial you might want to take a look at.

As far as our strategy class, this skeleton will get us started.

 1from backtesting import Backtest, Strategy
 2from backtesting.test import GOOG
 3
 4class DCA(Strategy):
 5
 6    amount_to_invest = 10
 7
 8    def init(self):
 9        pass
10
11    def next(self):
12        pass

Now this doesn't actually do anything, but at least we have the structure layed out. Before we continue, it's important to understand what roles init and next play.

init is usually used for computing indicators. It's only called once at the beginning of our backtest, so it makes sense to get odd jobs done there.

next is where the action happens. It's where we lay out our trading logic. Backtesting.py is event-driven, meaning that it feeds the data into our trading logic contained in next one bar at a time. Simulating the experience of actually trading on those candles.

For now let's calculate our indicators. While we aren't using any indicators in the traditional sense, I'm going to make a day_of_week indicator that tells us which day of the week it currently is, so that we can only invest on specific days. Similar logic applies to day of month etc. if you want to backtest a monthly buying strategy.

1....
2    def init(self):
3
4        self.day_of_week = self.I(
5                            lambda x: x,
6                            self.data.Close.s.index.dayofweek,
7                            plot = False,
8                            )
9....

self.I takes a function and some data that you want to apply that function to. This creates an indicator object that's ingested nicely by backtesting.py. In our case the lambda function that we pass here doesn't actually do anything, just returns the data that we pass in.

Next let's write our buying logic. Since we literally just want to buy on a specific day of the week it's super simple.

1...
2    def next(self):
3
4        if self.day_of_week[-1] == 1:
5            self.buy( size = math.floor(self.amount_to_invest / self.data.Close[-1]))
6...

The main thing to watch for here is the size parameter that we pass in to self.buy. If you enter a float between 0 and 1, backtesting.py will assume that you want to invest that fraction of your available cash. If you enter an integer >= 1, then it will assume that you want to purchase that number of shares.

This can be a bit of an obstacle for strategies like ours, in which we need to buy a constant dollar amount of shares that are likely to be more expensive than our weekly buys. So how do we fix this?

The main strategy here is to divide your data by some large number. Instead of buying shares now you're buying micro-shares

1GOOG = GOOG * 10**-6

Now each unit that we buy represents 0.000001 of a share. All you have to do is remember at the end to divide by whatever number you multiplied by here to get the actual amount of shares that you accumulated.

From here, running our backtest is pretty straightfoward.

1....
2bt = Backtest(
3        GOOG,
4        DCA,
5        trade_on_close = True,
6        cash = 10000 # make sure this is enough for all your buys
7        )
8
9stats = bt.run(amount_to_invest = 10) # put any amount you want here

trade_on_close here sends our our with a buy price equal to the closing price of the day. Normally backtesting.py will purchase with the opening price of the next candle to avoid look-ahead bias. You'll have to decide for yourself whether you want to leave it on. Given that we're just averaging in here it's unlikely to make a huge difference.

Reporting and Stats

Now that we've completed our backtest we can pull out some interesting metrics, like how many shares we accumulated, how many trades we did etc. Note that the default stats provided by backtesting.py will be slightly broken. This is because in our simulation our initial cash must be enough to cover all of our buys, so there will always be some dead cash left over and we won't get a proper idea of what returns we actually got.

To remedy that, let's calculate some stats directly from the raw trade data

 1trades = stats["_trades"]
 2price_paid = trades["Size"] * trades["EntryPrice"]
 3total_invested = price_paid.sum()
 4
 5current_shares = trades["Size"].sum()
 6current_equity = current_shares * GOOG.Close.iloc[-1]
 7
 8print("Total investment:",total_invested)
 9print("Current Shares:",current_shares / (10**6))
10print("current Equity:", current_equity)
11
12print("Return:", current_equity / total_invested)

Extensions

You might want to change the buying logic so that we double down when there's a dip in the market. Here's a simple extension to our code that does just that

 1......
 2    def next(self):
 3
 4        if self.day_of_week[-1] == 1:
 5            self.buy( size = math.floor(self.amount_to_invest / self.data.Close[-1]))
 6            try:
 7                if self.data.Close[-1]/self.data.Close[-30] < 0.95:
 8                    self.buy( size = math.floor(self.amount_to_invest / self.data.Close[-1]))
 9            except:
10                pass
11....

In this manner you can create endless variations of the DCA strategy to suit your needs, only needing to change the next function.

Video Tutorial

If you'd prefer a video tutorial, check out my channel here: