← Back to Home
A Volatility-Oscillator Divergence Strategy with Python and Backtrader

A Volatility-Oscillator Divergence Strategy with Python and Backtrader

In the complex world of algorithmic trading, a common pitfall is an obsessive focus on finding the perfect entry signal. While a good entry is crucial, it’s merely the opening act. A truly robust strategy is a complete system—a symphony of a core concept, rigorous qualification filters, and, most importantly, disciplined risk management.

This article provides a deep dive into the anatomy of such a system: a Volatility-Oscillator Divergence Strategy. We’ll dissect its logic, from the initial signal to the final exit, using a sophisticated Python implementation in the backtrader library. This strategy doesn’t just find a potential trade; it scrutinizes it through multiple layers of confirmation and manages it dynamically from start to finish, offering a blueprint for building more resilient automated trading systems.

Section 1: The Core Signal — A Unique Take on Divergence

At its heart, this strategy trades on divergence, a classic technical analysis concept where a technical indicator moves in the opposite direction of the price. However, instead of a standard momentum oscillator like RSI, this strategy employs a custom Volatility Oscillator. This is created by calculating the Rate of Change (ROC) of the 30-day historical volatility.

This leads to two powerful, non-intuitive divergence signals:

  1. Bullish Divergence (Healthy Trend): The price is making higher highs, but the volatility oscillator is making lower highs. This suggests the uptrend is calm, stable, and not driven by irrational euphoria. It’s a sign of a healthy trend continuation.
  2. Bearish Divergence (Accelerating Momentum): The price is making lower lows, but the volatility oscillator is making higher highs. This indicates that volatility is increasing as the price falls, a classic sign of panic and accelerating downward momentum.

These divergence signals form the foundation of our strategy, but they are never traded in isolation. They are simply the first question asked; the answer must be confirmed by a series of filters.

Section 2: The Filters — From Signal to High-Conviction Setup

A raw signal is not enough. To improve the probability of success, the strategy layers on several conditions that must be met before a trade is even considered. This filtering process is handled within the strategy’s __init__ and next methods.

class VolatilityOscillatorDivergenceStrategy(bt.Strategy):
    """Volatility-Oscillator Divergence: When vol-osc diverges from price, lean into trend (with ADX + Rising ADX filters + ATR Trailing Stops)"""
    
    params = (
        ('vol_window', 30),                  # Volatility calculation period
        ('vol_osc_roc_period', 7),           # ROC period for volatility oscillator
        ('divergence_price_lookback', 7),    # Price change lookback for divergence
        ('divergence_vol_osc_lookback', 7),  # Vol oscillator change lookback for divergence
        ('long_term_sma_window', 30),        # SMA for overall trend detection
        ('atr_window', 14),                  # ATR period for stops
        ('atr_multiplier', 2.0),             # ATR multiplier for stops
        ('adx_period', 14),                  # ADX period for trend strength
        ('adx_threshold', 20),               # Minimum ADX for trade
        ('rising_adx_lookback', 7),          # Periods to check if ADX is rising
    )
    
    def __init__(self):
        # --- Volatility Oscillator ---
        self.returns = bt.indicators.PctChange(self.data.close, period=1)
        self.volatility = bt.indicators.StandardDeviation(self.returns, period=self.params.vol_window)
        self.vol_oscillator = bt.indicators.ROC(self.volatility, period=self.params.vol_osc_roc_period)
        
        # --- Divergence Calculation ---
        self.price_change = self.data.close - self.data.close(-self.params.divergence_price_lookback)
        self.vol_osc_change = self.vol_oscillator - self.vol_oscillator(-self.params.divergence_vol_osc_lookback)
        
        # --- Filters and Stops ---
        self.long_term_sma = bt.indicators.SMA(self.data.close, period=self.params.long_term_sma_window)
        self.adx = bt.indicators.ADX(self.data, period=self.params.adx_period)
        self.atr = bt.indicators.ATR(self.data, period=self.params.atr_window)
        # ... trading variables for analysis ...
        
    def next(self):
        # ... [stop loss logic comes first] ...

        # --- Entry Logic ---
        # 1. Detect a raw divergence signal
        signal_detected = False
        if not self.position:
            if (is_price_up and is_vol_osc_down and is_overall_uptrend) or \
               (is_price_down and is_vol_osc_up and is_overall_downtrend):
                signal_detected = True
        
        # 2. Apply all filters
        all_filters_pass = (is_trending_market and is_adx_rising)
        
        # 3. Enter trade only if divergence AND all filters pass
        if not self.position and signal_detected and all_filters_pass:
            if is_price_up and is_vol_osc_down and is_overall_uptrend:
                self.buy()
                # ... [set initial stop] ...
            elif is_price_down and is_vol_osc_up and is_overall_downtrend:
                self.sell()
                # ... [set initial stop] ...

