Skip to main content

adaptive stop losses - why fixed stops are leaving money on the table

2:30am wednesday.

been refactoring my exit logic all week.

fixed stop losses are lazy. there I said it.

the problem with fixed stops
#

“just use a 2% stop loss.”

cool. but 2% of what? in what vol environment?

2% on ES when VIX is at 12 means you’re stopping out on noise.

2% on ES when VIX is at 35 means your stop is tighter than a guitar string and you’re getting chopped up.

same stop. completely different risk profiles depending on the market.

volatility-scaled exits
#

the idea is simple. scale your stop distance to current volatility.

high vol = wider stops.

low vol = tighter stops.

your stop should represent the same statistical significance regardless of market conditions.

Adaptive vs Fixed Stops

comparison of fixed 2% stop vs ATR-scaled stop over 6 months on ES. the fixed stop gets triggered way more often during high vol periods. the adaptive stop adjusts and avoids noise exits.

the implementation
#

from dataclasses import dataclass
from typing import Optional, Literal
from enum import Enum
import numpy as np
import pandas as pd


class VolModel(Enum):
    ATR = "atr"
    EWMA = "ewma"
    YANG_ZHANG = "yang_zhang"
    PARKINSON = "parkinson"


@dataclass
class StopConfig:
    """Adaptive stop loss configuration"""
    base_multiplier: float = 2.0        # multiplier on vol measure
    min_stop_pct: float = 0.5           # floor - never tighter than this
    max_stop_pct: float = 8.0           # ceiling - never wider than this
    vol_model: VolModel = VolModel.ATR
    vol_lookback: int = 20              # periods for vol calculation
    vol_decay: float = 0.94             # EWMA decay factor
    use_regime_adjustment: bool = True  # adjust for vol regime
    trailing: bool = True               # trailing vs fixed adaptive


