← Back to Home
Regime Filtered Trend Strategy A Market-Adaptive Trend-Following System

Regime Filtered Trend Strategy A Market-Adaptive Trend-Following System

Introduction

The Regime Filtered Trend Strategy is a sophisticated trend-following system designed to trade in volatile markets, such as cryptocurrencies (e.g., BTC-USD), by adapting to market conditions through regime classification. It uses Simple Moving Averages (SMAs) for trend detection, with regime filters based on the Average Directional Index (ADX), Bollinger Bands, ATR, and MA separation. The strategy dynamically adjusts position sizing and stop-loss levels based on whether the market is trending or ranging, ensuring optimal trade execution. A rolling backtest evaluates performance over 12-month windows from 2020 to 2025. This article details the strategy’s logic, reasoning, implementation, and backtesting framework, focusing on key code components.

Strategy Overview

The strategy integrates the following components:

  1. Trend Detection: Fast and slow SMAs identify bullish and bearish crossovers for trend-following signals.
  2. Regime Classification: Combines ADX, Bollinger Band width, ATR, and MA separation to classify markets as “trending,” “ranging,” or “uncertain,” with a confirmation mechanism to ensure stability.
  3. Adaptive Risk Management: Adjusts position sizes and stop-loss multipliers based on the market regime and volatility, using wider stops in trending markets and tighter stops in ranging markets.
  4. Alternative Entries: Captures strong trends with high confidence using MA spread, enhancing adaptability.

The strategy is tailored for volatile assets, with robust regime detection to avoid trading in unfavorable conditions and dynamic risk controls to protect capital.

Logic and Reasoning

Market Regime Classification

The strategy classifies market regimes using four indicators:

A regime is classified as:

Regime changes require confirmation over 3 bars to avoid false transitions, ensuring stability.

Entry Conditions

Position Sizing

Position sizes are adjusted based on regime and confidence:

Risk Management

Why This Approach?

Key Code Components

Below are the main components of the RegimeFilteredTrendStrategy class and the rolling backtest function, focusing on the parameters, regime classification, and next function.

import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf
import dateutil.relativedelta as rd

