Volatility Squeeze Strategy
Trade Bollinger Band / Keltner Channel squeezes — enter on the first directional expansion after a compression period.
Bollinger-Keltner Squeeze Strategy
1. Strategy Overview
The Bollinger-Keltner Squeeze strategy identifies periods of extreme price compression. This occurs when Bollinger Bands (calculated using standard deviation for volatility) contract and become entirely contained within Keltner Channels (calculated using Average True Range for volatility).
Squeeze Condition: Bollinger Band Upper < Keltner Channel Upper AND Bollinger Band Lower > Keltner Channel Lower
During a squeeze, the market exhibits unusually low volatility, a condition often preceding a significant directional price movement, analogous to a compressed spring. The direction of the subsequent breakout is determined by momentum at the point of squeeze release:
- Buy Signal (+1): Squeeze releases (Bollinger Bands expand beyond Keltner Channels) AND momentum is positive (> 0).
- Sell Signal (−1): Squeeze releases AND momentum is negative (< 0).
- No Signal (0): Squeeze is active; a directional breakout has not yet occurred.
2. Install Dependencies
Installation of required Python libraries for data manipulation and plotting.
!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. Import Libraries
Import necessary libraries for data processing, numerical operations, and visualization.
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots4. Data Generation
Generate synthetic candlestick price data for demonstration purposes. The generate_data function creates a DataFrame with 'open', 'high', 'low', 'close', 'volume', and 'datetime' columns over a specified number of periods.
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)5. Strategy Implementation
The volatility_squeeze_strategy function computes Bollinger Bands, Keltner Channels, identifies squeeze conditions, calculates momentum, and generates trade signals based on the squeeze release and momentum direction.
def volatility_squeeze_strategy(
df: pd.DataFrame,
window: int = 20,
bb_std: float = 2.0,
kc_mult: float = 1.5,
) -> pd.DataFrame:
"""
Applies the Bollinger-Keltner Squeeze strategy to financial time series data.
This function calculates Bollinger Bands and Keltner Channels to identify periods
of low volatility (a 'squeeze'). It then generates buy or sell signals when
the squeeze releases, based on the prevailing momentum.
Args:
df (pd.DataFrame): Input DataFrame with 'datetime', 'open', 'high', 'low', 'close' columns.
window (int): The lookback period for calculating EMA, Bollinger Bands, and ATR.
bb_std (float): The number of standard deviations for Bollinger Bands.
kc_mult (float): The multiplier for Average True Range (ATR) in Keltner Channels.
Returns:
pd.DataFrame: The original DataFrame with added columns for Bollinger Bands,
Keltner Channels, squeeze condition, momentum, and trade signals.
"""
# Ensure data is sorted by datetime for correct rolling calculations
df = df.copy().sort_values("datetime", ignore_index=True)
# Calculate Exponential Moving Average (EMA) as the centerline
df["ema"] = df["close"].ewm(span=window, adjust=False).mean()
# Calculate Bollinger Bands
bb_std_val = df["close"].rolling(window).std()
df["bb_upper"] = df["ema"] + bb_std * bb_std_val # Upper Bollinger Band
df["bb_lower"] = df["ema"] - bb_std * bb_std_val # Lower Bollinger Band
# Calculate True Range (TR) and Average True Range (ATR) for Keltner Channels
tr = pd.concat([
df["high"] - df["low"], # High minus Low
(df["high"] - df["close"].shift(1)).abs(), # High minus previous close (absolute value)
(df["low"] - df["close"].shift(1)).abs(), # Low minus previous close (absolute value)
], axis=1).max(axis=1)
atr = tr.rolling(window).mean() # Average True Range
# Calculate Keltner Channels
df["kc_upper"] = df["ema"] + kc_mult * atr # Upper Keltner Channel
df["kc_lower"] = df["ema"] - kc_mult * atr # Lower Keltner Channel
# Determine the squeeze condition: BB fully inside KC
df["squeeze"] = (df["bb_upper"] < df["kc_upper"]) & (df["bb_lower"] > df["kc_lower"])
# Calculate momentum based on close price relative to its moving average
df["momentum"] = df["close"] - df["close"].rolling(window).mean()
# Generate trade signals based on squeeze release and momentum direction
# Signal is 1 for buy, -1 for sell, and 0 if squeeze is active or no clear breakout
df["signal"] = np.where(~df["squeeze"] & (df["momentum"] > 0), 1, # Buy if squeeze ends and momentum is positive
np.where(~df["squeeze"] & (df["momentum"] < 0), -1, 0)) # Sell if squeeze ends and momentum is negative
return df6. Generate Signals
Apply the volatility_squeeze_strategy function to the generated data to produce trade signals.
df_signals = volatility_squeeze_strategy(df)7. Signal Interpretation and Distribution
Signals are generated at the first candle following the termination of a squeeze, where Bollinger Bands have expanded beyond the Keltner Channel boundaries. The momentum direction at this precise moment dictates the trade direction. During an active squeeze (squeeze=True), all signals are suppressed, as entry during price compression lacks a definitive directional bias. This section also displays the count of each signal type and the number of bars where the squeeze condition is active.
print("--- Signal Distribution ---"); print(df_signals["signal"].value_counts())
print(f"Squeeze bars: {df_signals['squeeze'].sum()}")--- Signal Distribution --- signal -1 238 1 180 0 82 Name: count, dtype: int64 Squeeze bars: 63
8. Visualization
This section visualizes the price data along with Bollinger Bands, Keltner Channels, trade signals, momentum, and the squeeze indicator. The plot provides a comprehensive view of the strategy's application to the synthetic data.
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 + Bands + Signals", "Momentum + Squeeze Indicator"],
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 col, color, name in [("bb_upper","blue","BB Upper"),("bb_lower","blue","BB Lower"),
("kc_upper","orange","KC Upper"),("kc_lower","orange","KC Lower")]:
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals[col],
mode="lines", name=name, line=dict(color=color, width=1, dash="dash")), 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.Bar(x=df_signals["datetime"], y=df_signals["momentum"],
name="Momentum",
marker_color=["green" if v >= 0 else "red" for v in df_signals["momentum"]]), row=2, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"],
y=df_signals["squeeze"].astype(int) * df_signals["momentum"].abs().max() * 0.5,
mode="markers", marker=dict(color=["gray" if s else "rgba(0,0,0,0)" for s in df_signals["squeeze"]], size=4),
name="Squeeze Active"), row=2, col=1)
fig.update_layout(title_text="Bollinger-Keltner Squeeze Strategy",
xaxis_rangeslider_visible=False, height=700, yaxis=dict(autorange=True))
fig.show()