# Bollinger Band Trading Strategy Backtest in Python

So, after a long time without posting (been super busy), I thought I’d write a quick Bollinger Band Trading Strategy Backtest in Python and then run some optimisations and analysis much like we have done in the past.

It’s pretty easy and can be written in just a few lines of code, which is why I love Python so much – so many things can be quickly prototyped and tested to see if it even holds water without wasting half your life typing.

So as some of you may be aware, Yahoo Finance have pulled their financial data API, which means that we can no longer use Pandas Datareader to pull down financial data from the Yahoo Finance site. Rumour has it that Google are pulling theirs too, although I’m yet to see that confirmed. Why they have both chosen to do this, I really don’t know but it’s a bit of a pain in the backside as it means lots of the code I’ve previously written for this blog no longer works!!! Such is life I guess…

Anyway, onto bigger and better things – we can still use the awesome Quandl Python API to pull the necessary data!

Let’s start coding…

```#make the necessary imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import quandl
%matplotlib inline
#download Dax data from the start of 2015 and store in a Pandas DataFrame
df = quandl.get("CHRIS/EUREX_FDAX1", authtoken="[enter-your-token-here]",start_date="2015-01-01")
```

We now have a Pandas DataFrame with the daily data for the Dax continuous contract. We can take a quick look at the structure of the data using the following:

```df.head()
```

and we get the following:

So next we get to the code for creating the actual Bollinger bands themselves:

```#Set number of days and standard deviations to use for rolling lookback period for Bollinger band calculation
window = 21
no_of_std = 2
#Calculate rolling mean and standard deviation using number of days set above
rolling_mean = df['Settle'].rolling(window).mean()
rolling_std = df['Settle'].rolling(window).std()
#create two new DataFrame columns to hold values of upper and lower Bollinger bands
df['Rolling Mean'] = rolling_mean
df['Bollinger High'] = rolling_mean + (rolling_std * no_of_std)
df['Bollinger Low'] = rolling_mean - (rolling_std * no_of_std)
```

Let’s plot the Dax price chart, along with the upper and lower Bollinger bands we have just created.

```df[['Settle','Bollinger High','Bollinger Low']].plot()
```

Now let’s move on to the strategy logic…

```#Create an "empty" column as placeholder for our /position signals
df['Position'] = None
#Fill our newly created position column - set to sell (-1) when the price hits the upper band, and set to buy (1) when it hits the lower band
for row in range(len(df)):

if (df['Settle'].iloc[row] > df['Bollinger High'].iloc[row]) and (df['Settle'].iloc[row-1] < df['Bollinger High'].iloc[row-1]):
df['Position'].iloc[row] = -1

if (df['Settle'].iloc[row] < df['Bollinger Low'].iloc[row]) and (df['Settle'].iloc[row-1] > df['Bollinger Low'].iloc[row-1]):
df['Position'].iloc[row] = 1
#Forward fill our position column to replace the "None" values with the correct long/short positions to represent the "holding" of our position
#forward through time
df['Position'].fillna(method='ffill',inplace=True)
#Calculate the daily market return and multiply that by the position to determine strategy returns
df['Market Return'] = np.log(df['Settle'] / df['Settle'].shift(1))
df['Strategy Return'] = df['Market Return'] * df['Position'].shift(1)
#Plot the strategy returns
df['Strategy Return'].cumsum().plot()
```

So not particularly great returns at all…in fact pretty abysmal!

Let’s try upping the window length to use a look-back of 50 days for the band calculations…

But first, lets define a “Bollinger Band trading Strategy” function that we can easily run again and again while varying the inputs:

```def bollinger_strat(df,window,std):
rolling_mean = df['Settle'].rolling(window).mean()
rolling_std = df['Settle'].rolling(window).std()

df['Bollinger High'] = rolling_mean + (rolling_std * no_of_std)
df['Bollinger Low'] = rolling_mean - (rolling_std * no_of_std)

df['Short'] = None
df['Long'] = None
df['Position'] = None

for row in range(len(df)):

if (df['Settle'].iloc[row] > df['Bollinger High'].iloc[row]) and (df['Settle'].iloc[row-1] < df['Bollinger High'].iloc[row-1]):
df['Position'].iloc[row] = -1

if (df['Settle'].iloc[row] < df['Bollinger Low'].iloc[row]) and (df['Settle'].iloc[row-1] > df['Bollinger Low'].iloc[row-1]):
df['Position'].iloc[row] = 1

df['Position'].fillna(method='ffill',inplace=True)

df['Market Return'] = np.log(df['Settle'] / df['Settle'].shift(1))
df['Strategy Return'] = df['Market Return'] * df['Position'].shift(1)

df['Strategy Return'].cumsum().plot()
```

Great, now we can just run a new strategy backtest with one line! Let’s use a 50 day look back period for the band calculations…

```bollinger_strat(df,50,2)
```

Which should get us a nice looking plot:

Well those returns are at least better than the previous back-test although definitely still not great.

