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.
The strategy integrates the following components:
The strategy is tailored for volatile assets, with robust regime detection to avoid trading in unfavorable conditions and dynamic risk controls to protect capital.
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.
Position sizes are adjusted based on regime and confidence:
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)params tuple defines:
ma_fast (20), ma_slow (50): Periods for
fast and slow SMAs.adx_period (14), adx_trending_threshold
(25): ADX settings for trend detection.bb_period (20), bb_width_threshold (0.03):
Bollinger Band settings for volatility.volatility_lookback (20),
vol_trending_threshold (0.025): Volatility tracking
settings.atr_period (14), trail_atr_mult (2.0),
range_atr_mult (1.5): ATR settings for stops.max_position_pct (0.95), min_position_pct
(0.20): Position size limits.regime_confirmation (3): Bars to confirm regime
changes.__init__ method
sets up SMAs, ADX, Bollinger Bands, ATR, and variables for regime and
volatility tracking.classify_market_regime function evaluates four signals
(ADX, BB width, ATR, MA separation) to determine the market regime and
confidence level.update_regime_state function enforces regime consistency
over 3 bars, updating the current regime and confidence.calculate_regime_position_size function adjusts size based
on regime (trending, ranging, uncertain) and confidence.get_adaptive_stop_multiplier function selects wider stops
for trending markets and adjusts for volatility spikes/drops.should_trade_trend_following function allows trading in
trending regimes or high-confidence uncertain regimes.next method:
run_rolling_backtest function tests the strategy over
12-month windows for BTC-USD, fetching data via yfinance,
with $100,000 initial capital, 0.1% commission, and a 95% sizer
(overridden by the strategy).yfinance to fetch BTC-USD
daily data, processed into a Backtrader-compatible
PandasData feed.
## 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.