Skip to content
FactorQX
intermediatebacktestingrisk

Position Sizing in a Backtest

How fixed-fractional, fixed-notional, and volatility-based sizing change a backtest, and why sizing — not signals — drives the shape of the equity curve.

3 min read

Education and engineering only. This is not investment advice, a signal, or a recommendation. The risk fractions and instruments below are placeholders to make the code runnable; they are not suggested values to trade with.

Most people tune entry rules and treat position size as an afterthought. That is backwards. Given a fixed set of signals, the sizing function determines the volatility of the equity curve, the depth of drawdowns, and whether the strategy compounds or grinds flat. This page implements three common sizing schemes in Python and shows how each one couples to the equity curve.

Three sizing schemes

Fixed-notional puts the same dollar amount into every trade regardless of account size. It is the simplest model and the easiest to reason about, but it does not compound: a winning streak does not grow your bets, and a 50% drawdown still risks the same absolute dollars on the next trade.

Fixed-fractional risks a constant fraction of current equity per trade. Because the base grows and shrinks with the account, gains compound and losses automatically de-leverage. This is the workhorse model, and the one whose interaction with the equity curve matters most.

Volatility-based sizing targets a constant amount of risk rather than a constant amount of capital. You size each position so that an N-unit adverse move costs a fixed fraction of equity. When an instrument is calm you hold more; when it is turbulent you hold less. This normalizes risk across instruments and across time.

Implementing the three

sizing.py
def fixed_notional(notional: float, price: float) -> float:
    """Same dollar exposure every trade. Does not compound."""
    return notional / price
 
def fixed_fractional(equity: float, risk_frac: float,
                     price: float, stop_distance: float) -> float:
    """Risk a constant fraction of CURRENT equity per trade.
 
    risk_frac is the share of equity lost if price moves
    stop_distance against the position (e.g. 0.01 = 1%).
    """
    dollars_at_risk = equity * risk_frac
    return dollars_at_risk / stop_distance
 
def volatility_target(equity: float, risk_frac: float,
                      price: float, atr: float, atr_mult: float) -> float:
    """Size so an atr_mult * ATR move costs risk_frac of equity."""
    stop_distance = atr_mult * atr
    dollars_at_risk = equity * risk_frac
    return dollars_at_risk / stop_distance

Note that fixed-fractional and volatility-target share the same skeleton — both divide a risk budget by a stop distance. The difference is purely how stop_distance is derived: a fixed price gap versus a multiple of recent ATR. That is the whole idea behind volatility scaling.

Wiring sizing into the equity curve

The reason fixed-fractional behaves so differently is that equity is read fresh on every bar, so each trade's size depends on the running result of all prior trades. A minimal loop makes the coupling explicit.

equity_loop.py
def run_backtest(trades, start_equity=10_000.0, risk_frac=0.01):
    equity = start_equity
    curve = [equity]
    for t in trades:
        qty = fixed_fractional(
            equity, risk_frac, t["price"], t["stop_distance"])
        # pnl_per_unit is realized move, signed, from the sim — NOT a forecast.
        pnl = qty * t["pnl_per_unit"]
        equity += pnl
        curve.append(equity)
    return curve

Because qty is recomputed from equity each iteration, the curve compounds geometrically: a 1% risk fraction after a run of wins commits more absolute dollars than it did at the start. Swap in fixed_notional and the same trade sequence produces a roughly linear curve instead — same signals, completely different risk profile and final number.

Compounding cuts both ways

Compounding is not free upside. Fixed-fractional sizing amplifies drawdowns just as it amplifies gains, and risk fractions stack non-linearly: risking 2% per trade is far more than twice as punishing as 1% across a losing streak, because each loss shrinks the base the next bet is computed from. This is why backtests that look fine at 1% can blow up at 5% on identical signals. Always sweep the risk fraction and inspect the worst equity path, not just the median.

One correctness note: the size of trade i must depend only on data available before trade i fills. If your stop_distance or atr accidentally includes the current bar's outcome, your sizing has peeked at the future and the whole curve is fiction.

Where to go next

Try the Position Size Calculator to build intuition for how risk fraction and stop distance trade off before you wire either into a loop. Then read Avoiding Lookahead Bias in Backtests — the single most common way a sizing function silently leaks future information into an otherwise honest backtest.

Educational content. This article covers software development and research methods only. It is not investment advice, a trading signal, or a recommendation. See our disclaimer.

More in Backtesting