Position Sizing
Implement fixed fractional, Kelly criterion, and volatility-targeted position sizing methods and compare their equity curve profiles.
Backtesting Realism — Position Sizing
1. Dependency Installation
import warnings
warnings.filterwarnings("ignore")
!pip install pandas numpy plotlyRequirement 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
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots3. 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.
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
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 currentequity. - Logic: The
risk_amountis determined byequity * risk_pct. Thestop_distanceis the absolute difference betweenentry_priceandstop_price. Units arerisk_amount / stop_distance. A zerostop_distanceresults in zero units to prevent division by zero. - Note: This method requires a predetermined
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 (
-
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), andavg_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_lossresults 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_distanceisatr * atr_mult. Similar to fixed fractional, units are(equity * risk_pct) / stop_distance. A zerostop_distanceresults in zero units. - Note:
atr_multtypically 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:
- Current High minus Current Low
- Current High minus Previous Close (absolute value)
- 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 theslow_ma(30-period SMA), and no position is currently held. - Exit Signal: An open long position is closed when the
fast_macrosses below theslow_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_pctis 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 withfast_ma,slow_ma,atr, andequitycolumns, reflecting the simulated equity curve over time.
6. Position Sizing Comparison Backtest
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 thedf.atr_value: The ATR calculated for the last period.stop_price: Dynamically set as 2 times the ATR below theentry_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
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:
- 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.
- 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. - 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
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()