Skip to content

#318: Testing Trading Algorithms: Bollinger Bands

As soon as you start searching for stock market data you quickly get your feeds on social media platforms flooded with trading tips. Stay sceptical and try them out before you jump in. In most cases the shortcomings show up sooner than you think. Let us play around with a simplified Bollinger Bands algorithm and write some code to compare multiple strategies.

Bollinger Bands?

Bollinger Bands are a technical analysis indicator made up of three lines plotted around an asset's price: a middle band that is typically a 20-period moving average, and an upper and lower band set a certain number of standard deviations (usually two) above and below that average. Because the bands are based on standard deviation, they automatically widen when price volatility increases and narrow when volatility decreases, giving a visual way to judge whether prices are relatively high or low compared to their recent history.

We use the crossing of the closing line with the lower band as a buy indicator and sell when the closing line crosses the upper band. This is an intentional simplification because that is what most LLMs will generate if you ask to build such a tool.

Implement the strategy

Since we want to test multiple strategies, we will need to implement multiple algorithms. For that I prefer to have a defined contract that we can enforce for each implementation. We can use this abstract class to make sure that the methods we need are there:

from abc import ABC, abstractmethod


class TradingStrategy(ABC):
    @abstractmethod
    def prepare_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
        """Add indicators needed by the strategy"""
        pass

    @abstractmethod
    def generate_signal(self, df: pd.DataFrame, i: int, position: int) -> str | None:
        """
        Return:
        - "BUY"
        - "SELL"
        - None
        """
        pass

Our Bollinger Band strategy looks like this:

class BollingerBandsStrategy(TradingStrategy):
    def __init__(self, window=20, num_std=2):
        self.window = window
        self.num_std = num_std

    def prepare_indicators(self, df):
        df = df.copy()
        df['SMA'] = df['Close'].rolling(self.window).mean()
        df['STD'] = df['Close'].rolling(self.window).std()
        df['Upper'] = df['SMA'] + self.num_std * df['STD']
        df['Lower'] = df['SMA'] - self.num_std * df['STD']
        return df

    def generate_signal(self, df, i, position):
        price = df['Close'].iloc[i]
        prev_price = df['Close'].iloc[i - 1]

        lower = df['Lower'].iloc[i]
        prev_lower = df['Lower'].iloc[i - 1]

        upper = df['Upper'].iloc[i]
        prev_upper = df['Upper'].iloc[i - 1]

        if (
            position == 0
            and prev_price > prev_lower
            and price < lower
        ):
            return "BUY"

        if (
            position > 0
            and prev_price < prev_upper
            and price > upper
        ):
            return "SELL"

        return None

Test the strategy

After we calculated the buy and sell signals, we want to run through our data and see how much money we could have made. For that we start with an initial cash amount and go all-in when a buy signal shows up and sell everything on a sell indicator. We ignore all details like trading costs to focus on the strategy.

class StrategyTester:
    def __init__(self, initial_cash=10_000):
        self.initial_cash = initial_cash

    def run(self, df, strategy: TradingStrategy):
        df = strategy.prepare_indicators(df)

        cash = self.initial_cash
        shares = 0
        trades = []

        for i in range(1, len(df)):
            signal = strategy.generate_signal(df, i, shares)
            price = float(df['Close'].iloc[i])

            if signal == "BUY" and shares == 0:
                shares = cash / price
                cash = 0
                trades.append((df.index[i], price, "BUY"))

            elif signal == "SELL" and shares > 0:
                cash = shares * price
                shares = 0
                trades.append((df.index[i], price, "SELL"))

        final_value = cash + shares * df['Close'].iloc[-1]

        return {
            "final_value": final_value,
            "return_pct": (final_value / self.initial_cash - 1) * 100,
            "trades": trades,
            "df": df
        }

    def baseline(self, df):
        cash = self.initial_cash
        trades = []

        first = float(df['Close'].iloc[0])
        trades.append((df.index[0], first, "BUY"))

        last = float(df['Close'].iloc[-1])
        trades.append((df.index[-1], last, "SELL"))

        shares = cash / first
        final_value = shares * last

        return {
            "final_value": final_value,
            "return_pct": (final_value / self.initial_cash - 1) * 100,
            "trades": trades,
            "df": df
        }

    def print_result(self, result, label):
        print(f"========= {label} =========")
        print(f"Final value: ${result['final_value']:,.2f}")
        print(f"Return: {result['return_pct']:.2f}%")
        print(f"Trades:")
        for trade in result['trades']:
            print(trade)
        print(f"\n==================================\n\n")

