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):
= abs(self.dataclose - self.dataclose(-period))
change = bt.indicators.SumN(
volatility abs(self.dataclose - self.dataclose(-1)),
=period
period
)= change / volatility
efficiency_ratio = 2.0 / (self.params.kama_sc_fastest + 1)
sc_fastest = 2.0 / (self.params.kama_sc_slowest + 1)
sc_slowest = (efficiency_ratio * (sc_fastest - sc_slowest) + sc_slowest) ** 2
sc return bt.indicators.ExponentialMovingAverage(period=period)
def check_volatility_conditions(self):
if len(self.atr) == 0 or len(self.bb) == 0:
return False
try:
= self.atr[0] / self.dataclose[0]
atr_pct = atr_pct > self.params.atr_threshold
atr_condition = (self.bb.top[0] - self.bb.bot[0]) / self.bb.mid[0]
bb_width = bb_width > self.params.bb_width_threshold
bb_condition 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
= self.broker.getvalue()
current_value if current_value > self.peak_value:
self.peak_value = current_value
= (self.peak_value - current_value) / self.peak_value
drawdown 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:
= self.atr[0] / self.dataclose[0]
atr_pct = min(0.08, max(0.01, atr_pct))
normalized_atr = (0.08 - normalized_atr) / 0.07
vol_factor = (self.params.min_position_pct +
position_pct * (self.params.max_position_pct - self.params.min_position_pct))
vol_factor 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(
=bt.Order.StopTrail,
exectype=self.atr[0] * self.params.trail_atr_mult)
trailamountelif self.position.size < 0:
self.trail_order = self.buy(
=bt.Order.StopTrail,
exectype=self.atr[0] * self.params.trail_atr_mult)
trailamountreturn
= max(self.params.kama_slow_period, self.params.atr_period, self.params.bb_period)
required_bars if len(self) < required_bars:
return
if not self.check_volatility_conditions():
return
= self.kama_fast[0]
kama_fast_current = self.kama_slow[0]
kama_slow_current = self.kama_fast[-1]
kama_fast_prev = self.kama_slow[-1]
kama_slow_prev = (kama_fast_current > kama_slow_current and
bullish_cross <= kama_slow_prev)
kama_fast_prev = (kama_fast_current < kama_slow_current and
bearish_cross >= kama_slow_prev)
kama_fast_prev = self.calculate_volatility_position_size()
position_size_pct = self.dataclose[0]
current_price if 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 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.check_volatility_conditions():
= abs(kama_fast_current - kama_slow_current) / kama_slow_current
kama_spread if kama_spread > 0.02:
if kama_fast_current > kama_slow_current * 1.01:
= self.broker.getcash()
cash = cash * (position_size_pct * 0.7)
target_value = target_value / current_price
shares self.order = self.buy(size=shares)
elif kama_fast_current < kama_slow_current * 0.99:
= self.broker.getcash()
cash = cash * (position_size_pct * 0.7)
target_value = target_value / current_price
shares 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(
="SOL-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(strategy,
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)
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.