This tutorial uses BBKCSqueezeStrategy from the
Mega
Backtrader Strategy Pack. The package includes the complete strategy
source and the backtest runner used to produce these
results.
The Bollinger/Keltner squeeze strategy returned 39.71% on BNB-USD while BNB buy-and-hold lost 27.23%. On SOL-USD, the strategy returned 37.40% while buy-and-hold lost 60.64%.
Each backtest started with $10,000 and used 1-hour bars from August 14, 2025 at 12:00 UTC through August 14, 2026 at 12:00 UTC, 0.10% commission per execution, 95% position sizing, and long-only execution. Every run ended with zero open trades.
A squeeze exists when narrow Bollinger Bands fit inside Keltner Channels. The strategy waits for price to escape the channel, enters in the breakout direction, and manages the position with a 1% trailing stop.
Install the minimal dependencies:
pip install backtrader yfinance pandas matplotlibThen fetch and normalize the hourly OHLCV frame:
from datetime import datetime, timezone
import backtrader as bt
import pandas as pd
import yfinance as yf
symbol = "BNB-USD"
data = yf.download(
symbol,
start=datetime(2025, 8, 14, 12, tzinfo=timezone.utc),
end=datetime(2026, 8, 14, 13, tzinfo=timezone.utc),
interval="1h",
auto_adjust=False,
progress=False,
)
if isinstance(data.columns, pd.MultiIndex):
data.columns = data.columns.get_level_values(0)
data = data[["Open", "High", "Low", "Close", "Volume"]].dropna()The Bollinger and ATR windows use seven hourly bars; the Keltner center uses a 30-hour EMA.
class BBKCSqueezeStrategy(bt.Strategy):
params = (
('bband_period', 7),
('bband_devfactor', 1.0),
('keltner_period', 30),
('keltner_atr_period', 7),
('keltner_atr_multiplier', 1.0),
('trail_percent', 0.01),
)Bollinger width responds to standard deviation. A one-standard-deviation setting is intentionally tighter than the common two-deviation default.
def __init__(self):
self.close = self.data.close
self.bband = bt.indicators.BollingerBands(
self.data,
period=self.p.bband_period,
devfactor=self.p.bband_devfactor,
)Keltner width is ATR-based, so comparing the two envelopes asks whether statistical volatility has compressed inside a range-based volatility measure.
self.atr = bt.indicators.ATR(
self.data, period=self.p.keltner_atr_period
)
self.keltner_mid = bt.indicators.EMA(
self.close, period=self.p.keltner_period
)
self.keltner_top = self.keltner_mid + self.atr * self.p.keltner_atr_multiplier
self.keltner_bot = self.keltner_mid - self.atr * self.p.keltner_atr_multiplierBoth Bollinger boundaries must sit inside the corresponding Keltner boundaries.
is_squeeze = (
self.bband.top[0] < self.keltner_top[0]
and self.bband.bot[0] > self.keltner_bot[0]
)The measured package run was long-only, so only the upside branch
could open a new position. Add --allow-short to test the
downside branch as a separate experiment.
if not self.position and is_squeeze:
if self.close[0] > self.keltner_top[0]:
self.order = self.buy()
elif self.close[0] < self.keltner_bot[0]:
self.order = self.sell()Backtrader's trailing stop follows favorable movement and exits after a 1% reversal from the running reference level.
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status == order.Completed and order.isbuy():
self.sell(
exectype=bt.Order.StopTrail,
trailpercent=self.p.trail_percent,
)
self.order = NoneReplace StrategyClass with the class built above. This
is the minimal engine configuration: $10,000 starting cash, 0.10%
commission, and 95% position sizing.
cerebro = bt.Cerebro()
cerebro.broker.setcash(10_000)
cerebro.broker.setcommission(commission=0.001) # 0.10% per execution
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.adddata(bt.feeds.PandasData(dataname=data))
cerebro.addstrategy(StrategyClass)
result = cerebro.run()[0]
print(f"Final value: ${cerebro.broker.getvalue():,.2f}")The following script includes the complete strategy, data download,
long-only execution rule, analyzers, and printed results. Save it as a
.py file and run it directly.
import math
from datetime import datetime, timezone
import backtrader as bt
import backtrader.indicators as btind
import pandas as pd
import yfinance as yf
# Complete strategy implementation
class BBKCSqueezeStrategy(bt.Strategy):
"""
A strategy that enters on a breakout after a period of low volatility
defined by Bollinger Bands being inside Keltner Channels, and uses a trailing stop.
1. Identify Squeeze: Bollinger Bands are within Keltner Channels.
2. Enter on Breakout: Price closes outside the Keltner Channels.
3. Exit: A trailing stop-loss order is placed upon entry.
"""
params = (
('bband_period', 7),
('bband_devfactor', 1.0),
('keltner_period', 30),
('keltner_atr_period', 7),
('keltner_atr_multiplier', 1.0),
('trail_percent', 0.01),
('printlog', False), # For optimization
)
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
dt = dt or self.datas[0].datetime.datetime(0)
print(f"{dt.isoformat()} - {txt}")
def __init__(self):
self.order = None
self.dataclose = self.datas[0].close
# Add Bollinger Bands indicator
self.bband = bt.indicators.BollingerBands(
self.datas[0],
period=self.p.bband_period,
devfactor=self.p.bband_devfactor
)
# Add Keltner Channels
self.atr = bt.indicators.ATR(self.datas[0], period=self.p.keltner_atr_period)
self.keltner_mid = bt.indicators.EMA(self.dataclose, period=self.p.keltner_period)
self.keltner_top = self.keltner_mid + (self.atr * self.p.keltner_atr_multiplier)
self.keltner_bot = self.keltner_mid - (self.atr * self.p.keltner_atr_multiplier)
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
self.log(f"BUY EXECUTED at {order.executed.price:.2f}")
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
elif order.issell():
self.log(f"SELL EXECUTED at {order.executed.price:.2f}")
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f"Order {order.getstatusname()}")
self.order = None
def next(self):
if self.order:
return
# Wait for sufficient data
if len(self) < max(self.p.bband_period, self.p.keltner_period, self.p.keltner_atr_period):
return
# Check for Keltner Channel and Bollinger Band overlap for squeeze
# Squeeze occurs when BBands are inside Keltner Channels
is_squeeze = (self.bband.top[0] < self.keltner_top[0] and
self.bband.bot[0] > self.keltner_bot[0])
if not self.position:
if is_squeeze:
# Breakout to the upside (close above Keltner top)
if self.dataclose[0] > self.keltner_top[0]:
self.log(f"SQUEEZE BREAKOUT UP at {self.dataclose[0]:.2f}")
self.order = self.buy()
# Breakout to the downside (close below Keltner bottom)
elif self.dataclose[0] < self.keltner_bot[0]:
self.log(f"SQUEEZE BREAKOUT DOWN at {self.dataclose[0]:.2f}")
self.order = self.sell()
# Keep this tutorial backtest long-only.
original_sell = bt.Strategy.sell
def long_only_sell(self, *args, **kwargs):
if self.position.size <= 0:
return None
size = kwargs.get("size")
if size is None or size > self.position.size:
kwargs["size"] = self.position.size
return original_sell(self, *args, **kwargs)
bt.Strategy.sell = long_only_sell
# Download the exact hourly test window.
symbol = "BNB-USD"
data = yf.download(
symbol,
start=datetime(2025, 8, 14, 12, tzinfo=timezone.utc),
end=datetime(2026, 8, 14, 13, tzinfo=timezone.utc),
interval="1h",
auto_adjust=False,
progress=False,
)
if isinstance(data.columns, pd.MultiIndex):
data.columns = data.columns.get_level_values(0)
data = data[["Open", "High", "Low", "Close", "Volume"]].dropna()
if getattr(data.index, "tz", None) is not None:
data.index = data.index.tz_localize(None)
# Configure and run Backtrader.
cerebro = bt.Cerebro()
cerebro.broker.setcash(10_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.adddata(bt.feeds.PandasData(dataname=data))
cerebro.addstrategy(BBKCSqueezeStrategy)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
result = cerebro.run()[0]
final_value = cerebro.broker.getvalue()
total_return = (final_value / 10_000 - 1) * 100
buy_hold_prices = data.loc[data.index.hour == 0, "Close"]
buy_hold_return = (buy_hold_prices.iloc[-1] / buy_hold_prices.iloc[0] - 1) * 100
excess_return = total_return - buy_hold_return
drawdown = result.analyzers.drawdown.get_analysis()
trades = result.analyzers.trades.get_analysis()
print(f"Final value: ${final_value:,.2f}")
print(f"Strategy return: {total_return:.2f}%")
print(f"Buy-and-hold return: {buy_hold_return:.2f}%")
print(f"Excess return: {excess_return:.2f} percentage points")
print(f"Maximum drawdown: {drawdown['max']['drawdown']:.2f}%")
print(f"Closed trades: {trades.get('total', {}).get('closed', 0)}")To run the second backtest, change symbol = "BNB-USD" to
symbol = "SOL-USD"; every other line stays the same.
If you have the Mega Backtrader Strategy Pack, this command runs the full reporting pipeline for BNB-USD and automatically creates the CSV metrics and charts used below:
python run_backtest.py --symbol BNB-USD --period 1y --interval 1h --benchmark BNB-USD --strategy-filter optimization_BBKCSqueezeStrategy.py --out hourly_crypto_resultsIf you have the Mega Backtrader Strategy Pack, this command runs the full reporting pipeline for SOL-USD and automatically creates the CSV metrics and charts used below:
python run_backtest.py --symbol SOL-USD --period 1y --interval 1h --benchmark SOL-USD --strategy-filter optimization_BBKCSqueezeStrategy.py --out hourly_crypto_results--allow-short.BBKCSqueezeStrategy comes from the Mega
Backtrader Strategy Pack. The package includes the complete
optimization_BBKCSqueezeStrategy.py source file and the
backtest runner used in this tutorial, so you can run the strategy,
change its parameters, and generate the same result files yourself.
This material is for research and education only. Backtests are hypothetical, sensitive to data and assumptions, and do not guarantee future performance.