The baseline() method is there as a benchmark and shows what would have been if we bought the stocks at the first trading day and sold them on the last day.

Plot the stock and the trades

To visually control the trades, we can use this little helper function to plot the stock price and the trading signals:

import matplotlib.pyplot as plt


def plot_results(df, trades, title):
    plt.figure(figsize=(14, 7))
    plt.plot(df.index, df['Close'], label="Close", linewidth=1.2)

    for col in ['SMA', 'Upper', 'Lower']:
        if col in df.columns:
            plt.plot(df.index, df[col], linestyle="--", label=col)

    for date, price, action in trades:
        marker = "^" if action == "BUY" else "v"
        plt.scatter(date, price, marker=marker, s=100, zorder=5)

    plt.title(title)
    plt.xlabel("Date")
    plt.ylabel("Price")
    plt.legend()
    plt.grid(True)
    plt.tight_layout()
    plt.show()

Get the data and glue everything together

As the final parts we need to fetch the stock market data and glue everything together:

import yfinance as yf
import pandas as pd


def load_price_data(ticker, start, end=None):
    df = yf.download(
        ticker,
        start=start,
        end=end,
        auto_adjust=True,
        progress=False
    )

    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)

    return df[['Close']].copy()


# Parameters
TICKER = "NVDA"
START_DATE = "2025-01-01"
END_DATE = "2025-12-31"

# Fetch data
df = load_price_data(TICKER, START_DATE, END_DATE)

# Initialise classes
strategy = BollingerBandsStrategy(window=20, num_std=2)
strategy_name = "Bollinger Bands"
tester = StrategyTester(initial_cash=10_000)

# Benchmark
benchmark = tester.baseline(df)
tester.print_result(benchmark, "Benchmark: Buy & Hold")

# Test strategy
result = tester.run(df, strategy)
tester.print_result(result, strategy_name)

plot_results(
    result['df'],
    result['trades'],
    title=f"{TICKER}{strategy_name} Strategy"
)

The test run

If we run the script for Nvidia and check the trades for 2025, we get this output:

========= Benchmark: Buy & Hold =========
Final value: $13,563.10
Return: 35.63%
Trades:
(Timestamp('2025-01-02 00:00:00'), 138.27218627929688, 'BUY')
(Timestamp('2025-12-30 00:00:00'), 187.5399932861328, 'SELL')

==================================


========= Bollinger Bands =========
Final value: $13,934.90
Return: 39.35%
Trades:
(Timestamp('2025-03-06 00:00:00'), 110.53976440429688, 'BUY')
(Timestamp('2025-05-13 00:00:00'), 129.9064178466797, 'SELL')
(Timestamp('2025-08-29 00:00:00'), 174.1604766845703, 'BUY')
(Timestamp('2025-09-30 00:00:00'), 186.56961059570312, 'SELL')
(Timestamp('2025-12-17 00:00:00'), 170.94000244140625, 'BUY')
(Timestamp('2025-12-23 00:00:00'), 189.2100067138672, 'SELL')

==================================

We see that our base line of Buy & Hold would have given us a return of 35.63%, while the Bollinger Bands strategy would have a return of 39.35% - or $371.80 more for an investment of $10000.

The plot shows us when we made the trades and what happened afterwards on the stock market:

We made 3 winning trades but lost out on the large uptake.

Next

We needed a lot more code than I anticipated to test the Bollinger Bands strategy. But our investment in code pays its dividend when we add more strategies as we see in the next post.