class RegimeFilteredTrendStrategy(bt.Strategy):
    params = (
        ('ma_fast', 20),            # Fast moving average
        ('ma_slow', 50),            # Slow moving average
        ('adx_period', 14),         # ADX period
        ('adx_trending_threshold', 25), # ADX threshold for trending regime
        ('bb_period', 20),          # Bollinger Bands period
        ('bb_width_threshold', 0.03), # BB width threshold for trending (3%)
        ('volatility_lookback', 20), # Volatility measurement period
        ('vol_trending_threshold', 0.025), # Volatility threshold for trending
        ('atr_period', 14),         # ATR period
        ('trail_atr_mult', 2.0),    # Trailing stop multiplier (trending)
        ('range_atr_mult', 1.5),    # Trailing stop multiplier (ranging)
        ('max_position_pct', 0.95), # Maximum position size
        ('min_position_pct', 0.20), # Minimum position size
        ('regime_confirmation', 3), # Bars to confirm regime change
    )
    
    def __init__(self):
        self.dataclose = self.datas[0].close
        self.ma_fast = bt.indicators.SMA(period=self.params.ma_fast)
        self.ma_slow = bt.indicators.SMA(period=self.params.ma_slow)
        self.adx = bt.indicators.ADX(period=self.params.adx_period)
        self.bb = bt.indicators.BollingerBands(period=self.params.bb_period)
        self.atr = bt.indicators.ATR(period=self.params.atr_period)
        self.order = None
        self.trail_order = None
        self.current_regime = "unknown"
        self.regime_history = []
        self.regime_confidence = 0
        self.volatility_history = []

    def classify_market_regime(self):
        if (len(self.adx) == 0 or len(self.bb) == 0 or 
            len(self.volatility_history) < 5):
            return "unknown", 0
        try:
            trending_signals = 0
            total_signals = 0
            total_signals += 1
            if self.adx[0] > self.params.adx_trending_threshold:
                trending_signals += 1
            total_signals += 1
            bb_width = (self.bb.top[0] - self.bb.bot[0]) / self.bb.mid[0]
            if bb_width > self.params.bb_width_threshold:
                trending_signals += 1
            total_signals += 1
            current_vol = self.volatility_history[-1]
            if current_vol > self.params.vol_trending_threshold:
                trending_signals += 1
            total_signals += 1
            ma_separation = abs(self.ma_fast[0] - self.ma_slow[0]) / self.ma_slow[0]
            if ma_separation > 0.02:
                trending_signals += 1
            confidence = trending_signals / total_signals
            if confidence >= 0.75:
                regime = "trending"
            elif confidence <= 0.25:
                regime = "ranging"
            else:
                regime = "uncertain"
            return regime, confidence
        except Exception:
            return "unknown", 0

    def update_regime_state(self):
        new_regime, confidence = self.classify_market_regime()
        self.regime_history.append(new_regime)
        if len(self.regime_history) > self.params.regime_confirmation * 2:
            self.regime_history = self.regime_history[-self.params.regime_confirmation * 2:]
        if len(self.regime_history) >= self.params.regime_confirmation:
            recent_regimes = self.regime_history[-self.params.regime_confirmation:]
            if all(r == new_regime for r in recent_regimes):
                if self.current_regime != new_regime:
                    self.current_regime = new_regime
                    self.regime_confidence = confidence
            else:
                self.regime_confidence = confidence

    def calculate_regime_position_size(self):
        try:
            base_size = self.params.max_position_pct
            if self.current_regime == "trending":
                size_factor = 1.0
            elif self.current_regime == "ranging":
                size_factor = 0.3
            else:
                size_factor = 0.1
            confidence_factor = max(0.5, self.regime_confidence)
            final_size = base_size * size_factor * confidence_factor
            return max(self.params.min_position_pct, 
                      min(self.params.max_position_pct, final_size))
        except Exception:
            return self.params.min_position_pct

    def get_adaptive_stop_multiplier(self):
        if self.current_regime == "trending":
            base_mult = self.params.trail_atr_mult
            if len(self.volatility_history) >= 5:
                current_vol = self.volatility_history[-1]
                avg_vol = np.mean(self.volatility_history[-10:])
                if current_vol > avg_vol * 1.2:
                    return base_mult * 1.3
                elif current_vol < avg_vol * 0.8:
                    return base_mult * 0.8
            return base_mult
        else:
            return self.params.range_atr_mult

    def should_trade_trend_following(self):
        if self.current_regime == "trending":
            return True
        elif self.current_regime == "ranging":
            return False
        else:
            return self.regime_confidence > 0.7

    def next(self):
        if self.order:
            return
        current_vol = self.calculate_volatility()
        if current_vol > 0:
            self.volatility_history.append(current_vol)
            if len(self.volatility_history) > self.params.volatility_lookback:
                self.volatility_history = self.volatility_history[-self.params.volatility_lookback:]
        self.update_regime_state()
        if self.position:
            if not self.trail_order:
                stop_multiplier = self.get_adaptive_stop_multiplier()
                if self.position.size > 0:
                    self.trail_order = self.sell(
                        exectype=bt.Order.StopTrail,
                        trailamount=self.atr[0] * stop_multiplier)
                elif self.position.size < 0:
                    self.trail_order = self.buy(
                        exectype=bt.Order.StopTrail,
                        trailamount=self.atr[0] * stop_multiplier)
            return
        required_bars = max(self.params.ma_slow, self.params.adx_period, self.params.bb_period)
        if len(self) < required_bars:
            return
        if not self.should_trade_trend_following():
            return
        ma_bullish_cross = (self.ma_fast[0] > self.ma_slow[0] and 
                           self.ma_fast[-1] <= self.ma_slow[-1])
        ma_bearish_cross = (self.ma_fast[0] < self.ma_slow[0] and 
                           self.ma_fast[-1] >= self.ma_slow[-1])
        position_size_pct = self.calculate_regime_position_size()
        current_price = self.dataclose[0]
        if ma_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 ma_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.current_regime == "trending" and 
              self.regime_confidence > 0.8):
            ma_spread = (self.ma_fast[0] - self.ma_slow[0]) / self.ma_slow[0]
            if ma_spread > 0.03:
                cash = self.broker.getcash()
                target_value = cash * (position_size_pct * 0.7)
                shares = target_value / current_price
                self.order = self.buy(size=shares)
            elif ma_spread < -0.03:
                cash = self.broker.getcash()
                target_value = cash * (position_size_pct * 0.7)
                shares = target_value / current_price
                self.order = self.sell(size=shares)

def run_rolling_backtest(
    ticker="BTC-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(RegimeFilteredTrendStrategy, **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)

Code Explanation

Implementation Details

Pasted image 20250712175247.png
Pasted image 20250712175253.png

Pasted image 20250712175259.png Pasted image 20250712175308.png ## Conclusion

The Regime Filtered Trend Strategy excels at adapting to market conditions through multi-indicator regime classification, ensuring trades are taken in favorable trending environments. Its use of SMAs for trend detection, combined with dynamic position sizing and adaptive stops, makes it well-suited for volatile markets like cryptocurrencies. The rolling backtest framework provides a comprehensive evaluation across diverse market conditions, enabling traders to assess consistency and optimize parameters (e.g., MA periods, regime thresholds) for specific assets or timeframes.