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(
=self.params.stoch_k_period,
period=self.params.stoch_d_period,
period_dfast=self.params.stoch_k_smooth
period_dslow
)# 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(
=bt.Order.StopTrail,
exectype=self.atr[0] * self.params.atr_multiplier)
trailamountelif self.position.size < 0:
self.trail_order = self.buy(
=bt.Order.StopTrail,
exectype=self.atr[0] * self.params.atr_multiplier)
trailamountreturn
# Ensure sufficient data
= max(self.params.trend_ma_period, self.params.stoch_k_period, self.params.atr_period)
required_bars if len(self) < required_bars:
return
# Stochastic values
= self.stoch.percK[0]
stoch_k = self.stoch.percD[0]
stoch_d = self.stoch.percK[-1]
stoch_k_prev = self.stoch.percD[-1]
stoch_d_prev = (stoch_k > stoch_d and stoch_k_prev <= stoch_d_prev)
stoch_bullish_cross = (stoch_k < stoch_d and stoch_k_prev >= stoch_d_prev)
stoch_bearish_cross
# Trend filter
= self.dataclose[0] > self.trend_ma[0]
trend_up = self.dataclose[0] < self.trend_ma[0]
trend_down
# Oversold/Overbought conditions
= stoch_k < self.params.stoch_oversold
oversold = stoch_k > self.params.stoch_overbought
overbought
# ATR-based position sizing
= self.calculate_atr_position_size()
position_size_pct = self.dataclose[0]
current_price
# LONG ENTRY: Stochastic bullish cross + uptrend
if stoch_bullish_cross and trend_up 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)
# SHORT ENTRY: Stochastic bearish cross + downtrend
elif stoch_bearish_cross and trend_down 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)
# Alternative entry: Oversold bounce in uptrend
elif not self.position and trend_up:
if (stoch_k > self.params.stoch_oversold and
<= self.params.stoch_oversold and
stoch_k_prev > stoch_d):
stoch_k = self.broker.getcash()
cash = cash * (position_size_pct * 0.8)
target_value = target_value / current_price
shares 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
>= self.params.stoch_overbought and
stoch_k_prev < stoch_d):
stoch_k = self.broker.getcash()
cash = cash * (position_size_pct * 0.8)
target_value = target_value / current_price
shares self.order = self.sell(size=shares)
def run_rolling_backtest(
="SOL-USD",
ticker="2020-01-01",
start="2025-01-01",
end=3,
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
= yf.download(ticker, start=current_start, end=current_end, progress=False)
data if data.empty or len(data) < 90:
+= 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(StochasticMomentumStrategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.b wakker.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,
})
+= rd.relativedelta(months=window_months)
current_start
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.