The entry logic is a multi-stage validation process:

  1. Trend Alignment: The first filter checks if the divergence signal aligns with the long-term trend, defined by a 30-day Simple Moving Average (SMA). A bullish divergence is only considered if the price is above the SMA, and a bearish divergence only if it’s below.
  2. Trend Strength (ADX): The Average Directional Index (ADX) is used to gauge the strength of the prevailing trend. A trade is only considered if the ADX is above 20, filtering out choppy, sideways markets where divergence signals are less reliable.
  3. Trend Momentum (Rising ADX): The final filter adds a layer of momentum. It checks if the ADX value itself is rising. This ensures the strategy enters not just in a strong trend, but in one that is actively gaining strength.

Only when a divergence signal passes all three of these filters does it become a high-conviction entry signal worthy of risking capital.

Section 3: The Art of the Exit — Dynamic ATR Trailing Stops

The most sophisticated part of this strategy is its exit logic. A simple target or static stop loss is often inefficient. This strategy employs a dynamic ATR-based Trailing Stop, which adapts to the market’s recent volatility. This mechanism is designed to let winning trades run while cutting losses decisively.

    def next(self):
        # Need enough data for all indicators
        if len(self) < 50:
            return
            
        current_price = self.data.close[0]

        # 1. Check trailing stop loss FIRST
        if self.position:
            if self.position.size > 0 and current_price <= self.stop_price:
                self.close()
                self.trailing_stop_hits += 1
                self.log(f'TRAILING STOP LOSS - Long closed at {current_price:.2f}')
                return
            elif self.position.size < 0 and current_price >= self.stop_price:
                self.close()
                self.trailing_stop_hits += 1
                self.log(f'TRAILING STOP LOSS - Short closed at {current_price:.2f}')
                return
        
        # 2. Update trailing stop for existing positions BEFORE checking entries
        if self.position:
            if self.position.size > 0:  # Long position - trail stop upward
                new_stop = current_price - (self.atr[0] * self.params.atr_multiplier)
                if new_stop > self.stop_price:
                    self.stop_price = new_stop
                    self.stop_updates += 1
                    
            elif self.position.size < 0:  # Short position - trail stop downward
                new_stop = current_price + (self.atr[0] * self.params.atr_multiplier)
                if new_stop < self.stop_price:
                    self.stop_price = new_stop
                    self.stop_updates += 1

        # ... [Entry Logic as shown in previous snippet] ...

The risk management logic is paramount and operates with two key principles:

  1. Stops First: On every new bar of data, the very first thing the code does is check if an existing position needs to be stopped out. This ensures that risk management takes precedence over all other decisions.
  2. Dynamic Trailing: If a trade is open and not stopped out, the strategy calculates a new potential stop loss based on the current price and the latest ATR value. Crucially, it only adjusts the stop if it locks in more profit (or reduces risk). For a long trade, the stop can only move up; for a short trade, it can only move down.

Section 4: Analysis — Quantifying Performance

A backtest is more than just a final equity number. This strategy’s stop() method is customized to provide a detailed report, offering deep insights into the system’s behavior.

# --- Backtesting Setup ---
ticker = "ETH-USD"

# Per user instructions, use yfinance download with auto_adjust=False and droplevel
data = yf.download(ticker, period="5y", interval="1d", auto_adjust=False)
data.columns = data.columns.droplevel(1)

cerebro = bt.Cerebro()
cerebro.addstrategy(VolatilityOscillatorDivergenceStrategy)
# ... [add data, capital, commission, sizer, analyzers] ...

# Run the backtest
results = cerebro.run()

# --- Custom Analysis Output from the strategy's stop() method ---
# === TRAILING STOP ANALYSIS ===
# Trailing Stop Hits: 20 (62.5% of trades)
# Total Stop Updates: 150
# Avg Stop Updates per Trade: 4.7
#
# === FILTER ANALYSIS ===
# Total Signals Detected: 125
# Signals Executed: 32
# Signals Filtered Out: 93
#   • ADX < 20: 75
#   • ADX Not Rising: 18
Pasted image 20250615192739.png

Conclusion

This deep dive demonstrates that a successful trading strategy is a complete, well-reasoned system. It begins with a unique market insight (the volatility divergence), rigorously qualifies it with a series of logical filters (trend alignment, strength, and momentum), and manages the resulting trade with a dynamic, adaptive risk management plan. By building systems that are not only profitable but also transparent and analyzable, we move from chasing random signals to executing a well-defined trading philosophy.