Bollinger Band Reversion
Mean-reversion strategy using Bollinger Band extremes — enter on band touch, exit on mid-band return, with volatility filters.
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()