← Back to Home
Volatility Adaptive MA Strategy A Dynamic Trend-Following System

Volatility Adaptive MA Strategy A Dynamic Trend-Following System

Introduction

The Volatility Adaptive MA Strategy is a trend-following trading system designed to capitalize on directional price movements in volatile markets, such as cryptocurrencies. It uses Kaufman’s Adaptive Moving Averages (KAMA), Bollinger Bands, and the Average True Range (ATR) to identify trends, filter signals based on volatility, and manage risk. The strategy incorporates dynamic position sizing, trailing stops, and a maximum drawdown limit to enhance robustness. This article explores the strategy’s logic, reasoning, and implementation, focusing on key code components.

Strategy Overview

The strategy combines the following components:

  1. Kaufman’s Adaptive Moving Averages (KAMA): Uses fast and slow KAMA indicators to detect trend changes via crossovers.
  2. Volatility Filters: Bollinger Band width and ATR ensure trades are taken in high-volatility conditions conducive to breakouts.
  3. Risk Management: Employs ATR-based position sizing, trailing stops, and a drawdown limit to control risk.
  4. Alternative Entries: Allows trades based on strong KAMA divergence in high-volatility environments.

The strategy is tailored for volatile assets, adapting to market conditions through dynamic adjustments and strict risk controls.

Logic and Reasoning

Entry Conditions

The strategy triggers trades under two scenarios, both requiring high volatility:

Position Sizing

Position sizes are calculated dynamically based on ATR normalized by price. Higher volatility (larger ATR) results in smaller positions, scaling between 30% and 95% of available capital.

Risk Management

Why This Approach?

Key Code Components

Below are the main components of the VolatilityAdaptiveMAStrategy class, focusing on the parameters and next function.

import backtrader as bt
import numpy as np

