← Back to Home
A Trend-Following Strategy using Adaptive Volatility Index Dynamic Average with CMO, ADX, and Momentum

A Trend-Following Strategy using Adaptive Volatility Index Dynamic Average with CMO, ADX, and Momentum

The Volatility Index Dynamic Average (VIDYA), created by Tushar Chande, is an adaptive moving average that adjusts its smoothing period based on market volatility. This makes it more responsive during trending periods and smoother during choppy or range-bound markets. This article explores a backtrader implementation of the VIDYA indicator, coupled with the Chande Momentum Oscillator (CMO), Average Directional Index (ADX), and a price momentum filter to create a robust trend-following strategy, explicitly incorporating your preferred use of trailing stops for risk management.

Pasted image 20250730011329.png

The Adaptive Core: VIDYA and CMO

At the heart of the strategy is the VIDYA indicator. Unlike simple moving averages with fixed periods, VIDYA dynamically changes its period based on a “Chande Momentum Oscillator (CMO) efficiency ratio”.

This dynamic adjustment allows VIDYA to quickly react to new trends while filtering out noise during consolidation phases.

VIDYACMOADXMomentumStrategy: The Confirmed Trend Follower

This strategy, named VIDYACMOADXMomentumStrategy to reflect its components, combines the adaptive nature of VIDYA with several confirmation filters to enhance signal quality.

import backtrader as bt
import numpy as np

class CMOIndicator(bt.Indicator):
    lines = ('cmo',)
    params = (('period', 14),)
    
    def __init__(self):
        self.addminperiod(self.params.period)
        
    def next(self):
        gains = losses = 0.0
        # Calculate sums of gains and losses over the period
        # Note: self.data.close[-i] refers to 'i' bars ago, self.data.close[-i-1] is 'i+1' bars ago
        for i in range(1, self.params.period + 1):
            # Ensure enough historical data exists
            if len(self.data) > i: 
                change = self.data.close[-i] - self.data.close[-i-1]
                if change > 0:
                    gains += change
                else:
                    losses += abs(change)
        
        # Calculate CMO. Handle division by zero.
        denominator = gains + losses
        self.lines.cmo[0] = 100 * (gains - losses) / denominator if denominator > 0 else 0


class VIDYACMOADXMomentumStrategy(bt.Strategy):
    params = (
        ('cmo_period', 10),           # Period for CMO calculation
        ('period_min', 10),           # Minimum VIDYA smoothing period
        ('period_max', 60),           # Maximum VIDYA smoothing period
        ('atr_period', 14),           # Period for Average True Range
        ('atr_multiplier', 2.),       # Multiplier for ATR-based trailing stop
        ('cooldown_bars', 3),         # Bars to wait after an exit before re-entry
        ('threshold_pct', 0.015),     # Percentage threshold for price crossing VIDYA
        ('adx_period', 14),           # Period for ADX
        ('adx_threshold', 20),        # ADX threshold for trend strength
        ('momentum_period', 50),      # Period for Momentum indicator
        ('momentum_threshold', 0.005), # Minimum required momentum percentage
    )
    
    def __init__(self):
        # Initialize required indicators
        self.cmo = CMOIndicator(period=self.params.cmo_period)
        self.atr = bt.indicators.ATR(period=self.params.atr_period)
        self.adx = bt.indicators.AverageDirectionalMovementIndex(period=self.params.adx_period)
        # Momentum calculates price change over 'period' bars
        self.momentum = bt.indicators.Momentum(self.data.close, period=self.params.momentum_period) 
        
        self.vidya_value = None       # Current VIDYA value
        self.prev_vidya_value = None  # Previous VIDYA value
        self.last_exit_bar = 0        # To track cooldown period
        self.order = None             # To track pending orders
        self.trailing_stop_order = None # To track the active trailing stop order
        
    def next(self):
        # Ensure sufficient data for all indicators
        min_periods = max(self.params.cmo_period, self.params.period_max, self.params.atr_period, 
                          self.params.adx_period, self.params.momentum_period)
        if len(self.data) < min_periods + 1:
            return
            
        # If an order is pending, do not generate new signals
        if self.order:
            return
            
        # Store previous VIDYA value before calculating the current one
        self.prev_vidya_value = self.vidya_value
        
        # Calculate adaptive period using lagged (previous bar's) CMO
        # CMO value is between -100 and 100, so abs(CMO)/100 normalizes it to 0-1
        lagged_norm_abs_cmo = min(1.0, abs(self.cmo[-1]) / 100.0) 
        # Adaptive period ranges from period_max (low momentum) to period_min (high momentum)
        adaptive_period = self.params.period_max - lagged_norm_abs_cmo * (self.params.period_max - self.params.period_min)
        
        # Calculate smoothing factor alpha
        alpha = 2.0 / (adaptive_period + 1)
        
        # Initialize VIDYA on first sufficient bar
        if self.vidya_value is None:
            self.vidya_value = self.data.close[0]
            return
        
        # Update VIDYA
        self.vidya_value = alpha * self.data.close[0] + (1 - alpha) * self.vidya_value
        
        # Define lagged values for comparison (signals are based on completed bars)
        lagged_close = self.data.close[-1]
        lagged_vidya = self.prev_vidya_value
        
        # --- Entry Filters ---
        # Cooldown period after an exit
        if (len(self.data) - self.last_exit_bar) < self.params.cooldown_bars:
            return
            
        # TREND STRENGTH FILTER - ADX must be above threshold (strong trend)
        if self.adx[0] < self.params.adx_threshold:
            return
            
        # MOMENTUM VALIDATOR - Recent momentum must be strong enough
        # Momentum indicator returns the difference between current and 'period' bars ago
        # Convert to percentage change for threshold comparison
        
        # Ensure there's enough data for momentum calculation
        if len(self.data) <= self.params.momentum_period:
            return # Not enough data for momentum
        
        # Use previous close as reference for percentage change, safer than 0 if momentum[0] is price diff
        if self.data.close[-self.params.momentum_period] == 0:
            return # Avoid division by zero
        
        momentum_pct = (self.momentum[0] / self.data.close[-self.params.momentum_period])
        # Note: momentum_threshold is typically a decimal (e.g., 0.005 for 0.5%)
        
        # Check for NaN values in indicators before proceeding
        if np.isnan(lagged_vidya) or np.isnan(self.adx[0]) or np.isnan(momentum_pct):
            return

        # --- Entry Logic ---
        if not self.position: # Only consider new entries if not in a position
            threshold = lagged_vidya * self.params.threshold_pct
            
            # Long Entry: Current close crosses above VIDYA by a threshold, with positive momentum and strong trend
            if (lagged_close > (lagged_vidya + threshold) and 
                momentum_pct > self.params.momentum_threshold):
                self.order = self.buy()
                
            # Short Entry: Current close crosses below VIDYA by a threshold, with negative momentum and strong trend
            elif (lagged_close < (lagged_vidya - threshold) and 
                  momentum_pct < -self.params.momentum_threshold):
                self.order = self.sell()
        
        # --- Exit Logic (Signal Reversal) ---
        # If long and price closes below VIDYA
        elif self.position.size > 0 and lagged_close < lagged_vidya:
            self.close()
            self.last_exit_bar = len(self.data)
            if self.trailing_stop_order: # Cancel existing trailing stop
                self.cancel(self.trailing_stop_order)
            self.trailing_stop_order = None # Clear the reference
            
        # If short and price closes above VIDYA
        elif self.position.size < 0 and lagged_close > lagged_vidya:
            self.close()
            self.last_exit_bar = len(self.data)
            if self.trailing_stop_order: # Cancel existing trailing stop
                self.cancel(self.trailing_stop_order)
            self.trailing_stop_order = None # Clear the reference

    def notify_order(self, order):
        if order.status in [order.Completed]:
            # If an entry order is completed, place a trailing stop
            if order.isbuy():
                # The trailing stop will be a sell order
                self.trailing_stop_order = self.sell(
                    exectype=bt.Order.StopTrail, 
                    trailamount=self.params.atr_multiplier * self.atr[0]
                )
            elif order.issell(): # This means a short entry order completed
                # The trailing stop will be a buy order
                self.trailing_stop_order = self.buy(
                    exectype=bt.Order.StopTrail, 
                    trailamount=self.params.atr_multiplier * self.atr[0]
                )
        
        # Reset the order tracking variable if the order is completed, canceled, or rejected
        if order.status in [order.Completed, order.Canceled, order.Margin, order.Rejected]:
            self.order = None # Clear the main order reference
            if order == self.trailing_stop_order: # If the current order is the trailing stop
                self.trailing_stop_order = None # Clear its reference too

