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:
= 0
trending_signals = 0
total_signals += 1
total_signals if self.adx[0] > self.params.adx_trending_threshold:
+= 1
trending_signals += 1
total_signals = (self.bb.top[0] - self.bb.bot[0]) / self.bb.mid[0]
bb_width if bb_width > self.params.bb_width_threshold:
+= 1
trending_signals += 1
total_signals = self.volatility_history[-1]
current_vol if current_vol > self.params.vol_trending_threshold:
+= 1
trending_signals += 1
total_signals = abs(self.ma_fast[0] - self.ma_slow[0]) / self.ma_slow[0]
ma_separation if ma_separation > 0.02:
+= 1
trending_signals = trending_signals / total_signals
confidence if confidence >= 0.75:
= "trending"
regime elif confidence <= 0.25:
= "ranging"
regime else:
= "uncertain"
regime return regime, confidence
except Exception:
return "unknown", 0
def update_regime_state(self):
= self.classify_market_regime()
new_regime, confidence 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:
= self.regime_history[-self.params.regime_confirmation:]
recent_regimes 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:
= self.params.max_position_pct
base_size if self.current_regime == "trending":
= 1.0
size_factor elif self.current_regime == "ranging":
= 0.3
size_factor else:
= 0.1
size_factor = max(0.5, self.regime_confidence)
confidence_factor = base_size * size_factor * confidence_factor
final_size 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":
= self.params.trail_atr_mult
base_mult if len(self.volatility_history) >= 5:
= self.volatility_history[-1]
current_vol = np.mean(self.volatility_history[-10:])
avg_vol 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
= self.calculate_volatility()
current_vol 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:
= self.get_adaptive_stop_multiplier()
stop_multiplier if self.position.size > 0:
self.trail_order = self.sell(
=bt.Order.StopTrail,
exectype=self.atr[0] * stop_multiplier)
trailamountelif self.position.size < 0:
self.trail_order = self.buy(
=bt.Order.StopTrail,
exectype=self.atr[0] * stop_multiplier)
trailamountreturn
= max(self.params.ma_slow, self.params.adx_period, self.params.bb_period)
required_bars if len(self) < required_bars:
return
if not self.should_trade_trend_following():
return
= (self.ma_fast[0] > self.ma_slow[0] and
ma_bullish_cross self.ma_fast[-1] <= self.ma_slow[-1])
= (self.ma_fast[0] < self.ma_slow[0] and
ma_bearish_cross self.ma_fast[-1] >= self.ma_slow[-1])
= self.calculate_regime_position_size()
position_size_pct = self.dataclose[0]
current_price if ma_bullish_cross and not self.position:
self.cancel_trail()
= self.broker.getcash()
cash = cash * position_size_pct
target_value = target_value / current_price
shares self.order = self.buy(size=shares)
elif ma_bearish_cross and not self.position:
self.cancel_trail()
= self.broker.getcash()
cash = cash * position_size_pct
target_value = target_value / current_price
shares self.order = self.sell(size=shares)
elif (not self.position and self.current_regime == "trending" and
self.regime_confidence > 0.8):
= (self.ma_fast[0] - self.ma_slow[0]) / self.ma_slow[0]
ma_spread if ma_spread > 0.03:
= self.broker.getcash()
cash = cash * (position_size_pct * 0.7)
target_value = target_value / current_price
shares self.order = self.buy(size=shares)
elif ma_spread < -0.03:
= self.broker.getcash()
cash = cash * (position_size_pct * 0.7)
target_value = target_value / current_price
shares self.order = self.sell(size=shares)
def run_rolling_backtest(
="BTC-USD",
ticker="2020-01-01",
start="2025-01-01",
end=12,
window_months=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, progress=False)
data if data.empty or len(data) < 90:
print("Not enough data.")
+= rd.relativedelta(months=window_months)
current_start continue
= data.droplevel(1, 1) if isinstance(data.columns, pd.MultiIndex) else data
data = bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params)
cerebro.addstrategy(RegimeFilteredTrendStrategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
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}")
+= rd.relativedelta(months=window_months)
current_start 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.