← Back to Home
Volatility Breakout Strategy A Momentum-Driven Trading System with Keltner Channels

Volatility Breakout Strategy A Momentum-Driven Trading System with Keltner Channels

Introduction

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.

Strategy Overview

The strategy leverages the following components:

  1. Keltner Channels: Defines volatility-based breakout levels using an EMA as the centerline and ATR-based bands.
  2. RSI: Filters entries to ensure momentum aligns with breakouts (RSI > 60 for longs, < 40 for shorts).
  3. EMA Trend Filter: Ensures trades align with the prevailing trend (price above EMA for longs, below for shorts).
  4. Risk Management: Uses a fixed percentage sizer (10% of capital per trade) and exit conditions based on Keltner Channels and RSI.

The backtest fetches BTC-USD data via yfinance and evaluates performance with detailed logging and visualization of trades.

Logic and Reasoning

Entry Conditions

The strategy enters trades when:

Exit Conditions

Position Sizing

The strategy uses a fixed 10% of available capital per trade, a conservative approach to manage risk in volatile markets like cryptocurrencies.

Why This Approach?

Key Code Components

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}%)")

Code Explanation

Backtest

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)

Pasted image 20250711221309.png ## 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.