If we want to get a quick idea of whether there are any lookback periods that will create a positive return we can quickly set up a couple of vectors to hold a series of daily periods and standard deviations, and then just “brute force” our way through a series of backtests which iterates over the two vectors, as follows…

```#Set up "daily look back period" and "number of standard deviation" vectors
#For example the first one creates a vector of 20 evenly spaced integer values ranging from 10 to 100
#The second creates a vector of 10 evenly spaced floating point numbers from 1 to 3
windows = np.linspace(10,100,20,dtype=int)
stds = np.linspace(1,3,10)
#And iterate through them both, running the strategy function each time
for window in windows:
for std in stds:
bollinger_strat(df,window,std)
```

This gets us the following plot at the end:

Granted at this point we can’t be sure exactly which combination of standard deviations and daily look back periods produce which results shown in the chart above, however the fact that there are only a couple of equity curves that end up in positive territory would suggest to me that this may not be a great strategy to pursue…for the Dax at least. That’s not to say Bollinger bands are not useful, just that used in such a simple way as outlined in the above strategy most likely isn’t going to provide you with any kind of real “edge”.

Oh well..perhaps we’ll find something better next time.

until then!

#### You may also like

2 August 2017 - 09:03

I like your explanation. It’s a pity this strategy doesn’t provide good returns. Keep on looking 🙂

7 August 2017 - 19:41

Thanks! I will continue the eternal search for that “winning strategy” 😉

5 August 2017 - 22:21

Hey thanks for your posts, hope you keep them coming. These are great!

7 August 2017 - 19:36

Thanks for the comment! Also – I replied to your other comment about running code using data stored locally on your PC but didn’t hear back from you. i am happy to try to help you refactor the code to use locally held data – I just need to know a bit more about what format it is stored in. 😀

7 September 2017 - 16:06

Hello Sir! I have a question regarding how you download the Dax data. What does authtoken=”5GGEggAyyGa6_mVsKrxZ” mean?? Is it a website? Also, if I want to get the data for a certain stock such as Apple, what will be the corresponding code? Thank you in advance!

7 September 2017 - 18:47

Pandas have fixed their DataReader so that the Yahoo Finance API can be used again now – so the simplest solution to your problem is to use something in the following format:

dax_data = web.DataReader(“^GDAXI”, ‘yahoo’, ’01/01/2000′, ’07/09/2017′)

That will get you Dax cash index data between those two dates alter the dates as necessary. hope that helps!

9 September 2017 - 17:06

Hey! Thanks for these posts. Really enjoy them. Shouldn’t it be: df[‘Strategy Return’] = df[‘Market Return’] * df[‘Position’].shift(1) in order to be correct?

10 October 2017 - 13:12

Hi Aleksander – I think you are indeed correct; because we are using settle price to determine whether the bollinger bands have been hit, the position determined the day before will be the position that affects the next day’s returns. So in short, yes i think you are correct. I shall change the code when I have a moment. Well spotted – thanks for that!

6 October 2017 - 17:11

Thank you very much for this. Helps beginners like myself port this over to several other domains like Cryptocurrencies. Keep on posting this great content! Very helpful to see how easy python can test an idea.

7 October 2017 - 10:59

No problem, thanks for the comment – I’ll be trying to post an article very soon along the same lines but this time using the Stochastic Oscillator as a signal.

26 October 2017 - 18:30

The best one!

22 December 2017 - 09:28

When you execute a sale, shouldn’t the following rows be filled with 0 after -1? You effectively stopped making a loss/gain then? If you continue multiplying by -1 in my understanding you will continue ‘making a loss’ in the sum even though you sold?

12 January 2018 - 01:47

Hi Jed – actually this strategy operates on the assumption that you go “long” when the price hits the lower band and go “short” when the prices hits the upper band, rather than just selling your long position when the upper band is hit. SO there is always a position being held, whether long or short. Hope that explains it.

10 January 2018 - 10:28

hi thanks for the post really helped me learning algorithm in python from you website, I have a small question when is run a for loop to calculate position my position dataframe dosent change it show none as selected earlier cna you help me out in this it would really help thx

12 January 2018 - 01:49

Hi Hardik – are you using the code I have written above or are you adapting it and changing it in any way?

29 April 2018 - 16:57

I follow your strategies and they are very well explained. I saw you use lot of looping. We can create additional 3 columns for ‘Settle’, ‘Bollinger High’, ‘Bollinger Low’ using shift(1) on the existing columns in the dataframe. This will avoid looping and vectorized operation is much faster. This is just a suggestion.

29 May 2018 - 11:39

Hi Gurudutt, that is a good point – I should really try to avoid looping operations and go for more vectorised operations for sure!

15 June 2018 - 09:51

Hi S666,

It seems to me that you are able to change your position before you get the signal.
In the code you calculate the strategy return by multiplying the (log) return with the current position:

df[‘Market Return’] = np.log(df[‘Settle’] / df[‘Settle’].shift(1))
df[‘Strategy Return’] = df[‘Market Return’] * df[‘Position’]

I’m wondering if it shouldn’t be