Strategy Logic Breakdown:

  1. Initialization (__init__):

    • Initializes the CMOIndicator, ATR (Average True Range), ADX (Average Directional Index), and Momentum indicators.
    • Sets up variables to track vidya_value, prev_vidya_value, last_exit_bar (for cooldown), and order references (self.order, self.trailing_stop_order).
  2. next() Method (Per Bar Logic):

    • Data Sufficiency Check: Ensures enough historical bars are available for all indicators to be calculated accurately.
    • Order Management: Prevents new orders if one is already pending.
    • VIDYA Calculation:
      • Calculates lagged_norm_abs_cmo from the previous bar’s CMO, which drives the adaptiveness.
      • Determines the adaptive_period for VIDYA, which shrinks with strong momentum (high abs(CMO)) and expands with low momentum (low abs(CMO)).
      • Computes the alpha smoothing factor.
      • Updates self.vidya_value using the current close price and the adaptive alpha.
    • Entry Filters: Before considering an entry, the strategy applies several filters:
      • Cooldown: Prevents immediate re-entry after an exit for a specified number of bars (cooldown_bars).
      • Trend Strength (ADX): Requires the ADX value to be above adx_threshold to ensure a sufficiently strong trend is present. This helps avoid choppy markets.
      • Momentum Validator: Checks if the price momentum (percentage change over momentum_period) meets a momentum_threshold. This ensures that entries are only made when there’s significant directional conviction.
    • Entry Logic:
      • If no position is open, it checks for a crossover of the lagged close price above/below the lagged vidya_value by a threshold_pct.
      • Combined with positive/negative momentum_pct and the ADX filter, it triggers buy() or sell() orders.
    • Exit Logic (Signal Reversal):
      • If a long position is active, and the lagged close price falls below the lagged vidya_value, the position is closed.
      • If a short position is active, and the lagged close price rises above the lagged vidya_value, the position is closed.
      • Upon closing, the last_exit_bar is updated, and any active trailing stop order for that position is canceled.
  3. notify_order() Method (Order Lifecycle):

    • Trailing Stop Implementation: This method is central to your preference for trailing stops. Whenever a buy or sell entry order completes, a corresponding trailing stop order is immediately placed.
      • For a buy (long) order, a sell trailing stop is placed with a trailamount based on a multiple of the current ATR.
      • For a sell (short) order, a buy trailing stop is placed with the same ATR-based trailamount.
    • Order Cleanup: Resets the self.order and self.trailing_stop_order flags once orders are completed, canceled, or rejected, allowing the strategy to process new signals.

This VIDYACMOADXMomentumStrategy represents a well-structured approach to trend following, using an adaptive moving average as its core, reinforced by multiple indicators to confirm trend strength and momentum, and safeguarded by the consistent application of trailing stops.