Position sizing by ATR
Lesson 15 · about 13 min
So far, "long" has meant 100% of the account. That is not a sizing decision, it is the absence of one. A system needs to decide how much to hold so that a normal adverse move costs a fixed, small fraction of equity, regardless of whether the instrument moves 0.5% a day or 5%. ATR gives you that scaling, and this lesson turns the position-sizing arithmetic from the Risk Management course into a column.
The formula, in fractions of equity
Fixed-fractional sizing says: risk f of equity per trade (say 1%), with the stop k ATRs away from entry (say 2). Then:
- stop distance as a fraction of price =
k × ATR / price - position, as a fraction of equity =
f / (k × ATR / price)
If ATR is 2% of price and the stop is 2 ATRs (4%) away, a 1% risk budget gives a position of 0.01 / 0.04 = 25% of equity. If ATR doubles to 4%, the position halves to 12.5%. That is the whole point: exposure shrinks when the instrument gets wilder.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma, atr
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
def atr_position_size(bars: pd.DataFrame, risk_per_trade: float = 0.01, atr_mult: float = 2.0,
atr_n: int = 14, max_leverage: float = 1.0) -> pd.Series:
"""Fraction of equity to hold so that a move of atr_mult ATRs loses risk_per_trade of equity."""
stop_frac = atr_mult * atr(bars, atr_n) / bars["close"]
size = risk_per_trade / stop_frac
return size.clip(upper=max_leverage).rename("size")
bars = synthetic_ohlcv(1500, seed=42)
size = atr_position_size(bars)
print(size.describe().round(3))
The clip(upper=max_leverage) is the position cap from the Risk Management course: no matter how quiet the instrument, do not exceed a fixed multiple of equity. With max_leverage=1.0 you can never be more than fully invested; for futures or margin accounts you might allow 2 or 3, knowing that gaps go through stops.
Sizing into the backtest
Position is now signal times size, both lagged, because both are computed from the bar before the position is held.
def backtest_sized(bars: pd.DataFrame, signal: pd.Series, size: pd.Series,
cost_bps: float = 5.0) -> pd.DataFrame:
asset_ret = bars["close"].pct_change()
position = (signal * size).shift(1).fillna(0.0)
turnover = position.diff().abs().fillna(0.0)
net = (position * asset_ret).fillna(0.0) - turnover * cost_bps / 10_000.0
return pd.DataFrame({"position": position, "turnover": turnover, "net": net,
"equity": (1 + net).cumprod()})
sig = ma_crossover_signal(bars["close"])
unsized = backtest_sized(bars, sig, pd.Series(1.0, index=bars.index))
sized = backtest_sized(bars, sig, size)
for name, bt in [("all-in", unsized), ("ATR-sized", sized)]:
dd = (bt["equity"] / bt["equity"].cummax() - 1).min()
print(f"{name:>10}: final {bt['equity'].iloc[-1]:.3f}, max DD {dd:.1%}, "
f"avg position {bt['position'][bt['position'] > 0].mean():.2f}, turnover {bt['turnover'].sum():.1f}")
The sized version holds a fraction of the account, so its return and its drawdown are both smaller. The comparison that matters is not final equity but return per unit of drawdown, which Module 6 measures. Note also that turnover went up: the sized position changes a little every bar as ATR changes, and every change is charged.
Continuous rebalancing versus size-at-entry
That constant small turnover is an artefact. A discretionary trader sizes once at entry and holds the position until exit; a vectorized backtest with signal × size re-sizes daily. The daily version is a legitimate strategy (it is volatility targeting), but if you intend to size at entry, model that instead:
def size_at_entry(signal: pd.Series, size: pd.Series) -> pd.Series:
"""Freeze the size at the first bar of each trade; zero when flat."""
in_trade = signal.fillna(0.0) != 0
trade_id = (in_trade != in_trade.shift(1)).cumsum()
frozen = size.groupby(trade_id).transform("first")
return frozen.where(in_trade, 0.0).rename("size_at_entry")
entry_size = size_at_entry(sig, size)
fixed = backtest_sized(bars, sig, entry_size.where(sig != 0, 1.0))
print(f"size-at-entry: final {fixed['equity'].iloc[-1]:.3f}, turnover {fixed['turnover'].sum():.1f}")
trade_id increments every time the in/out state flips, so each trade gets its own group, and transform("first") broadcasts the first bar's size across the whole trade. Turnover drops to roughly one unit in and one out per trade, as it would be for a real position. (The where(sig != 0, 1.0) just keeps the flat periods at a harmless placeholder; signal is zero there so the product is zero anyway.)
Which is right? Neither is more correct; they are different strategies. Volatility targeting adjusts continuously and pays for it in turnover; size-at-entry is cheaper and lets the risk drift as volatility changes. Decide, write it down, and model the one you will actually run.
Key idea: Position as a fraction of equity = risk per trade ÷ (ATR multiple × ATR ÷ price), capped at a maximum leverage. Decide whether you re-size every bar or freeze at entry, and make the backtest do exactly that.
Sizing in real units
At some point a fraction of equity has to become a number of shares or contracts, and the broker will reject 12.7 shares of most things.
def to_units(fraction: float, equity: float, price: float, lot: float = 1.0,
multiplier: float = 1.0) -> float:
"""Round a fraction of equity down to a whole number of lots."""
notional = fraction * equity
raw = notional / (price * multiplier)
return np.floor(raw / lot) * lot
print(to_units(0.25, equity=50_000, price=412.30)) # shares
print(to_units(0.25, equity=50_000, price=5200.0, multiplier=5.0)) # MES contracts ($5/pt)
print(to_units(0.25, equity=50_000, price=64_000.0, lot=0.001)) # BTC in 0.001 lots
Rounding down means small accounts sometimes get zero units. That is the correct answer: if one contract is more risk than the plan allows, the trade is skipped, not oversized. Sizing this way into the vectorized backtest matters less (fractions are fine for statistics) but it is essential in Module 7's event loop and in Module 9's live code.
Where the stop went
Careful readers will notice the backtest above sizes as if there were a 2-ATR stop but never actually exits at it; exits still come only from the signal. In a vectorized backtest that is unavoidable, because a stop is a path-dependent intrabar event. Module 7 adds real stops. Until then, the sizing is still correct as a way to make exposure proportional to volatility; it just does not yet cap the loss on any single trade.
Try it: Run the ATR-sized crossover with
risk_per_tradeat 0.5%, 1% and 2% and record final equity and max drawdown for each. Then run the all-in version. Plot the four equity curves on one axis with a log y-scale (ax.set_yscale("log")). Notice that the shapes are the same and only the amplitude changes; sizing scales a strategy, it does not fix one.
Recap
- Position fraction = risk per trade ÷ (ATR multiple × ATR / price), capped by a maximum leverage.
- Multiply signal by size and lag both; exposure now shrinks automatically when volatility rises.
- Daily re-sizing is volatility targeting and costs turnover;
size_at_entryfreezes the size per trade withgroupby(trade_id).transform("first"). - Convert fractions to whole lots by rounding down; zero units means skip the trade.
- The stop implied by the sizing is not yet enforced; that needs the event loop in Module 7.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.