df[‘Market Return’] = np.log(df[‘Settle’] / df[‘Settle’].shift(1))
df[‘Strategy Return’] = df[‘Market Return’] * df[‘Position’].shift(1)

Let me give an example:

t | price | returns | pos
1 | 100 | | 1
2 | 99 | -1 | -1

at t = 1 we get the signal to enter a long position and buy one instrument.
at t = 2 we get the signal to enter a short position sell the instrument we already bought and short sell another.

Using the code without shifting the position, the strategy makes a (non log) return of -1*-1 = 1, but in reality we bought 1 at a price of 100 at t= 1 and sold it for 99 at t=2 giving a strategy return of -1 (then we also short sold another in order to enter a short position, but that first influence the strategy return at t=3 when we see which way the price went).

Am I completely off here, i’m very curious of what i’m missing?

best regards
Nich

15 June 2018 - 09:54

I’m sorry for the messed up formatting of my post, the forum seems to eat white spaces. I hope you still can read it, otherwise shoot me an email.

20 June 2018 - 17:15

Hi Nich, you are correct it seems – I have failed to shift the position forward to align with the correct signal as you have shown. Apologies for that – always happy for people to point out errors of logic like that – thanks very much!

5 October 2018 - 11:28

Seems so easy … so good explanation … thanks you Sir

9 May 2019 - 21:58

Love your articles and the brute force method!

9 May 2019 - 22:26

Hey Chris – Glad you like the content 😉 – if there is anything in particular you would like to see a post about, just shout and I will see what I can do.

13 August 2019 - 16:19

Hey very helpful article! Any suggestions on if it’s possible to automate the execution as well?

26 March 2020 - 22:11

I have issues plotting the timeframe for the different scenarios in the end of your post, can you please advise me how to plot the results after the loop?

22 July 2020 - 11:45

Hi ! Thanks a lot for your articles !

I’ve tried this piece of code to see the positions taken by the Algo and It”s says for my data that from 2017 until 2019 all the positions are “sells” and since 2019 all positions are “Buys”.

data[‘Position’].plot(lw=1.5,ylim=[-1.1,1.1])
plt.show()

My problem here is that if my beginning positions are “sells” I should already posses the stock price which is not always the case. So , do you agree with that the cumulative returns should be from 2019 instead of 2017 even if my data begins at 2017 and supposing that I do not have the possibility to make “short positions” only “selling” (which is the case in my country…)

Many Thanks

13 November 2020 - 09:43

#you have so:
df[‘Strategy Return’] = df[‘Market Return’] * df[‘Position’]
#tcorrectly so:
df[‘Strategy Return’] = df[‘Market Return’] * df[‘Position’].shift(1)

19 November 2020 - 14:56

Quite right – it had been pointed out to me earlier but I was lazy and hadnt gotten around to fixing it – all fixed now. Many thanks for helping proof read and taking time to inform me!

13 August 2022 - 09:18

Hi. Thanks for the code. I tried using it but I got the following errors and it just didnt work. Could you look over it? Or maybe the code needs to be modified now? Thanks a lot.

ettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
pair_data[“Position”].iloc[row] = -1
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
pair_data[“Position”].iloc[row] = 1
Traceback (most recent call last):
File “C:\Users\ASUS\PycharmProjects\pairs\venv\lib\site-packages\pandas\core\indexes\base.py”, line 3621, in get_loc
return self._engine.get_loc(casted_key)
File “pandas_libs\index.pyx”, line 136, in pandas._libs.index.IndexEngine.get_loc
File “pandas_libs\index.pyx”, line 163, in pandas._libs.index.IndexEngine.get_loc
File “pandas_libs\hashtable_class_helper.pxi”, line 5198, in pandas._libs.hashtable.PyObjectHashTable.get_item
File “pandas_libs\hashtable_class_helper.pxi”, line 5206, in pandas._libs.hashtable.PyObjectHashTable.get_item
KeyError: ‘Close’

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
pair_data[“Strategy Returns”] = pair_data[“Returns”] * pair_data[“Close”].shift(1)
File “C:\Users\ASUS\PycharmProjects\pairs\venv\lib\site-packages\pandas\core\frame.py”, line 3505, in getitem
indexer = self.columns.get_loc(key)
File “C:\Users\ASUS\PycharmProjects\pairs\venv\lib\site-packages\pandas\core\indexes\base.py”, line 3623, in get_loc
raise KeyError(key) from err
KeyError: ‘Close’

Process finished with exit code 1

13 August 2022 - 09:23

You need to change the “Close” to “Position”:

change this:

``````pair_data[“Strategy Returns”] = pair_data[“Returns”] * pair_data[“Close”].shift(1)
``````

to this:

``````pair_data[“Strategy Returns”] = pair_data[“Returns”] * pair_data[“Position”].shift(1)
``````

Also FYI you have used the name “Strategy Returns” with an “s” – where as mine has no “s”. This is of course absolutely fine – just wanted to point it out in case you get an error somewhere further along.

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Accept Read More