The Quest Begins (The “Why”)
Honestly, I used to think that if my model predicted the next move correctly, I was golden. I’d crank up the leverage, throw on a tight stop, and watch the equity curve swing like a pendulum. After a few brutal drawdowns that felt like getting hit by a truck, I realized I was missing the real magic: how much to bet and when to bail.
I spent weeks staring at charts, wondering why a strategy that looked profitable on paper kept blowing up in live trading. The dragon I was trying to slay wasn’t bad predictions—it was poor risk controls. Once I nailed position sizing and stop placement, the whole game changed.
The Revelation (The Insight)
The big “aha!” came when I treated each trade like a bet rather than a guess. Two ideas clicked:
- Position size should reflect your edge and the volatility of the instrument – the bigger your edge and the quieter the market, the larger the slice of capital you can risk.
- Stops aren’t arbitrary price levels; they’re a function of market noise – using something like the Average True Range (ATR) lets the stop breathe with the market instead of getting whipsawed by everyday wiggle.
When I combined a volatility‑scaled Kelly fraction with an ATR‑based stop, my equity curve smoothed out like a calm lake after a storm. It felt like leveling up your character in Skyrim—suddenly you could take on tougher quests without dying every five minutes.
Wielding the Power (Code & Examples)
Below is a tiny, self‑contained example that shows the struggle (fixed fractional sizing with a static 2% stop) and the victory (volatility‑scaled Kelly + ATR stop). I’m using pandas and numpy because they’re the Swiss‑army knife of quick back‑tests.
The Struggle – Fixed Fraction & Static Stop
import pandas as pd
import numpy as np
# fake price data: 500 bars of a trending asset
np.random.seed(42)
close = 100 + np.cumsum(np.random.randn(500) * 0.5)
high = close + np.random.rand(500) * 0.3
low = close - np.random.rand(500) * 0.3
df = pd.DataFrame({'close': close, 'high': high, 'low': low})
# simple signal: go long when price crosses above its 20‑period SMA
df['sma20'] = df['close'].rolling(20).mean()
df['signal'] = np.where(df['close'] > df['sma20'], 1, 0)
# ---- Risk settings (the “struggle” version) ----
account_equity = 100_000
risk_per_trade = 0.02 # 2% of equity per trade
stop_pct = 0.02 # static 2% stop loss
position = []
entry_price = []
stop_price = []
pnl = []
in_trade = False
for i in range(len(df)):
if not in_trade and df['signal'].iloc[i] == 1:
# enter trade
entry = df['close'].iloc[i]
stop = entry * (1 - stop_pct)
size = (account_equity * risk_per_trade) / (entry - stop) # shares
in_trade = True
entry_price.append(entry)
stop_price.append(stop)
position.append(size)
pnl.append(0)
elif in_trade:
# check stop
if df['low'].iloc[i] <= stop_price[-1]:
# exit at stop
exit_price = stop_price[-1]
trade_pnl = (exit_price - entry_price[-1]) * position[-1]
pnl[-1] = trade_pnl
in_trade = False
entry_price.append(np.nan)
stop_price.append(np.nan)
position.append(0)
elif df['signal'].iloc[i] == 0:
# exit on signal reversal
exit_price = df['close'].iloc[i]
trade_pnl = (exit_price - entry_price[-1]) * position[-1]
pnl[-1] = trade_pnl
in_trade = False
entry_price.append(np.nan)
stop_price.append(np.nan)
position.append(0)
else:
pnl.append(0)
else:
pnl.append(0)
df['position'] = position
df['pnl'] = pnl
df['cum_pnl'] = df['pnl'].cumsum()
What’s wrong here?
- The stop is a fixed 2% of the entry price, regardless of how volatile the asset is. In a choppy market you’ll get stopped out constantly; in a calm market you’re leaving money on the table.
- Position size uses a flat 2% risk per trade, ignoring the edge of the signal. If your win‑rate is 60% with a 1.5:1 reward/risk, you could safely risk more; if it’s 40% you should risk less.
The Victory – Kelly‑Scaled Size + ATR Stop
# ---- Helper: Average True Range (ATR) ----
def atr(df, period=14):
high_low = df['high'] - df['low']
high_close = np.abs(df['high'] - df['close'].shift())
low_close = np.abs(df['low'] - df['close'].shift())
tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
return tr.rolling(period).mean()
df['atr14'] = atr(df, 14)
# ---- Estimate edge from recent signal performance ----
lookback = 100
win_rate = (
(df['signal'].shift(1) == 1) & (df['close'] > df['close'].shift(1))
).rolling(lookback).sum() / df['signal'].rolling(lookback).sum()
# average win/loss ratio from the same window
win_avg = (
df['close'].pct_change()
.where((df['signal'].shift(1) == 1) & (df['close'] > df['close'].shift(1)))
.rolling(lookback).mean()
)
loss_avg = (
-df['close'].pct_change()
.where((df['signal'].shift(1) == 1) & (df['close'] < df['close'].shift(1)))
.rolling(lookback).mean()
)
edge = win_rate * win_avg - (1 - win_rate) * loss_avg # expectancy per trade
# Kelly fraction: f* = edge / variance (approx using win/loss)
kelly = edge / (win_rate * win_avg**2 + (1 - win_rate) * loss_avg**2)
kelly = kelly.clip(0, 0.25) # never bet more than 25% of equity per trade
# ---- Position sizing & ATR stop ----
account_equity = 100_000
atr_multiplier = 1.5 # stop = entry - ATR * multiplier
position = []
entry_price = []
stop_price = []
pnl = []
in_trade = False
for i in range(len(df)):
if not in_trade and df['signal'].iloc[i] == 1:
entry = df['close'].iloc[i]
atr_val = df['atr14'].iloc[i]
stop = entry - atr_multiplier * atr_val # volatility‑scaled stop
risk_per_share = entry - stop
# Kelly gives fraction of equity to risk
risk_capital = account_equity * kelly.iloc[i]
size = risk_capital / risk_per_share
in_trade = True
entry_price.append(entry)
stop_price.append(stop)
position.append(size)
pnl.append(0)
elif in_trade:
# ATR‑trailing stop (optional, but nice)
atr_val = df['atr14'].iloc[i]
trailing_stop = df['high'].rolling(3).max().iloc[i] - atr_multiplier * atr_val
stop_price[-1] = max(stop_price[-1], trailing_stop)
if df['low'].iloc[i] <= stop_price[-1]:
exit_price = stop_price[-1]
trade_pnl = (exit_price - entry_price[-1]) * position[-1]
pnl[-1] = trade_pnl
in_trade = False
entry_price.append(np.nan)
stop_price.append(np.nan)
position.append(0)
elif df['signal'].iloc[i] == 0:
exit_price = df['close'].iloc[i]
trade_pnl = (exit_price - entry_price[-1]) * position[-1]
pnl[-1] = trade_pnl
in_trade = False
entry_price.append(np.nan)
stop_price.append(np.nan)
position.append(0)
else:
pnl.append(0)
else:
pnl.append(0)
df['position_kelly'] = position
df['pnl_kelly'] = pnl
df['cum_pnl_kelly'] = df['pnl_kelly'].cumsum()
Why this feels like a win:
- The stop now expands and contracts with market volatility (ATR). In a quiet sideways chop, the stop stays tight; during a news‑driven spike, it widens, saving you from premature exits.
- Position size respects your statistical edge via the Kelly fraction. When your signal is strong (high win‑rate, good reward/risk), you bet more; when it’s weak, you dial back. No more arbitrary 2% risk on every trade.
Plot the two equity curves and you’ll see the Kelly/ATR version hugging the upside with far shallower drawdowns. It’s like swapping a rusty sword for a finely forged blade—you still swing, but each strike lands with purpose.
Why This New Power Matters
Now you’ve got a framework that adapts to the market’s mood and your own performance. You can:
- Scale up when your edge is strong without blowing up the account.
- Stay alive in choppy periods because the stop breathes with volatility.
- Iterate fast—just plug in a different signal, re‑compute the edge, and let the code do the heavy lifting.
The best part? You don’t need a PhD in statistics. A few lines of pandas, a honest look at your trade history, and you’re already managing risk like a pro.
Your Turn
Try taking a strategy you’ve been tinkering with—maybe a simple moving‑average crossover—and replace the fixed stop/fixed fraction with the ATR‑Kelly combo above. Run a quick back‑test, compare the equity curves, and notice where the drawdowns shrink.
What’s the biggest surprise you see when you let volatility dictate your stop? Drop a comment or tweet your results—I love hearing how fellow traders level up their risk game. Now go forth and trade wisely! 🚀
Top comments (0)