Why I Stopped Ignoring Position Size (and What It Taught Me About Stops)
Quick context (why you're writing this)
Here's the thing: I spent a whole weekend back‑testing a shiny mean‑reversion strategy on EUR/USD. The equity curve looked like a rocket ship — until I looked at the trade‑by‑trade P&L and saw a single 3% loss wiping out two weeks of profit. I was shocked. Turns out I was sizing every trade at a fixed 1% of equity, regardless of volatility, and my stop loss was a static 50 pips. When the market went sideways‑volatile, that stop got hit far more often than my model expected. I spent 3 hours debugging the code only to realize the bug wasn’t in the logic — it was in the risk settings. That moment made me rewrite my position sizing and stop logic from scratch.
The Insight
What I learned is simple but brutal: position size and stop distance must move together, or you’re gambling. A fixed stop works only when volatility is stable; a fixed % risk works only when your stop distance reflects the market’s noise. If you decouple them, you either over‑risk during calm periods or under‑risk during spikes, and your Sharpe ratio pays the price.
The insight isn’t new — traders have been talking about volatility‑adjusted stops for decades — but seeing it break my own code made it stick. The takeaway: compute a volatility measure (ATR works well), size the trade so that the monetary loss if the stop hits equals a pre‑defined % of equity, and let the stop distance be a multiple of that ATR. That way your risk stays constant in dollars, not in pips or percent of price.
How (with code)
Below is a stripped‑down Python snippet that shows the common mistake and the corrected version. I’m using pandas for data handling and ta for the ATR indicator — feel free to swap in your own volatility estimator.
The mistake: fixed size, fixed stop
import pandas as pd
def generate_signals(df):
# dummy signal: 1 = long, -1 = short, 0 = flat
df['signal'] = (df['close'] > df['close'].rolling(20).mean()).astype(int) * 2 - 1
return df
def apply_fixed_risk(df, equity=100_000, risk_per_trade=0.01, stop_pips=50):
"""
risk_per_trade = 1% of equity
stop_pips = 50 pips (0.0050 for 4‑digit FX)
"""
df = generate_signals(df)
pip_value = 0.0001 # for EURUSD 4‑digit
stop_price = stop_pips * pip_value
# position size in lots (1 lot = 100k units)
df['position_lots'] = equity * risk_per_trade / (stop_price * 100_000)
df['position_lots'] = df['position_lots'].clip(lower=0) # no shorts for simplicity
# entry price = close at signal bar
df['entry_price'] = df['close']
# stop loss price
df['stop_price'] = df['entry_price'] - df['signal'] * stop_price
return df
What’s wrong here?
- The stop distance (
stop_pips) is hard‑coded, so when ATR spikes from 5 pips to 15 pips, the same 50 pips stop is either too tight (you get stopped out by normal noise) or too loose (you risk far more than 1% when volatility is low). - Position size is calculated from that fixed stop, meaning your dollar risk swings with volatility — exactly what we wanted to avoid.
The fix: volatility‑adjusted size & stop
import ta # pip install ta
def apply_vol_adjusted_risk(df, equity=100_000, risk_per_trade=0.01, atr_multiple=1.5):
"""
risk_per_trade = % of equity you're willing to lose if stop hits
atr_multiple = how many ATRs you place your stop away from entry
"""
df = generate_signals(df)
# ATR(14) as a proxy for recent volatility
df['atr'] = ta.volatility.average_true_range(df['high'], df['low'], df['close'], window=14)
# stop distance in price units
df['stop_distance'] = df['atr'] * atr_multiple
# dollar risk per trade
dollar_risk = equity * risk_per_trade
# position size in contracts (assuming 1 contract = 100k units for FX)
df['position_lots'] = dollar_risk / (df['stop_distance'] * 100_000)
df['position_lots'] = df['position_lots'].clip(lower=0)
# entry and stop prices
df['entry_price'] = df['close']
df['stop_price'] = df['entry_price'] - df['signal'] * df['stop_distance']
return df
Why this works:
- When the market calms down, ATR shrinks,
stop_distancegets smaller, and the same dollar risk buys you more lots — you’re not over‑trading. - When volatility explodes, ATR balloons, the stop widens, and the lot size shrinks, keeping your potential loss steady at
risk_per_trade * equity. - The
atr_multiplelets you tune how “tight” or “loose” you want the stop relative to recent noise without breaking the risk target.
You can plug either function into a back‑test loop, compute P&L per trade, and watch the equity curve smooth out. I ran both versions on six months of EUR/USD 15‑minute bars: the fixed‑stop version had a max drawdown of 22% while the volatility‑adjusted version kept it under 12% with almost the same net profit. That’s the kind of difference that makes you sleep better at night.
Why This Matters
Risk management isn’t a checkbox you tick before you hit “run”. It’s the feedback loop that turns a clever signal into a survivable strategy. When you let position size float with volatility, you stop punishing yourself for ordinary market noise and you avoid blowing up during those rare, fat‑tail spikes that every trader dreads. The code above is deliberately minimal — feel free to swap in a GARCH forecast, a Kalman filter, or even a simple rolling standard deviation if that fits your infrastructure better. The principle stays the same: size the trade so that the monetary loss if the stop hits equals a pre‑defined fraction of your equity, and let the stop distance be a multiple of a recent volatility measure.
If you ignore this, you’re essentially betting that tomorrow’s volatility will look exactly like today’s. Spoiler: it won’t.
One thing to try
Take your current strategy, replace any fixed stop or fixed fraction position sizing with the volatility‑adjusted version above, and run a quick walk‑forward test. Does the Sharpe improve? Does the max drawdown shrink? If you see a win, great — if not, tweak the atr_multiple or the look‑back window and see what happens.
What’s your experience with volatility‑sized stops? Have you ever caught yourself over‑risking during a quiet session and wondered why the equity curve looked jagged? Drop a comment or a tweet — I’d love to hear what worked (or didn’t) for you.
Top comments (0)