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.
The strategy combines the following components:
The strategy is tailored for volatile assets, adapting to market conditions through dynamic adjustments and strict risk controls.
The strategy triggers trades under two scenarios, both requiring high volatility:
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.
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)params tuple defines:
kama_fast_period (10) and kama_slow_period
(30): Periods for fast and slow KAMA.kama_sc_fastest (2) and kama_sc_slowest
(30): Smoothing constants for KAMA.atr_period (14) and atr_threshold (0.02):
ATR settings for volatility filtering.bb_period (20) and bb_width_threshold
(0.02): Bollinger Band settings for volatility.trail_atr_mult (2.0): Multiplier for trailing
stops.max_drawdown (0.15): Maximum allowable drawdown.max_position_pct (0.95) and
min_position_pct (0.30): Position size limits.__init__ method
sets up ATR, Bollinger Bands, fast and slow KAMA (simplified as EMA due
to implementation limitations), and variables for order and performance
tracking.create_kama
function calculates an efficiency ratio and adaptive smoothing constant,
but uses an EMA as a placeholder due to the complexity of full KAMA
implementation.check_volatility_conditions function ensures ATR > 2% of
price and Bollinger Band width > 2%, confirming high-volatility
conditions.check_drawdown_limit function pauses trading if drawdown
exceeds 15%, closing all positions.calculate_volatility_position_size function scales position
size inversely to ATR, between 30% and 95% of capital.next method:
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)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.