The Volatility Breakout Strategy is a momentum-based trading approach designed to capture significant price movements in volatile markets, such as cryptocurrencies (e.g., BTC-USD). It uses Keltner Channels, the Relative Strength Index (RSI), and an Exponential Moving Average (EMA) to identify breakout opportunities, filter signals, and manage risk. The strategy is backtested over a period from July 2020 to July 2025, with a custom plotting function to visualize price action, indicators, and trading signals. This article details the strategy’s logic, reasoning, implementation, and visualization, focusing on key code components.
The strategy leverages the following components:
The backtest fetches BTC-USD data via yfinance
and
evaluates performance with detailed logging and visualization of
trades.
The strategy enters trades when:
The strategy uses a fixed 10% of available capital per trade, a conservative approach to manage risk in volatile markets like cryptocurrencies.
Below are the main components of the
VolatilityBreakoutStrategy
class and related functions,
focusing on the parameters, next
function, and plotting
logic.
import backtrader as bt
import yfinance as yf
import pandas as pd
from datetime import datetime
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
class VolatilityBreakoutStrategy(bt.Strategy):
= (
params 'atr_period', 14),
('atr_multiplier', 1.5),
('rsi_period', 14),
('trail_percent', 0.02),
('ema_period', 30),
(
)
def __init__(self):
self.dataclose = self.datas[0].close
self.datahigh = self.datas[0].high
self.datalow = self.datas[0].low
self.order = None
self.close_order = None
self.atr = bt.indicators.ATR(self.datas[0], period=self.p.atr_period)
self.rsi = bt.indicators.RSI(self.datas[0], period=self.p.rsi_period)
self.ema = bt.indicators.EMA(self.datas[0], period=self.p.ema_period)
self.upper_keltner = self.ema + self.atr * self.p.atr_multiplier
self.lower_keltner = self.ema - self.atr * self.p.atr_multiplier
self.trade_data = []
def log_trade_data(self):
if len(self) >= max(self.p.atr_period, self.p.rsi_period, self.p.ema_period):
self.trade_data.append({
'date': self.datas[0].datetime.date(0),
'close': self.dataclose[0],
'high': self.datahigh[0],
'low': self.datalow[0],
'upper_keltner': self.upper_keltner[0],
'lower_keltner': self.lower_keltner[0],
'ema': self.ema[0],
'rsi': self.rsi[0],
'position': self.position.size
})
def next(self):
self.log_trade_data()
if len(self) < max(self.p.atr_period, self.p.rsi_period, self.p.ema_period):
return
if self.order:
return
= self.datahigh[0]
current_high = self.datalow[0]
current_low = self.upper_keltner[0]
current_upper_keltner = self.lower_keltner[0]
current_lower_keltner if not self.position:
if (current_high > current_upper_keltner and
self.rsi[0] > 60 and
self.dataclose[0] > self.ema[0]):
print(f"LONG Entry Signal at {self.datas[0].datetime.date(0)}: Price={self.dataclose[0]:.2f}, Upper Keltner={current_upper_keltner:.2f}")
self.order = self.buy()
elif (current_low < current_lower_keltner and
self.rsi[0] < 40 and
self.dataclose[0] < self.ema[0]):
print(f"SHORT Entry Signal at {self.datas[0].datetime.date(0)}: Price={self.dataclose[0]:.2f}, Lower Keltner={current_lower_keltner:.2f}")
self.order = self.sell()
else:
if self.position.size > 0:
if (self.dataclose[0] < current_lower_keltner or self.rsi[0] < 30):
print(f"LONG Exit Signal at {self.datas[0].datetime.date(0)}")
self.order = self.close()
elif self.position.size < 0:
if (self.dataclose[0] > current_upper_keltner or self.rsi[0] > 70):
print(f"SHORT Exit Signal at {self.datas[0].datetime.date(0)}")
self.order = self.close()
def plot_results(strategy_instance, title="Volatility Breakout Strategy Results"):
if not hasattr(strategy_instance, 'trade_data') or not strategy_instance.trade_data:
print("No trade data available for plotting")
return
= pd.DataFrame(strategy_instance.trade_data)
df 'date'] = pd.to_datetime(df['date'])
df[= df.set_index('date')
df = plt.subplots(2, 1, figsize=(15, 12), height_ratios=[3, 1])
fig, (ax1, ax2) 'close'], label='Close Price', linewidth=1.5, color='black')
ax1.plot(df.index, df['ema'], label='EMA', linewidth=1, color='blue', alpha=0.7)
ax1.plot(df.index, df['upper_keltner'], label='Upper Keltner', linewidth=1, color='red', alpha=0.7)
ax1.plot(df.index, df['lower_keltner'], label='Lower Keltner', linewidth=1, color='green', alpha=0.7)
ax1.plot(df.index, df['upper_keltner'], df['lower_keltner'], alpha=0.1, color='gray')
ax1.fill_between(df.index, df[= df[df['position'] > 0]
long_positions = df[df['position'] < 0]
short_positions if not long_positions.empty:
'close'], color='green', marker='^', s=50, label='Long Position', zorder=5)
ax1.scatter(long_positions.index, long_positions[if not short_positions.empty:
'close'], color='red', marker='v', s=50, label='Short Position', zorder=5)
ax1.scatter(short_positions.index, short_positions[f'{title} - Price and Signals')
ax1.set_title('Price')
ax1.set_ylabel(
ax1.legend()True, alpha=0.3)
ax1.grid('rsi'], label='RSI', linewidth=1, color='purple')
ax2.plot(df.index, df[=70, color='r', linestyle='--', alpha=0.5, label='Overbought (70)')
ax2.axhline(y=30, color='g', linestyle='--', alpha=0.5, label='Oversold (30)')
ax2.axhline(y=60, color='orange', linestyle=':', alpha=0.5, label='Long Threshold (60)')
ax2.axhline(y=40, color='orange', linestyle=':', alpha=0.5, label='Short Threshold (40)')
ax2.axhline(y'RSI Indicator')
ax2.set_title('RSI')
ax2.set_ylabel('Date')
ax2.set_xlabel(
ax2.legend()True, alpha=0.3)
ax2.grid(0, 100)
ax2.set_ylim(for ax in [ax1, ax2]:
'%Y-%m'))
ax.xaxis.set_major_formatter(mdates.DateFormatter(=6))
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=45)
plt.setp(ax.xaxis.get_majorticklabels(), rotation
plt.tight_layout()
plt.show()= len(long_positions)
total_long_days = len(short_positions)
total_short_days = len(df)
total_days print(f"\nTrading Summary:")
print(f"Total trading days: {total_days}")
print(f"Days in long position: {total_long_days} ({total_long_days/total_days*100:.1f}%)")
print(f"Days in short position: {total_short_days} ({total_short_days/total_days*100:.1f}%)")
print(f"Days in cash: {total_days - total_long_days - total_short_days} ({(total_days - total_long_days - total_short_days)/total_days*100:.1f}%)")
params
tuple defines:
atr_period
(14): Period for ATR calculation.atr_multiplier
(1.5): Multiplier for Keltner Channel
bands.rsi_period
(14): Period for RSI calculation.trail_percent
(0.02): Unused in the provided code but
likely intended for trailing stops.ema_period
(30): Period for the EMA trend filter.__init__
method
sets up:
trade_data
list to store data for plotting.next
method:
plot_results
function:
yfinance
.def run_rolling_backtest(
="SOL-USD",
ticker="2020-01-01",
start="2025-01-01",
end=12,
window_months=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, progress=False)
data if data.empty or len(data) < 90:
print("Not enough data.")
+= rd.relativedelta(months=window_months)
current_start continue
= data.droplevel(1, 1) if isinstance(data.columns, pd.MultiIndex) else data
data
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params)
cerebro.addstrategy(strategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': ret,
'final_value': final_val,
})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
+= rd.relativedelta(months=window_months)
current_start
return pd.DataFrame(all_results)
## Conclusion
The Volatility Breakout Strategy effectively captures momentum-driven price movements using Keltner Channels, RSI, and an EMA trend filter. Its conservative position sizing and dynamic exit conditions make it suitable for volatile markets like cryptocurrencies. The custom plotting function enhances analysis by visualizing trade signals and indicator behavior, while the backtest framework provides a robust evaluation of performance. Traders can further optimize parameters (e.g., ATR multiplier, RSI thresholds) to adapt the strategy to specific assets or market conditions.