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.
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 |
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