Signals·TA Strategy Implementations·Intermediate

Bollinger Band Reversion

Implement a Bollinger Bands mean reversion strategy that trades price touches and breaches of the upper and lower standard deviation bands with volatility-adjusted dynamic stop placement and band-width-based take-profit targets.

ta-strategy-implementationstrading-signals

Strategy — Bollinger Band Mean Reversion


1–2. Installation and Imports

[ ]
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go

!pip install pandas numpy plotly
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

3. Strategy Overview

Bollinger Bands are a volatility-adaptive price envelope consisting of three lines:

LineFormula
Middle BandRolling mean of close over N candles
Upper BandMiddle Band + K × rolling standard deviation
Lower BandMiddle Band − K × rolling standard deviation

Mean reversion logic:

  • Close touches or crosses below the lower bandBuy (+1): price is statistically unusually low relative to recent history. The deviation is expected to revert toward the mean (middle band).
  • Close touches or crosses above the upper bandSell (−1): price is statistically unusually high. Reversion toward the mean is expected.
  • Close is between the bands → No signal (0): price is within its normal statistical range.

Why it works: At K=2, approximately 95% of price observations fall within the bands under a normal distribution assumption. A close outside the bands is therefore a statistically unusual event that has historically tended to reverse. The bands are adaptive — they widen during volatile periods and narrow during quiet periods — so the threshold is always calibrated to current market conditions rather than a fixed price level.

Standard parameters: N=20, K=2. These are the most widely used Bollinger Band settings across professional trading platforms and academic research.


4. Data Generation

[ ]
def generate_data(periods: int) -> pd.DataFrame:
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []; last_close = 42000
    for i in range(periods):
        open_price  = last_close + np.random.normal(0, last_close * 0.0005)
        close_price = open_price + np.random.normal(0, last_close * 0.005)
        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)
        high_price  = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        if high_price < low_price: high_price, low_price = low_price, high_price
        price_data.append({"open": max(1,int(open_price)), "high": max(1,int(high_price)),
                            "low":  max(1,int(low_price)),  "close": max(1,int(close_price))})
        last_close = close_price
    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"] = np.random.uniform(100.0, 500.0, periods)
    df["datetime"] = df.index.to_series()
    return df.reset_index(drop=True)

df = generate_data(500)
display(df.head())
open high low close volume datetime
0 42009 42049 41947 41955 471.430854 2024-01-01 00:00:00+00:00
1 41961 42134 41754 42130 333.971884 2024-01-01 00:01:00+00:00
2 42132 42179 41728 41807 242.964173 2024-01-01 00:02:00+00:00
3 41773 41906 41761 41768 438.123893 2024-01-01 00:03:00+00:00
4 41766 41938 41744 41933 287.286090 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def bollinger_band_reversion(
    df:      pd.DataFrame,
    window:  int   = 20,
    num_std: float = 2.0,
) -> pd.DataFrame:
    # Create a copy of the DataFrame and sort it by datetime to ensure correct rolling calculations.
    df = df.copy().sort_values("datetime", ignore_index=True)

    # Calculate the Middle Bollinger Band: a 'window'-period simple moving average of the 'close' price.
    df["bb_middle"] = df["close"].rolling(window).mean()
    # Calculate the rolling standard deviation of the 'close' price over the specified 'window'.
    df["bb_std"]    = df["close"].rolling(window).std()
    # Calculate the Upper Bollinger Band: Middle Band + (num_std * rolling standard deviation).
    df["bb_upper"]  = df["bb_middle"] + num_std * df["bb_std"]
    # Calculate the Lower Bollinger Band: Middle Band - (num_std * rolling standard deviation).
    df["bb_lower"]  = df["bb_middle"] - num_std * df["bb_std"]

    # %B: position of close within the bands (0=lower, 1=upper).
    # This normalizes the close price's position relative to the bands.
    df["pct_b"] = (df["close"] - df["bb_lower"]) / (df["bb_upper"] - df["bb_lower"])

    # Generate trading signals based on Bollinger Band mean reversion logic:
    # If 'close' price is below or touches the Lower Band, generate a buy signal (1).
    # If 'close' price is above or touches the Upper Band, generate a sell signal (-1).
    # Otherwise, if price is within the bands, generate no signal (0).
    df["signal"] = np.where(df["close"] <= df["bb_lower"],  1,
                   np.where(df["close"] >= df["bb_upper"], -1, 0))

    return df

df_signals = bollinger_band_reversion(df, window=20, num_std=2.0)

print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
print("\n--- %B Statistics ---")
print(df_signals["pct_b"].describe().round(4))
--- Signal Distribution ---
signal
 0    457
-1     29
 1     14
Name: count, dtype: int64

--- %B Statistics ---
count    481.0000
mean       0.5121
std        0.3295
min       -0.1015
25%        0.2263
50%        0.5070
75%        0.8108
max        1.1904
Name: pct_b, dtype: float64

Explanation:

  • rolling(window).mean() and rolling(window).std(): The mean defines the fair-value anchor; the standard deviation defines the band width — the further price deviates from the mean relative to its own historical volatility, the wider the bands stretch.
  • num_std = 2.0: At two standard deviations, approximately 95% of close prices fall inside the bands under normally distributed returns — making band touches statistically meaningful.
  • %B: The normalized position of the close within the bands. Values below 0 indicate the close is outside (below) the lower band; above 1 means outside (above) the upper band. This is provided as a supplementary diagnostic metric.

6. Visualization

[ ]
from plotly.subplots import make_subplots

buy_signals  = df_signals[df_signals["signal"] ==  1]
sell_signals = df_signals[df_signals["signal"] == -1]

fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price + Bollinger Bands", "%B"],
    row_heights=[0.65, 0.35])

fig.add_trace(go.Candlestick(
    x=df_signals["datetime"],
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"],   close=df_signals["close"],
    name="Price"), row=1, col=1)

for band, color, name in [
    ("bb_upper", "rgba(0,0,255,0.3)", "Upper Band"),
    ("bb_middle","blue",              "Middle Band"),
    ("bb_lower", "rgba(0,0,255,0.3)", "Lower Band"),
]:
    fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals[band],
        mode="lines", name=name, line=dict(color=color, width=1)), row=1, col=1)

fig.add_trace(go.Scatter(
    x=buy_signals["datetime"],  y=buy_signals["low"]  * 0.999,
    mode="markers", marker=dict(symbol="triangle-up",   size=10, color="green"), name="Buy (+1)"),
    row=1, col=1)
fig.add_trace(go.Scatter(
    x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
    mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"),   name="Sell (−1)"),
    row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["pct_b"],
    mode="lines", name="%B", line=dict(color="purple", width=1)), row=2, col=1)
fig.add_hline(y=1, line_dash="dash", line_color="red",   row=2, col=1)
fig.add_hline(y=0, line_dash="dash", line_color="green", row=2, col=1)

fig.update_layout(
    title_text="Bollinger Band Mean Reversion Strategy",
    xaxis_rangeslider_visible=False,
    height=700, yaxis=dict(autorange=True),
    xaxis2_title="Datetime",
)
fig.show()

Conclusion

This notebook demonstrates the implementation of a Bollinger Band Mean Reversion strategy. We've seen how to generate synthetic price data, calculate Bollinger Bands, generate trading signals, and visualize these signals along with the price action. The strategy aims to capitalize on the tendency of prices to revert to their mean after extreme movements beyond the bands.

Further steps could include backtesting the strategy with real-world data, optimizing parameters (window, num_std), and incorporating additional indicators or risk management techniques to improve performance.