Position sizing is where math meets money management. In this article, we implement five battle-tested position sizing algorithms in Python and compare their risk-reward profiles through Monte Carlo simulation.
The Five Algorithms
import numpy as np
from abc import ABC, abstractmethod
from typing import Optional
class PositionSizer(ABC):
@abstractmethod
def calculate(self, account_balance: float, risk_per_trade: float,
stop_distance: float, tick_value: float) -> int:
pass
class FixedFractional(PositionSizer):
"""Classic fixed percentage risk per trade."""
def __init__(self, fraction: float = 0.02):
self.fraction = fraction
def calculate(self, account_balance, risk_per_trade, stop_distance, tick_value):
risk_dollars = account_balance * self.fraction
risk_per_contract = stop_distance * tick_value
return max(1, int(risk_dollars / risk_per_contract))
class KellyCriterion(PositionSizer):
"""Kelly Criterion — optimal growth rate sizing."""
def __init__(self, win_rate: float, avg_win: float, avg_loss: float, kelly_fraction: float = 0.5):
self.win_rate = win_rate
self.avg_win = avg_win
self.avg_loss = abs(avg_loss)
self.kelly_fraction = kelly_fraction # Half-Kelly is safer
def calculate(self, account_balance, risk_per_trade, stop_distance, tick_value):
# Kelly formula: f* = (bp - q) / b
# b = avg_win / avg_loss, p = win_rate, q = 1 - p
b = self.avg_win / self.avg_loss if self.avg_loss > 0 else 1
f_star = (b * self.win_rate - (1 - self.win_rate)) / b
f_star = max(0, f_star * self.kelly_fraction)
risk_dollars = account_balance * f_star
risk_per_contract = stop_distance * tick_value
return max(1, int(risk_dollars / risk_per_contract))
class FixedRatio(PositionSizer):
"""Ryan Jones' Fixed Ratio — delta-based scaling."""
def __init__(self, delta: float = 5000):
self.delta = delta
def calculate(self, account_balance, risk_per_trade, stop_distance, tick_value):
# n(n+1)/2 * delta = profit needed
# Solve for n: n = (-1 + sqrt(1 + 8*P/delta)) / 2
profit = max(0, account_balance - 50000) # Assume starting balance
n = (-1 + np.sqrt(1 + 8 * profit / self.delta)) / 2
return max(1, int(n) + 1)
class VolatilityBased(PositionSizer):
"""Size based on recent volatility (ATR-based)."""
def __init__(self, atr_multiplier: float = 2.0, risk_pct: float = 0.01):
self.atr_multiplier = atr_multiplier
self.risk_pct = risk_pct
def calculate(self, account_balance, risk_per_trade, stop_distance, tick_value):
risk_dollars = account_balance * self.risk_pct
volatility_stop = stop_distance * self.atr_multiplier * tick_value
return max(1, int(risk_dollars / volatility_stop))
class AntiMartingale(PositionSizer):
"""Increase size after wins, decrease after losses."""
def __init__(self, base_contracts: int = 1, scale_factor: float = 1.5):
self.base_contracts = base_contracts
self.scale_factor = scale_factor
self.consecutive_wins = 0
def update(self, is_win: bool):
if is_win:
self.consecutive_wins += 1
else:
self.consecutive_wins = 0
def calculate(self, account_balance, risk_per_trade, stop_distance, tick_value):
extra = int(self.consecutive_wins * self.scale_factor)
return self.base_contracts + extra
Monte Carlo Comparison
def monte_carlo_simulation(sizer: PositionSizer, n_trades: int = 500,
n_sims: int = 1000, starting_balance: float = 50000,
win_rate: float = 0.55, avg_win: float = 400,
avg_loss: float = 300) -> dict:
final_balances = []
max_drawdowns = []
blown = 0
for _ in range(n_sims):
balance = starting_balance
peak = starting_balance
max_dd = 0
for _ in range(n_trades):
contracts = sizer.calculate(balance, 0, 10, 12.50) # ES-like
if np.random.random() < win_rate:
pnl = avg_win * contracts
if hasattr(sizer, 'update'):
sizer.update(True)
else:
pnl = -avg_loss * contracts
if hasattr(sizer, 'update'):
sizer.update(False)
balance += pnl
peak = max(peak, balance)
dd = (peak - balance) / peak * 100
max_dd = max(max_dd, dd)
if balance <= 0:
blown += 1
break
final_balances.append(balance)
max_drawdowns.append(max_dd)
return {
"median_final": np.median(final_balances),
"mean_final": np.mean(final_balances),
"std_final": np.std(final_balances),
"avg_max_dd": np.mean(max_drawdowns),
"blown_pct": blown / n_sims * 100,
"p90_final": np.percentile(final_balances, 90),
"p10_final": np.percentile(final_balances, 10),
}
# Compare all five
sizers = {
"Fixed 2%": FixedFractional(0.02),
"Half Kelly": KellyCriterion(0.55, 400, 300, 0.5),
"Fixed Ratio": FixedRatio(5000),
"Volatility": VolatilityBased(2.0, 0.01),
"Anti-Martingale": AntiMartingale(1, 1.5),
}
print(f"{'Method':<18} {'Median':>10} {'Mean':>10} {'Blown%':>8} {'Avg MaxDD':>10}")
print("-" * 60)
for name, sizer in sizers.items():
results = monte_carlo_simulation(sizer)
print(f"{name:<18} ${results['median_final']:>9,.0f} ${results['mean_final']:>9,.0f} "
f"{results['blown_pct']:>7.1f}% {results['avg_max_dd']:>9.1f}%")
Key Findings
- Fixed Fractional offers the best risk-adjusted returns for most traders
- Kelly Criterion maximizes long-run growth but has brutal drawdowns at full Kelly — always use half or quarter Kelly
- Fixed Ratio scales conservatively, ideal for building confidence
- Volatility-based adapts to market conditions automatically
- Anti-Martingale can amplify winning streaks but can also give back gains quickly
Practical Recommendation
For most funded account traders, Fixed Fractional at 1% is the safest choice. As you build a track record, graduate to Half Kelly for optimal growth.
The right position sizing depends partly on the drawdown rules your prop firm enforces. For a comprehensive look at how different firms structure their rules, PropFirmKey provides side-by-side comparisons — firms like Alpha Futures that use EOD trailing drawdown allow more aggressive sizing than real-time trailing firms.
Top comments (0)