Volatility targeting & ATR-based sizing
Size so every trade risks the same amount of volatility, not the same number of price points. Quiet markets get bigger positions, wild markets get smaller ones — automatically.
Fixed-fractional sizing keeps your *currency* risk constant. But markets breathe: the same instrument might swing 0.5% a day for months, then 5% a day during a crisis. If your stop is a fixed number of points, you are unknowingly risking a tiny bit of volatility in calm times and a huge amount in wild times. Volatility targeting fixes this by making the *volatility* you risk constant instead.
ATR: measuring how much a market moves
The standard volatility yardstick for a single instrument is the Average True Range (ATR). True range for one bar is the largest of: today's high−low, |high−previous close|, and |low−previous close| (the last two catch overnight gaps). ATR is a rolling average of that — a clean, gap-aware measure of "how far this thing typically travels in a bar".
import pandas as pd
def atr(df, period=class="n">14):
class="s">""class="s">"df needs columns: high, low, close. Returns an ATR series."class="s">""
high, low, close = df[class="s">"high"], df[class="s">"low"], df[class="s">"close"]
prev_close = close.shift(class="n">1)
true_range = pd.concat([
high - low, class="c"># todayclass="s">'s range
(high - prev_close).abs(), class="c"># gap up from prev close
(low - prev_close).abs(), class="c"># gap down from prev close
], axis=class="n">1).max(axis=class="n">1)
class="c"># Wilder's smoothing (an EMA with alpha = class="n">1/period)
return true_range.ewm(alpha=class="n">1 / period, adjust=False).mean()ATR-based stops and sizing
Now the two pieces click together. Set the stop a multiple of ATR away from entry (say 2×ATR) so the stop automatically widens in volatile markets and tightens in calm ones. Then feed that ATR-based stop distance into the fixed-fractional formula from the last lesson. The result: constant currency risk *and* constant volatility risk.
def atr_position(equity, risk_pct, price, atr_value, atr_mult=class="n">2.0):
class="s">""class="s">"
Stop is atr_mult ATRs away; size risks risk_pct of equity.
Returns (units, stop_price, stop_distance).
"class="s">""
stop_distance = atr_mult * atr_value
stop_price = price - stop_distance class="c"># for a long
risk_amount = equity * risk_pct
units = risk_amount / stop_distance
return units, stop_price, stop_distance
equity, risk_pct = 50_000, class="n">0.01
class="c"># Calm market: ATR is small
u1, s1, d1 = atr_position(equity, risk_pct, price=class="n">100, atr_value=class="n">0.8)
class="c"># Wild market: same price, ATR 5x bigger
u2, s2, d2 = atr_position(equity, risk_pct, price=class="n">100, atr_value=class="n">4.0)
print(f"Calm: ATR class="n">0.8 stop {d1:.1f} away -> {u1:class="n">6.0f} unitsclass="s">")
print(f"Wild: ATR class="n">4.0 stop {d2:.1f} away -> {u2:class="n">6.0f} units")
class="c"># Calm: ATR class="n">0.8 stop class="n">1.6 away -> class="n">312 units
class="c"># Wild: ATR class="n">4.0 stop class="n">8.0 away -> class="n">62 unitsSame account, same price, same 1% risk. In the calm market the model buys 312 units; in the wild market — five times more volatile — it buys just 62, one-fifth as many. You did not decide that. The volatility decided it. When a crisis hits and ATR spikes, your positions shrink automatically, exactly when you want less exposure. This is why volatility targeting is the default sizing method for most professional trend and futures programmes.
Volatility targeting at the portfolio level
The same idea scales up to a whole account. Instead of a fixed currency risk, you target an annualised portfolio volatility — say "I want my equity curve to have 15% annual volatility" — and scale total exposure up or down to hit it. When realised volatility runs hot, you lever down; when it is quiet, you lever up. This is the core of "risk parity" and most managed-futures sizing.
import numpy as np
def vol_target_leverage(returns, target_vol=class="n">0.15, lookback=class="n">20):
class="s">""class="s">"
returns : recent per-period returns (e.g. daily)
target_vol: desired ANNUAL volatility of the strategy
returns : leverage multiplier to apply to the position
"class="s">""
realised_daily = np.std(returns[-lookback:])
realised_annual = realised_daily * np.sqrt(class="n">252) class="c"># annualise
if realised_annual == class="n">0:
return class="n">1.0
lev = target_vol / realised_annual
return float(np.clip(lev, class="n">0.0, class="n">3.0)) class="c"># cap leverage at 3x
calm = np.random.normal(class="n">0, class="n">0.005, class="n">40) class="c"># ~class="n">8% annual vol
wild = np.random.normal(class="n">0, class="n">0.030, class="n">40) class="c"># ~class="n">48% annual vol
print(f"Calm market -> leverage {vol_target_leverage(calm):.2f}xclass="s">")
print(f"Wild market -> leverage {vol_target_leverage(wild):.2f}x")
class="c"># Calm market -> leverage ~class="n">1.90x (lever UP to reach target vol)
class="c"># Wild market -> leverage ~class="n">0.31x (lever DOWN to stay at target)We now have two ways to answer "how big?" — constant currency risk and constant volatility risk. The next question is the most seductive and most dangerous in all of trading: is there an *optimal* size that maximises growth? There is. It is called Kelly, and it will try to bankrupt you.