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
current_high = self.datahigh[0]
current_low = self.datalow[0]
current_upper_keltner = self.upper_keltner[0]
current_lower_keltner = self.lower_keltner[0]
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
df = pd.DataFrame(strategy_instance.trade_data)
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 12), height_ratios=[3, 1])
ax1.plot(df.index, df['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.fill_between(df.index, df['upper_keltner'], df['lower_keltner'], alpha=0.1, color='gray')
long_positions = df[df['position'] > 0]
short_positions = df[df['position'] < 0]
if not long_positions.empty:
ax1.scatter(long_positions.index, long_positions['close'], color='green', marker='^', s=50, label='Long Position', zorder=5)
if not short_positions.empty:
ax1.scatter(short_positions.index, short_positions['close'], color='red', marker='v', s=50, label='Short Position', zorder=5)
ax1.set_title(f'{title} - Price and Signals')
ax1.set_ylabel('Price')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax2.plot(df.index, df['rsi'], label='RSI', linewidth=1, color='purple')
ax2.axhline(y=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.set_title('RSI Indicator')
ax2.set_ylabel('RSI')
ax2.set_xlabel('Date')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.set_ylim(0, 100)
for ax in [ax1, ax2]:
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=6))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
plt.tight_layout()
plt.show()
total_long_days = len(long_positions)
total_short_days = len(short_positions)
total_days = len(df)
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(
ticker="SOL-USD",
start="2020-01-01",
end="2025-01-01",
window_months=12,
strategy_params=None
):
strategy_params = strategy_params or {}
all_results = []
start_dt = pd.to_datetime(start)
end_dt = pd.to_datetime(end)
current_start = start_dt
while True:
current_end = current_start + rd.relativedelta(months=window_months)
if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
data = yf.download(ticker, start=current_start, end=current_end, progress=False)
if data.empty or len(data) < 90:
print("Not enough data.")
current_start += rd.relativedelta(months=window_months)
continue
data = data.droplevel(1, 1) if isinstance(data.columns, pd.MultiIndex) else data
feed = bt.feeds.PandasData(dataname=data)
cerebro = bt.Cerebro()
cerebro.addstrategy(strategy, **strategy_params)
cerebro.adddata(feed)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
start_val = cerebro.broker.getvalue()
cerebro.run()
final_val = cerebro.broker.getvalue()
ret = (final_val - start_val) / start_val * 100
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}")
current_start += rd.relativedelta(months=window_months)
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.