Notebooks/Bollinger Band Reversion
Signals·TA Strategies·Beginner

Bollinger Band Reversion

Mean-reversion strategy using Bollinger Band extremes — enter on band touch, exit on mid-band return, with volatility filters.

bollinger bandsmean reversionvolatility

Strategy — Bollinger Band Mean Reversion


1–2. Installation and Imports

[1]
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:

| Line | Formula | |---| | Middle Band | Rolling mean of close over N candles | | Upper Band | Middle Band + K × rolling standard deviation | | Lower Band | Middle 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

[2]
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

[3]
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

[4]
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()
[ ]