class AdaptiveStopEngine:
    """Volatility-scaled stop loss engine"""

    def __init__(self, config: StopConfig = None):
        self.config = config or StopConfig()
        self._vol_cache: Optional[pd.Series] = None

    def calculate_volatility(self, ohlcv: pd.DataFrame) -> pd.Series:
        """Calculate volatility using configured model"""
        if self.config.vol_model == VolModel.ATR:
            return self._atr(ohlcv)
        elif self.config.vol_model == VolModel.EWMA:
            return self._ewma_vol(ohlcv)
        elif self.config.vol_model == VolModel.YANG_ZHANG:
            return self._yang_zhang(ohlcv)
        elif self.config.vol_model == VolModel.PARKINSON:
            return self._parkinson(ohlcv)
        else:
            raise ValueError(f"unknown vol model: {self.config.vol_model}")

    def _atr(self, df: pd.DataFrame) -> pd.Series:
        """Average True Range"""
        high = df['high']
        low = df['low']
        close = df['close'].shift(1)

        tr = pd.concat([
            high - low,
            (high - close).abs(),
            (low - close).abs()
        ], axis=1).max(axis=1)

        return tr.rolling(self.config.vol_lookback).mean()

    def _ewma_vol(self, df: pd.DataFrame) -> pd.Series:
        """Exponentially weighted volatility"""
        returns = df['close'].pct_change()
        return returns.ewm(
            alpha=1 - self.config.vol_decay,
            min_periods=self.config.vol_lookback
        ).std() * df['close']  # convert to dollar vol

    def _yang_zhang(self, df: pd.DataFrame) -> pd.Series:
        """Yang-Zhang estimator - better than close-to-close"""
        n = self.config.vol_lookback
        log_ho = np.log(df['high'] / df['open'])
        log_lo = np.log(df['low'] / df['open'])
        log_co = np.log(df['close'] / df['open'])

        # overnight vol
        log_oc = np.log(df['open'] / df['close'].shift(1))
        sigma_o = log_oc.rolling(n).var()

        # close-to-close
        sigma_c = log_co.rolling(n).var()

        # Rogers-Satchell
        rs = (log_ho * (log_ho - log_co) + log_lo * (log_lo - log_co))
        sigma_rs = rs.rolling(n).mean()

        k = 0.34 / (1.34 + (n + 1) / (n - 1))
        sigma_yz = sigma_o + k * sigma_c + (1 - k) * sigma_rs

        return np.sqrt(sigma_yz) * df['close']

    def _parkinson(self, df: pd.DataFrame) -> pd.Series:
        """Parkinson high-low estimator"""
        n = self.config.vol_lookback
        log_hl = np.log(df['high'] / df['low'])
        factor = 1.0 / (4.0 * n * np.log(2))
        parkinson = np.sqrt(factor * (log_hl ** 2).rolling(n).sum())
        return parkinson * df['close']

    def get_stop_distance(self, ohlcv: pd.DataFrame,
                          direction: Literal['long', 'short'] = 'long'
                          ) -> pd.Series:
        """Calculate adaptive stop distance in price terms"""
        vol = self.calculate_volatility(ohlcv)
        self._vol_cache = vol

        # base stop distance
        stop_dist = vol * self.config.base_multiplier

        # apply regime adjustment
        if self.config.use_regime_adjustment:
            vol_ratio = vol / vol.rolling(60).mean()
            # dampen in extreme vol (wider but not linearly)
            regime_adj = np.where(
                vol_ratio > 1.5,
                1.0 + (vol_ratio - 1.5) * 0.5,  # diminishing returns
                1.0
            )
            stop_dist = stop_dist * regime_adj

        # convert to percentage
        stop_pct = (stop_dist / ohlcv['close']) * 100

        # apply floor and ceiling
        stop_pct = stop_pct.clip(
            lower=self.config.min_stop_pct,
            upper=self.config.max_stop_pct
        )

        # convert back to price
        stop_price_dist = ohlcv['close'] * (stop_pct / 100)

        return stop_price_dist

    def get_stop_price(self, ohlcv: pd.DataFrame,
                       entry_price: float,
                       direction: Literal['long', 'short'] = 'long'
                       ) -> float:
        """Get current stop price for a position"""
        stop_dist = self.get_stop_distance(ohlcv, direction).iloc[-1]

        if direction == 'long':
            if self.config.trailing:
                # trail from highest close since entry
                recent_high = ohlcv['close'].max()
                return recent_high - stop_dist
            return entry_price - stop_dist
        else:
            if self.config.trailing:
                recent_low = ohlcv['close'].min()
                return recent_low + stop_dist
            return entry_price + stop_dist

    def backtest_stops(self, ohlcv: pd.DataFrame,
                       fixed_stop_pct: float = 2.0) -> dict:
        """Compare adaptive vs fixed stop performance"""
        adaptive_dist = self.get_stop_distance(ohlcv)
        fixed_dist = ohlcv['close'] * (fixed_stop_pct / 100)

        adaptive_pct = (adaptive_dist / ohlcv['close']) * 100
        fixed_pct_series = pd.Series(fixed_stop_pct, index=ohlcv.index)

        # count how many times each stop would trigger on noise
        noise = ohlcv['close'].pct_change().abs() * 100

        adaptive_triggers = (noise > adaptive_pct).sum()
        fixed_triggers = (noise > fixed_pct_series).sum()

        return {
            'adaptive_avg_stop_pct': float(adaptive_pct.mean()),
            'adaptive_median_stop_pct': float(adaptive_pct.median()),
            'fixed_stop_pct': fixed_stop_pct,
            'adaptive_noise_triggers': int(adaptive_triggers),
            'fixed_noise_triggers': int(fixed_triggers),
            'noise_reduction_pct': float(
                (1 - adaptive_triggers / max(fixed_triggers, 1)) * 100
            )
        }

four different volatility models. ATR is the standard but Yang-Zhang is better because it accounts for overnight gaps.

the regime adjustment is key. when vol spikes above 1.5x its 60-day average, the stop widens but at a diminishing rate. you don’t want your stop at 15% just because VIX hit 40.

comparing the models
#

ran all four vol estimators against ES data for 2025.

results:

vol model avg stop % noise triggers false exits avoided
fixed 2% 2.00 147 baseline
ATR 1.83 89 39% fewer
EWMA 1.91 94 36% fewer
Yang-Zhang 1.76 82 44% fewer
Parkinson 1.79 85 42% fewer

