Skip to content
GetProfitable
Search

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_trade at 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_entry freezes the size per trade with groupby(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.

How a position size is worked outAccount size, risk per trade and stop distance feed into one box giving the number of shares.ACCOUNT SIZE$25,000your capitalRISK PER TRADE1%of the accountSTOP DISTANCE$0.50entry to stopPOSITION SIZE500 sharesrisk budget: $25,000 × 1% = $250position size: $250 ÷ $0.50 = 500 shares
Working out a position size. Three numbers decide how big a trade is: the account, the share of it put at risk, and the distance from entry to stop. One percent of $25,000 is a $250 budget, and a $0.50 stop divides into that 500 times.
Margin and leverageA small deposit controlling a much larger position, and the point at which losses trigger a margin call.Position you controlnotional value $100,000your margin deposit: $5,000$100,000 / $5,000 = 20:1 leverageYour deposit absorbs every dollar of loss$5,000$2,500$0Equity leftMARGIN CALLequity has fallen to $2,5000%1%2%2.5%3%4%5%How far the price moves against you
Margin and leverage. A $5,000 deposit can control a $100,000 position, which is 20:1 leverage. Because the loss is measured on the full $100,000, a 2.5% move against you halves the deposit and brings a margin call, and a 5% move uses all of it.
An equity curve and its drawdownAn account balance rising over a year, falling from a peak to a trough, then climbing back to the old peak.ACCOUNT EQUITY$20k$12k$8k024681012TIME (MONTHS)PEAK $16,000TROUGH $12,000DRAWDOWN−25%RECOVERY
Equity curve and drawdown. An account balance plotted month by month. The fall from the $16,000 peak to the $12,000 trough is a 25% drawdown, and the shaded area lasts until the balance climbs back to the old peak.

Finished this module? Take the module quiz.