The Stochastic Momentum Strategy is a trading system that blends momentum and mean-reversion principles to capture price movements in trending markets while exploiting oversold and overbought conditions. Designed for volatile assets like cryptocurrencies (e.g., SOL-USD), it uses the Stochastic Oscillator, a trend-following moving average, and volatility-based risk management. This article details the strategy’s logic, reasoning, and implementation, focusing on key code components and the rolling backtest framework.
The strategy combines three core elements:
The strategy employs trailing stops to protect profits and limits position sizes based on volatility, making it adaptable to fluctuating market conditions.
The strategy triggers trades under two primary scenarios, with a trend filter:
Position sizes are dynamically calculated based on the ATR normalized by price. Higher volatility (larger ATR) results in smaller positions to reduce risk, while lower volatility allows larger positions. The size scales between 30% and 95% of available capital.
Trailing stops, set at a multiple of the ATR (e.g., 1x ATR), are used to exit positions when the price reverses, balancing trend-following with loss protection.
Below are the main components of the
StochasticMomentumStrategy class and the rolling backtest
function, focusing on the parameters 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 StochasticMomentumStrategy(bt.Strategy):
params = (
('stoch_k_period', 14), # Stochastic %K period
('stoch_d_period', 3), # Stochastic %D period
('stoch_k_smooth', 3), # %K smoothing
('trend_ma_period', 30), # Trend filter MA period
('atr_period', 14), # ATR period
('atr_multiplier', 1.0), # ATR multiplier for trailing stops
('vol_lookback', 20), # Volatility lookback for position sizing
('max_position_pct', 0.95), # Maximum position size
('min_position_pct', 0.30), # Minimum position size
('stoch_oversold', 20), # Oversold level
('stoch_overbought', 80), # Overbought level
)
def __init__(self):
self.dataclose = self.datas[0].close
# Stochastic Oscillator
self.stoch = bt.indicators.Stochastic(
period=self.params.stoch_k_period,
period_dfast=self.params.stoch_d_period,
period_dslow=self.params.stoch_k_smooth
)
# Trend filter
self.trend_ma = bt.indicators.SMA(period=self.params.trend_ma_period)
# ATR for volatility and stops
self.atr = bt.indicators.ATR(period=self.params.atr_period)
# Track orders
self.order = None
self.trail_order = None
# Volatility tracking for position sizing
self.volatility_history = []
def next(self):
# Skip if order is pending
if self.order:
return
# Handle trailing stops for existing positions
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.atr_multiplier)
elif self.position.size < 0:
self.trail_order = self.buy(
exectype=bt.Order.StopTrail,
trailamount=self.atr[0] * self.params.atr_multiplier)
return
# Ensure sufficient data
required_bars = max(self.params.trend_ma_period, self.params.stoch_k_period, self.params.atr_period)
if len(self) < required_bars:
return
# Stochastic values
stoch_k = self.stoch.percK[0]
stoch_d = self.stoch.percD[0]
stoch_k_prev = self.stoch.percK[-1]
stoch_d_prev = self.stoch.percD[-1]
stoch_bullish_cross = (stoch_k > stoch_d and stoch_k_prev <= stoch_d_prev)
stoch_bearish_cross = (stoch_k < stoch_d and stoch_k_prev >= stoch_d_prev)
# Trend filter
trend_up = self.dataclose[0] > self.trend_ma[0]
trend_down = self.dataclose[0] < self.trend_ma[0]
# Oversold/Overbought conditions
oversold = stoch_k < self.params.stoch_oversold
overbought = stoch_k > self.params.stoch_overbought
# ATR-based position sizing
position_size_pct = self.calculate_atr_position_size()
current_price = self.dataclose[0]
# LONG ENTRY: Stochastic bullish cross + uptrend
if stoch_bullish_cross and trend_up 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)
# SHORT ENTRY: Stochastic bearish cross + downtrend
elif stoch_bearish_cross and trend_down 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)
# Alternative entry: Oversold bounce in uptrend
elif not self.position and trend_up:
if (stoch_k > self.params.stoch_oversold and
stoch_k_prev <= self.params.stoch_oversold and
stoch_k > stoch_d):
cash = self.broker.getcash()
target_value = cash * (position_size_pct * 0.8)
shares = target_value / current_price
self.order = self.buy(size=shares)
# Alternative entry: Overbought breakdown in downtrend
elif not self.position and trend_down:
if (stoch_k < self.params.stoch_overbought and
stoch_k_prev >= self.params.stoch_overbought and
stoch_k < stoch_d):
cash = self.broker.getcash()
target_value = cash * (position_size_pct * 0.8)
shares = target_value / current_price
self.order = self.sell(size=shares)
def run_rolling_backtest(
ticker="SOL-USD",
start="2020-01-01",
end="2025-01-01",
window_months=3,
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
data = yf.download(ticker, start=current_start, end=current_end, progress=False)
if data.empty or len(data) < 90:
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(StochasticMomentumStrategy, **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.b wakker.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,
})
current_start += rd.relativedelta(months=window_months)
return pd.DataFrame(all_results)params tuple defines
settings such as Stochastic Oscillator periods
(stoch_k_period, stoch_d_period,
stoch_k_smooth), trend SMA period, ATR period, and position
sizing limits. Oversold (20) and overbought (80) levels are also
configurable.__init__ method
sets up the Stochastic Oscillator, SMA for trend filtering, ATR for
volatility, and variables for order and volatility tracking.next method drives
the trading logic by:
run_rolling_backtest function tests the strategy over
multiple 3-month windows from 2020 to 2025, fetching data for SOL-USD
via yfinance. It handles data validation, sets up the
Backtrader environment, and collects returns for each period.yfinance to fetch SOL-USD
data, ensuring compatibility with Backtrader via
PandasData.PercentSizer is set to 95%, but the
strategy overrides this with ATR-based sizing.The Stochastic Momentum Strategy effectively combines momentum and mean-reversion signals with a trend filter and volatility-based risk management. Its use of the Stochastic Oscillator, SMA, and ATR makes it well-suited for volatile markets like cryptocurrencies. The rolling backtest framework enhances its evaluation by testing performance across multiple periods, offering insights into its consistency and adaptability.