class VolatilityAdaptiveMAStrategy(bt.Strategy):
    params = (
        ('kama_fast_period', 10),   # Fast KAMA period
        ('kama_slow_period', 30),   # Slow KAMA period
        ('kama_sc_fastest', 2),     # Fastest smoothing constant
        ('kama_sc_slowest', 30),    # Slowest smoothing constant
        ('atr_period', 14),         # ATR period
        ('atr_threshold', 0.02),    # ATR threshold (2% of price)
        ('bb_period', 20),          # Bollinger Bands period for volatility
        ('bb_width_threshold', 0.02), # BB width threshold (2%)
        ('trail_atr_mult', 2.0),    # Trailing stop ATR multiplier
        ('max_drawdown', 0.15),     # Maximum drawdown (15%)
        ('max_position_pct', 0.95), # Maximum position size
        ('min_position_pct', 0.30), # Minimum position size
    )
    
    def __init__(self):
        self.dataclose = self.datas[0].close
        self.atr = bt.indicators.ATR(period=self.params.atr_period)
        self.bb = bt.indicators.BollingerBands(period=self.params.bb_period)
        self.kama_fast = self.create_kama(self.params.kama_fast_period)
        self.kama_slow = self.create_kama(self.params.kama_slow_period)
        self.order = None
        self.trail_order = None
        self.initial_cash = None
        self.peak_value = None
        self.trading_paused = False

    def create_kama(self, period):
        change = abs(self.dataclose - self.dataclose(-period))
        volatility = bt.indicators.SumN(
            abs(self.dataclose - self.dataclose(-1)), 
            period=period
        )
        efficiency_ratio = change / volatility
        sc_fastest = 2.0 / (self.params.kama_sc_fastest + 1)
        sc_slowest = 2.0 / (self.params.kama_sc_slowest + 1)
        sc = (efficiency_ratio * (sc_fastest - sc_slowest) + sc_slowest) ** 2
        return bt.indicators.ExponentialMovingAverage(period=period)

    def check_volatility_conditions(self):
        if len(self.atr) == 0 or len(self.bb) == 0:
            return False
        try:
            atr_pct = self.atr[0] / self.dataclose[0]
            atr_condition = atr_pct > self.params.atr_threshold
            bb_width = (self.bb.top[0] - self.bb.bot[0]) / self.bb.mid[0]
            bb_condition = bb_width > self.params.bb_width_threshold
            return atr_condition and bb_condition
        except Exception:
            return False

    def check_drawdown_limit(self):
        if self.initial_cash is None:
            self.initial_cash = self.broker.getvalue()
            self.peak_value = self.initial_cash
            return False
        current_value = self.broker.getvalue()
        if current_value > self.peak_value:
            self.peak_value = current_value
        drawdown = (self.peak_value - current_value) / self.peak_value
        if drawdown > self.params.max_drawdown:
            self.trading_paused = True
            return True
        return False

    def calculate_volatility_position_size(self):
        if len(self.atr) == 0:
            return self.params.max_position_pct
        try:
            atr_pct = self.atr[0] / self.dataclose[0]
            normalized_atr = min(0.08, max(0.01, atr_pct))
            vol_factor = (0.08 - normalized_atr) / 0.07
            position_pct = (self.params.min_position_pct + 
                          vol_factor * (self.params.max_position_pct - self.params.min_position_pct))
            return max(self.params.min_position_pct, min(self.params.max_position_pct, position_pct))
        except Exception:
            return self.params.max_position_pct

    def next(self):
        if self.order:
            return
        if self.check_drawdown_limit():
            if self.position:
                self.cancel_trail()
                self.order = self.close()
            return
        if self.trading_paused:
            return
        if self.position:
            if not self.trail_order:
                if self.position.size > 0:
                    self.trail_order = self.sell(
                        exectype=bt.Order.StopTrail,
                        trailamount=self.atr[0] * self.params.trail_atr_mult)
                elif self.position.size < 0:
                    self.trail_order = self.buy(
                        exectype=bt.Order.StopTrail,
                        trailamount=self.atr[0] * self.params.trail_atr_mult)
            return
        required_bars = max(self.params.kama_slow_period, self.params.atr_period, self.params.bb_period)
        if len(self) < required_bars:
            return
        if not self.check_volatility_conditions():
            return
        kama_fast_current = self.kama_fast[0]
        kama_slow_current = self.kama_slow[0]
        kama_fast_prev = self.kama_fast[-1]
        kama_slow_prev = self.kama_slow[-1]
        bullish_cross = (kama_fast_current > kama_slow_current and 
                        kama_fast_prev <= kama_slow_prev)
        bearish_cross = (kama_fast_current < kama_slow_current and 
                        kama_fast_prev >= kama_slow_prev)
        position_size_pct = self.calculate_volatility_position_size()
        current_price = self.dataclose[0]
        if bullish_cross and not self.position:
            self.cancel_trail()
            cash = self.broker.getcash()
            target_value = cash * position_size_pct
            shares = target_value / current_price
            self.order = self.buy(size=shares)
        elif bearish_cross and not self.position:
            self.cancel_trail()
            cash = self.broker.getcash()
            target_value = cash * position_size_pct
            shares = target_value / current_price
            self.order = self.sell(size=shares)
        elif not self.position and self.check_volatility_conditions():
            kama_spread = abs(kama_fast_current - kama_slow_current) / kama_slow_current
            if kama_spread > 0.02:
                if kama_fast_current > kama_slow_current * 1.01:
                    cash = self.broker.getcash()
                    target_value = cash * (position_size_pct * 0.7)
                    shares = target_value / current_price
                    self.order = self.buy(size=shares)
                elif kama_fast_current < kama_slow_current * 0.99:
                    cash = self.broker.getcash()
                    target_value = cash * (position_size_pct * 0.7)
                    shares = target_value / current_price
                    self.order = self.sell(size=shares)

Code Explanation

Rolling 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 20250712131307.png
Pasted image 20250712131317.png
Pasted image 20250712131323.png

Conclusion

The Volatility Adaptive MA Strategy effectively combines trend-following (KAMA crossovers) with volatility filtering (ATR and Bollinger Bands) and robust risk management (trailing stops, drawdown limits). Its dynamic position sizing and high-volatility focus make it suitable for volatile markets like cryptocurrencies. The strategy’s flexibility, with primary and alternative entry conditions, enhances its ability to capture trends while managing risk. Traders can refine parameters (e.g., KAMA periods, volatility thresholds) to optimize performance for specific assets or market conditions.