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.
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 plotlyRequirement 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 band → Buy (+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 band → Sell (−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()androlling(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.