← Back to Home
Stochastic Momentum Strategy A Trend-Following and Mean-Reversion Hybrid

Stochastic Momentum Strategy A Trend-Following and Mean-Reversion Hybrid

Introduction

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.

Strategy Overview

The strategy combines three core elements:

  1. Stochastic Oscillator: Identifies momentum through %K and %D crossovers and mean-reversion opportunities via oversold/overbought levels.
  2. Trend Filter: A Simple Moving Average (SMA) ensures trades align with the broader market trend.
  3. Volatility Management: The Average True Range (ATR) adjusts position sizes and sets trailing stops to manage risk dynamically.

The strategy employs trailing stops to protect profits and limits position sizes based on volatility, making it adaptable to fluctuating market conditions.

Logic and Reasoning

Entry Conditions

The strategy triggers trades under two primary scenarios, with a trend filter:

Position Sizing

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.

Risk Management

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.

Why This Approach?

Key Code Components

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)

Code Explanation

Implementation Details

Pasted image 20250711184447.png

Conclusion

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.