Vol Model Comparison

stop distance over time for each volatility model. yang-zhang (purple) adapts fastest to regime changes because it uses OHLC data, not just close prices. ATR (blue) is smoother but slower to react.

Yang-Zhang wins.

uses all four OHLC prices. captures overnight gaps that ATR misses. adapts faster to regime changes.

the 44% reduction in noise triggers is real money. each false exit is a round trip of commissions plus slippage plus the opportunity cost of missing the move.

the floor and ceiling
#

this is where people get lazy.

without bounds your adaptive stop will:

  • get impossibly tight in low vol (stopped out by spread alone)
  • get absurdly wide in crisis vol (might as well not have a stop)

I use 0.5% floor and 8% ceiling.

floor = never tighter than half a percent. even in dead calm markets.

ceiling = never wider than 8%. if your stop needs to be wider than 8% you shouldn’t be in the trade.

what this looks like in practice
#

january 2026 ES example:

  • jan 6-10: VIX around 14. adaptive stop: 1.2% (~$72 on ES at 6000)
  • jan 13-17: VIX spike to 22. adaptive stop widens to 2.1% (~$126)
  • jan 20-24: VIX settles to 18. adaptive stop: 1.7% (~$102)

a fixed 2% stop would have been:

  • too wide jan 6-10 (giving back $48 unnecessarily)
  • about right jan 13-17
  • slightly tight jan 20-24

the adaptive version optimizes for each environment automatically.

been sharing notes on adaptive exits with some NexusFi members who run systematic strategies. the consensus is that vol-scaled stops improve risk-adjusted returns by 15-25% over fixed percentage stops. the hard part is picking the right vol model for your timeframe.

the trailing component
#

trailing stops on top of adaptive sizing.

the stop ratchets up (for longs) as price moves in your favor.

but the trailing distance also adapts.

if vol compresses during your trade, the trailing distance tightens. locking in more profit.

if vol expands, the distance widens. giving the trade room to breathe.

this is the part that makes the biggest difference for trend-following on crypto. BTC can move 5% in a day. a fixed trailing stop gets clipped constantly. an adaptive one rides the trend.

the takeaway
#

fixed stops are a blunt instrument.

adaptive stops that scale to volatility reduce false exits by 40%+.

Yang-Zhang estimator beats ATR for most use cases.

always use a floor and ceiling on your adaptive stops.

trail with adaptive distance, not fixed distance.

your exits should be as smart as your entries.


2:30am wednesday. refactored my stop loss engine to use volatility-scaled exits. fixed 2% stops are lazy - they’re too tight in high vol and too wide in low vol. built an adaptive engine with 4 vol models. yang-zhang wins at 44% fewer false exits vs fixed. floor at 0.5%, ceiling at 8%. trailing with adaptive distance for trend following. same strategy, better exits, more money kept.

-AK

Related

monte carlo backtesting - why single backtest runs lie to you
2:30am wednesday. ran a single backtest last week. looked incredible. sharpe of 2.4. max drawdown 8%. then I ran 10,000 of them. reality check. the problem with one backtest # you run a backtest. it returns +22% over 2 years.
cross-asset correlation tracking - why diversification is a lie
1:30am and i’m staring at correlation matrices again. everyone talks about diversification like it’s free lunch. it’s not. the diversification myth # portfolios are “diversified” until they’re not.
volatility regime detection - when to switch strategies
the market doesn’t care what strategy you’re running. it runs whatever regime it wants. your job is to detect the regime and adapt. why regime matters # every strategy has conditions where it crushes and conditions where it bleeds.
vix term structure algo - contango/backwardation trading
been researching VIX term structure trades. contango vs backwardation. predictable patterns. finally got an algo working. the concept # contango: front month VIX < back month VIX
earnings volatility filter - implementation and early results
been running the earnings volatility filter for a week now. early results are promising. the problem # earnings = binary events. stock moves 5-10% or nothing.
regime detection filter - why it failed march, python implementation fix
march disaster taught lesson. regime detection lagged. cost $6,690 before pausing. fixing implementation. what went wrong # my current filter: