Backtesting·Realism·Intermediate

Position Sizing

Implement fixed fractional, Kelly criterion, and volatility-targeted position sizing methods and compare their equity curve profiles.

position sizingkellyrisk management

Backtesting Realism — Position Sizing


1. Dependency Installation

[3]
import warnings
warnings.filterwarnings("ignore")

!pip install pandas numpy plotly
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

2. Library Imports

[4]
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

3. Position Sizing Concepts

Position sizing is the methodology employed to determine the capital allocation per trade. This parameter is critical for long-term portfolio survival; even strategies possessing a statistical edge can lead to ruin if position sizing is excessively aggressive. This notebook details three fundamental position sizing methods:

3.1 Fixed Fractional (Fixed Risk Percentage)

This method involves risking a constant percentage r of the current equity on each trade. Position size is calculated such that a stop-loss execution results in a loss precisely equal to r percent of the current equity. The formula for calculating trade units is:

Units = (Equity × r) / Stop Distance

Where Stop Distance is defined as the absolute difference between the entry price and the stop-loss price: |Entry Price − Stop Price|.

A typical risk allocation per trade is 1–2% of equity. This conservative approach mitigates the risk of catastrophic loss during extended losing streaks. For instance, risking 1% per trade ensures that 50 consecutive losses would reduce initial capital by approximately 40%, rather than leading to complete depletion.


3.2 Kelly Criterion

The Kelly formula determines the theoretically optimal fraction of capital to wager, aiming to maximize the long-run geometric growth rate of capital. The formula is expressed as:

Kelly Fraction = Win Rate − (Loss Rate / Reward:Risk Ratio)

A positive Kelly fraction indicates a strategy with a mathematical edge. While full Kelly betting maximizes long-term growth, it frequently leads to substantial short-term volatility, with drawdowns of 30–50% being common even for advantageous strategies. Consequently, Half-Kelly (50% of the full Kelly amount) is widely adopted in practice. This modification balances growth maximization with a significant reduction in potential drawdown.


3.3 ATR-Based Position Sizing

This method sets the stop-loss distance as a fixed multiple (typically 2×) of the Average True Range (ATR). Subsequently, the position size is calculated using principles similar to fixed fractional logic:

Units = (Equity × risk_pct) / (ATR × atr_multiplier)

During periods of elevated volatility, a larger ATR results in a smaller computed position size. Conversely, during periods of low volatility, a reduced ATR allows for a larger position size. This approach dynamically adjusts market exposure inversely to current volatility, thereby mitigating risk during unstable market conditions without requiring manual parameter recalibration.


4. Data Generation

4.1 Data Generation Function (generate_data)

This function synthesizes synthetic OHLCV (Open, High, Low, Close, Volume) price data. The generation process simulates price movements based on a random walk with adjustable volatility and wick scales, producing realistic-looking minute-level financial time series. The output pd.DataFrame includes open, high, low, close, volume, and datetime columns.

Parameters:

  • periods (int): The number of time periods (minutes) for which to generate data.

Output:

  • pd.DataFrame: A DataFrame containing the synthetic OHLCV data.
[5]
def generate_data(periods: int) -> pd.DataFrame:
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []; last_close = 42000
    volatility_scale = 0.005; wick_scale = 0.002

    for _ in range(periods):
        open_price  = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
        close_price = open_price + np.random.normal(0, last_close * volatility_scale)
        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)
        high_price  = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
                          open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, last_close * wick_scale)),
                          open_price, close_price)
        if high_price < low_price:
            high_price, low_price = low_price, high_price
        price_data.append({
            "open":  max(1, int(open_price)),
            "high":  max(1, int(high_price)),
            "low":   max(1, int(low_price)),
            "close": max(1, int(close_price)),
        })
        last_close = close_price

    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"]   = np.random.uniform(100.0, 500.0, periods)
    df["datetime"] = df.index.to_series()
    return df.reset_index(drop=True)

df = generate_data(500)
display(df.head())
open high low close volume datetime
0 42034 42461 41980 42334 329.381319 2024-01-01 00:00:00+00:00
1 42308 42401 41970 42021 109.657455 2024-01-01 00:01:00+00:00
2 42042 42082 41818 42006 401.094753 2024-01-01 00:02:00+00:00
3 42019 42074 41847 41848 490.726699 2024-01-01 00:03:00+00:00
4 41850 41912 41739 41746 285.280204 2024-01-01 00:04:00+00:00

5.1 Position Sizing Function Definitions

This section defines the core functions for calculating position sizes based on the three methodologies discussed: Fixed Fractional, Half-Kelly Criterion, and ATR-Based sizing. Each function provides specific unit calculations tailored to its respective risk management approach.


5. Position Sizing Functions

[6]
def fixed_fractional_units(
    equity:       float,
    risk_pct:     float,
    entry_price:  float,
    stop_price:   float,
) -> float:
    """
    Compute position size in units such that a stop-loss hit results
    in a loss equal to exactly risk_pct of current equity.

    stop_price must be below entry for a long position and above for a short.
    """
    risk_amount   = equity * risk_pct
    stop_distance = abs(entry_price - stop_price)
    if stop_distance == 0:
        return 0.0
    return risk_amount / stop_distance


def kelly_fraction(
    win_rate:  float,
    avg_win:   float,
    avg_loss:  float,
) -> float:
    """
    Compute the Half-Kelly optimal position fraction.

    win_rate : Historical proportion of winning trades (e.g., 0.55).
    avg_win  : Average return on winning trades (e.g., 0.01 = 1%).
    avg_loss : Average return on losing trades as a positive number.

    Returns the fraction of equity to allocate (capped at 0 minimum).
    """
    if avg_loss == 0:
        return 0.0
    rr     = avg_win / avg_loss
    kelly  = win_rate - (1 - win_rate) / rr
    return max(0.0, kelly * 0.5)   # Half-Kelly


def atr_based_units(
    equity:      float,
    risk_pct:    float,
    atr:         float,
    atr_mult:    float = 2.0,
) -> float:
    """
    Compute position size in units such that an ATR-based stop results
    in a loss equal to risk_pct of current equity.

    atr_mult : The number of ATR units defining the stop distance.
               Standard value: 2.0 (places stop outside normal market noise).
    """
    stop_distance = atr * atr_mult
    if stop_distance == 0:
        return 0.0
    return (equity * risk_pct) / stop_distance
  • fixed_fractional_units(equity, risk_pct, entry_price, stop_price):

    • Purpose: Computes the number of units to trade such that the loss incurred if the stop-loss is hit precisely equals a specified percentage (risk_pct) of the current equity.
    • Logic: The risk_amount is determined by equity * risk_pct. The stop_distance is the absolute difference between entry_price and stop_price. Units are risk_amount / stop_distance. A zero stop_distance results in zero units to prevent division by zero.
    • Note: This method requires a predetermined stop_price.
  • kelly_fraction(win_rate, avg_win, avg_loss):

    • Purpose: Calculates the Half-Kelly optimal fraction of capital to allocate per trade. This fraction maximizes the long-run geometric growth rate of the portfolio.
    • Logic: The Kelly formula is applied using historical win_rate, avg_win (average gain of winning trades), and avg_loss (average loss of losing trades). The result is capped at 0 and then halved (Half-Kelly) to reduce short-term volatility.
    • Note: A zero avg_loss results in zero allocation to prevent division by zero.
  • atr_based_units(equity, risk_pct, atr, atr_mult=2.0):

    • Purpose: Determines trade units based on a dynamically calculated stop distance, which is a multiple of the Average True Range (ATR). This integrates volatility into the position sizing.
    • Logic: The stop_distance is atr * atr_mult. Similar to fixed fractional, units are (equity * risk_pct) / stop_distance. A zero stop_distance results in zero units.
    • Note: atr_mult typically defaults to 2.0, placing the stop outside common market noise.

Explanation:

  • fixed_fractional_units: The stop distance is the key input — it must be determined before the position size can be computed. A wider stop (more room for the trade) forces a smaller position for the same risk amount; a tighter stop allows a larger position.
  • kelly_fraction: Returns a fraction of equity (e.g., 0.12 = allocate 12% of equity to this trade). Half-Kelly is used universally in practice because full-Kelly is theoretically optimal only when the exact win rate and pay-off ratio are known with certainty — which they never are.
  • atr_based_units: Combines ATR volatility measurement with fixed fractional risk. The stop is not a user-defined fixed level but a dynamically computed distance based on current market conditions.

6.1 compute_atr Function

This helper function calculates the Average True Range (ATR), a volatility indicator. ATR is the greatest of three values:

  1. Current High minus Current Low
  2. Current High minus Previous Close (absolute value)
  3. Current Low minus Previous Close (absolute value)

The ATR is then typically smoothed over a specified window (e.g., 14 periods) using a simple moving average.

Parameters:

  • df (pd.DataFrame): Input DataFrame containing 'high', 'low', and 'close' prices.
  • window (int): The period over which to calculate the moving average of the True Range.

Output:

  • pd.Series: A Series containing the ATR values.

6.2 backtest_position_sizing Function

This function performs a simplified backtest to compare the performance of different position sizing methods. It simulates a trading strategy based on a simple moving average (SMA) crossover system and applies the chosen position sizing logic.

Strategy Logic:

  • Entry Signal: A long position is initiated when the fast_ma (10-period SMA) crosses above the slow_ma (30-period SMA), and no position is currently held.
  • Exit Signal: An open long position is closed when the fast_ma crosses below the slow_ma.
  • Stop Loss (Dynamic): For Fixed Fractional and ATR-Based methods, a stop-loss is set at 2 times the current ATR below the entry price.
  • Fees: A fixed percentage fee_pct is applied to each transaction (buy and sell).

Parameters:

  • df (pd.DataFrame): The input DataFrame containing OHLCV data.
  • sizing_method (str): Specifies the position sizing method ('fixed_fractional', 'kelly', 'atr_based').
  • initial_capital (float): Starting capital for the backtest.
  • risk_pct (float): Percentage of equity risked per trade (for Fixed Fractional and ATR-Based).
  • fee_pct (float): Transaction fee percentage.
  • win_rate (float): Historical win rate (for Kelly Criterion).
  • avg_win (float): Average win percentage (for Kelly Criterion).
  • avg_loss (float): Average loss percentage (for Kelly Criterion).

Output:

  • pd.DataFrame: The input DataFrame augmented with fast_ma, slow_ma, atr, and equity columns, reflecting the simulated equity curve over time.

6. Position Sizing Comparison Backtest

[7]
def compute_atr(df: pd.DataFrame, window: int = 14) -> pd.Series:
    tr = pd.concat([
        df["high"] - df["low"],
        (df["high"] - df["close"].shift(1)).abs(),
        (df["low"]  - df["close"].shift(1)).abs(),
    ], axis=1).max(axis=1)
    return tr.rolling(window).mean()


def backtest_position_sizing(
    df:              pd.DataFrame,
    sizing_method:   str   = "fixed_fractional",
    initial_capital: float = 10_000.0,
    risk_pct:        float = 0.01,
    fee_pct:         float = 0.0005,
    win_rate:        float = 0.52,
    avg_win:         float = 0.008,
    avg_loss:        float = 0.005,
) -> pd.DataFrame:
    df = df.copy().sort_values("datetime", ignore_index=True)
    df["fast_ma"] = df["close"].rolling(10).mean()
    df["slow_ma"] = df["close"].rolling(30).mean()
    df["atr"]     = compute_atr(df, window=14)

    cash = initial_capital; pos = 0.0; entry_price = None
    equity_log = []

    for _, row in df.iterrows():
        if pd.isna(row["fast_ma"]) or pd.isna(row["slow_ma"]) or pd.isna(row["atr"]):
            equity_log.append(cash + pos * row["close"])
            continue

        sig   = 1 if row["fast_ma"] > row["slow_ma"] else 0
        price = row["close"]

        if sig == 1 and pos == 0:
            stop_price = price - 2 * row["atr"]

            if sizing_method == "fixed_fractional":
                units = fixed_fractional_units(cash, risk_pct, price, stop_price)
            elif sizing_method == "kelly":
                frac  = kelly_fraction(win_rate, avg_win, avg_loss)
                units = (cash * frac) / price
            elif sizing_method == "atr_based":
                units = atr_based_units(cash, risk_pct, row["atr"], atr_mult=2.0)
            else:
                units = (cash * 0.95) / price

            cost = units * price * (1 + fee_pct)
            if cost <= cash:
                cash -= cost; pos = units; entry_price = price

        elif sig == 0 and pos > 0:
            cash += pos * price * (1 - fee_pct)
            pos   = 0.0; entry_price = None

        equity_log.append(cash + pos * price)

    df["equity"] = equity_log
    return df


methods = ["fixed_fractional", "kelly", "atr_based"]
results = {}

for method in methods:
    df_r = backtest_position_sizing(df, sizing_method=method, initial_capital=10_000)
    results[method] = df_r["equity"].values
    final  = df_r["equity"].iloc[-1]
    ret    = (final / 10_000 - 1) * 100
    max_dd = ((df_r["equity"] / df_r["equity"].cummax()) - 1).min() * 100
    print(f"{method:20s} : Final ${final:,.2f}  ({ret:+.2f}%)  MaxDD {max_dd:.2f}%")
fixed_fractional     : Final $11,136.14  (+11.36%)  MaxDD -4.72%
kelly                : Final $10,171.77  (+1.72%)  MaxDD -0.74%
atr_based            : Final $11,136.14  (+11.36%)  MaxDD -4.72%

7.1 Point-in-Time Sizing Calculations

This section demonstrates the application of each position sizing function for a single, hypothetical trade at the last data point in the generated dataset. It calculates the recommended trade units or capital allocation based on the current market conditions (last close price and ATR) and a defined risk parameter.

Inputs:

  • equity: Current portfolio equity.
  • entry_price: The closing price of the last period in the df.
  • atr_value: The ATR calculated for the last period.
  • stop_price: Dynamically set as 2 times the ATR below the entry_price.

Calculations:

  • Fixed Fractional: Calculates units risking 1% of equity with the derived stop_price.
  • Half-Kelly: Calculates the fraction of equity to allocate, using predefined historical win/loss parameters.
  • ATR-Based: Calculates units risking 1% of equity, with the stop distance based on 2x ATR.

Output:

  • Printed details including equity, entry price, ATR, stop price, and the calculated units/allocation for each method, along with their notional values.

7. Point-in-Time Sizing Example

[8]
equity      = 10_000.0
entry_price = df["close"].iloc[-1]
atr_value   = compute_atr(df).iloc[-1]
stop_price  = entry_price - 2 * atr_value

print("--- Point-in-Time Position Sizing ---")
print(f"Equity       : ${equity:,.2f}")
print(f"Entry Price  : ${entry_price:,.2f}")
print(f"ATR (14)     : ${atr_value:,.2f}")
print(f"Stop Price   : ${stop_price:,.2f}  (2× ATR below entry)")
print()
ff_units = fixed_fractional_units(equity, 0.01, entry_price, stop_price)
kf_frac  = kelly_fraction(0.52, 0.008, 0.005)
ab_units = atr_based_units(equity, 0.01, atr_value, 2.0)
print(f"Fixed Fractional (1% risk) : {ff_units:.6f} units  (${ff_units * entry_price:,.2f} notional)")
print(f"Half-Kelly fraction        : {kf_frac:.4f}  (${equity * kf_frac:,.2f} allocated)")
print(f"ATR-Based (1% risk, 2×ATR) : {ab_units:.6f} units  (${ab_units * entry_price:,.2f} notional)")
--- Point-in-Time Position Sizing ---
Equity       : $10,000.00
Entry Price  : $47,304.00
ATR (14)     : $428.21
Stop Price   : $46,447.57  (2× ATR below entry)

Fixed Fractional (1% risk) : 0.116764 units  ($5,523.40 notional)
Half-Kelly fraction        : 0.1100  ($1,100.00 allocated)
ATR-Based (1% risk, 2×ATR) : 0.116764 units  ($5,523.40 notional)

8.1 Interactive Visualization of Position Sizing Comparison

This section generates an interactive Plotly visualization to graphically compare the price action with ATR, the equity curves, and drawdowns of the three position sizing methodologies over the backtest period.

Subplots Description:

  1. Price + ATR Dynamic Stop Reference: Displays the candlestick chart of the generated price data overlaid with the 14-period Average True Range (ATR). This illustrates the underlying market movements and volatility.
  2. Equity Curves — Position Sizing Method Comparison: Presents the simulated equity growth for each position sizing method (fixed_fractional, kelly, atr_based). This allows for a visual comparison of their cumulative performance.
  3. Drawdown (%): Shows the percentage drawdown from peak equity for each method. This metric is crucial for assessing risk and capital preservation effectiveness.

Legend Positioning: The legend for all plots is explicitly positioned at the top-right corner to ensure visibility and clarity.

8. Visualization

[10]

fig = make_subplots(
    rows=3, cols=1, shared_xaxes=True,
    subplot_titles=[
        "Price + ATR Dynamic Stop Reference",
        "Equity Curves — Position Sizing Method Comparison",
        "Drawdown (%)",
    ],
    row_heights=[0.35, 0.40, 0.25],
)

# Subplot 1: Candlestick chart with ATR overlay
fig.add_trace(go.Candlestick(
    x=df["datetime"],
    open=df["open"], high=df["high"],
    low=df["low"],   close=df["close"],
    name="Price",
    showlegend=True # Ensure legend item is shown for Price
), row=1, col=1)

atr_series = compute_atr(df)
fig.add_trace(go.Scatter(
    x=df["datetime"], y=atr_series,
    mode="lines", name="ATR (14)",
    line=dict(color="purple", width=1),
    yaxis="y2",
    showlegend=True # Ensure legend item is shown for ATR
), row=1, col=1)

colors_map = {
    "fixed_fractional": "blue",
    "kelly":            "green",
    "atr_based":        "orange",
}

# Subplot 2 & 3: Equity Curves and Drawdown for each method
for method, color in colors_map.items():
    equity_vals = results[method]
    equity_s    = pd.Series(equity_vals)
    drawdown    = (equity_s / equity_s.cummax() - 1) * 100

    # Equity Curve
    fig.add_trace(go.Scatter(
        x=df["datetime"], y=equity_vals,
        mode="lines", name=f"Equity — {method}",
        line=dict(color=color, width=1.5),
        showlegend=True # Ensure legend item is shown for Equity
    ), row=2, col=1)

    # Drawdown
    fig.add_trace(go.Scatter(
        x=df["datetime"], y=drawdown,
        mode="lines", name=f"DD — {method}",
        line=dict(color=color, width=1, dash="dot"),
        showlegend=True # Ensure legend item is shown for Drawdown
    ), row=3, col=1)

fig.update_layout(
    title_text="Position Sizing Method Comparison",
    xaxis_rangeslider_visible=False,
    height=900,
    yaxis=dict(autorange=True),
    yaxis2=dict(title="ATR", overlaying="y", side="right"),
    xaxis3_title="Datetime",
    yaxis_title="Price",
    yaxis2_title="Portfolio Value ($)", # Label for the Y-axis of the equity plot
    yaxis3_title="Drawdown (%)",         # Label for the Y-axis of the drawdown plot
    legend=dict(
        orientation="h", # Horizontal legend
        yanchor="bottom",
        y=1.02, # Position above the first subplot
        xanchor="right",
        x=1 # Right aligned
    ) # Fix legend position and orientation
)

fig.show()