Position Sizing for Forex: Kelly Criterion in R
position sizing forex, Kelly criterion R, forex risk management, quantitative finance R, systematic trading
Traders spend months tuning entry signals and five minutes deciding how many lots to trade. That imbalance is a mistake, because position sizing determines the shape of your equity curve far more than any entry condition does.
This is not opinion. It follows directly from the mathematics of compound growth. Two traders running the same signals but different sizing rules will produce completely different account trajectories over 500 trades. This article implements the Kelly criterion in R so you can find the sizing that maximises long-run growth from your actual backtest data.
Why Sizing Dominates Entry Signals
Consider a coin-flip strategy: 55% win rate, pays 1:1. Trade it at 50% of capital per flip and you will almost certainly go broke, because a short losing streak wipes you out before the edge compounds. Trade it at 1% and you survive but grow slowly. Trade it at the Kelly-optimal 10% and geometric growth is maximised.
Mathematically, the long-run compound growth rate of a strategy is:
\[g = \ln(1 + f \cdot b) \cdot p + \ln(1 - f) \cdot (1 - p)\]
where f is the fraction of capital risked, b is the win-to-loss ratio, and p is the win rate. This expression has a maximum at a specific f. Above that maximum, growth falls, and well above it, ruin becomes certain. That maximum is the Kelly fraction.
A systematic forex trader in Nairobi running a 52% win rate breakout strategy on EURUSD has an edge. Whether that edge translates into account growth depends almost entirely on how they size positions.
The Kelly Criterion: Formula and Derivation
John Kelly derived his formula in 1956 to solve optimal bet sizing for a gambler with a noisy signal. For a binary outcome game (win b units or lose 1 unit per bet), the Kelly fraction is:
\[f^* = \frac{p \cdot b - (1 - p)}{b} = p - \frac{q}{b}\]
where p is win probability and q = 1 - p. The formula tells you the fraction of current capital to risk on each trade. For a strategy with 55% wins and average payoff ratio 1.5:
\[f^* = 0.55 - \frac{0.45}{1.5} = 0.55 - 0.30 = 0.25\]
Risk 25% of equity per trade. That is the full Kelly recommendation for those parameters, and it is almost certainly too aggressive for a live forex account. We will get to why shortly.
Estimating Parameters and Computing Kelly in R
Before sizing, you need reliable estimates of p and b from real trade data. The following R code reads an MT5-style trade log and computes both parameters.
library(dplyr)
library(readr)
# MT5 exports a semicolon-delimited file; adjust sep as needed
trades <- read_delim("mt5_history.csv", delim = ";", col_types = cols()) |>
rename_with(tolower) |>
filter(type %in% c("buy", "sell")) |> # exclude deposits/withdrawals
mutate(profit = as.numeric(profit))
# Separate wins from losses
wins <- trades |> filter(profit > 0)
losses <- trades |> filter(profit < 0)
win_rate <- nrow(wins) / nrow(trades)
avg_win <- mean(wins$profit)
avg_loss <- abs(mean(losses$profit))
payoff_ratio <- avg_win / avg_loss
cat(sprintf(
"Trades: %d | Win rate: %.1f%% | Avg win: %.2f | Avg loss: %.2f | Payoff: %.2f\n",
nrow(trades), win_rate * 100, avg_win, avg_loss, payoff_ratio
))For real East African forex traders operating on ECN brokers through Nairobi-based prop desks, the average trade log contains 200 to 800 trades per year. That is enough data to estimate win rate with reasonable precision, but not enough to treat the estimate as exact. We return to this limitation in the next section.
From Parameters to Lot Size
With p and b in hand, the Kelly fraction is a single line of arithmetic:
kelly_fraction <- function(win_rate, payoff_ratio) {
q <- 1 - win_rate
(win_rate * payoff_ratio - q) / payoff_ratio
}
f_star <- kelly_fraction(win_rate, payoff_ratio)
cat(sprintf("Full Kelly fraction: %.4f (%.1f%% of equity per trade)\n",
f_star, f_star * 100))To convert that fraction into an MT5 lot size for a given account balance and instrument:
lot_size_from_kelly <- function(balance_usd, kelly_f, pip_value, stop_loss_pips) {
# kelly_f is fraction of equity to risk
# pip_value in USD per standard lot
risk_usd <- balance_usd * kelly_f
lots <- risk_usd / (stop_loss_pips * pip_value)
round(lots, 2)
}
# Example: $10,000 account, EURUSD, 10 pip SL, pip value = $10/lot
lot_size_from_kelly(
balance_usd = 10000,
kelly_f = f_star,
pip_value = 10,
stop_loss_pips = 10
)This gives a concrete lot size you can drop into an Expert Advisor or a manual order.
Why Full Kelly Is Almost Always Too Aggressive
The Kelly fraction is extremely sensitive to the input estimates. A win rate of 55% yields a Kelly fraction well above zero. A win rate of 49%, estimated from a different 300-trade sample of the same strategy, produces a negative Kelly fraction: do not trade. That gap between estimates is entirely plausible due to sampling noise.
This is the core problem. Full Kelly sized to your point estimate of p and b is almost always too aggressive, because your estimates contain error. The resulting over-sizing produces severe drawdowns that compound faster than your edge can recover them.
Bootstrap confidence intervals make this concrete. You can resample your trade history to see how much the Kelly fraction bounces around:
library(rsample)
library(purrr)
bootstrap_kelly <- function(trades, n_boot = 2000) {
boot_samples <- bootstraps(trades, times = n_boot)
map_dbl(boot_samples$splits, function(split) {
d <- analysis(split)
wins <- d |> filter(profit > 0)
losses <- d |> filter(profit < 0)
if (nrow(wins) == 0 || nrow(losses) == 0) return(NA_real_)
p_b <- nrow(wins) / nrow(d)
b_b <- mean(wins$profit) / abs(mean(losses$profit))
kelly_fraction(p_b, b_b)
}) |>
na.omit()
}
boot_kelly <- bootstrap_kelly(trades)
cat(sprintf(
"Kelly fraction: point est = %.3f | 5th pct = %.3f | 95th pct = %.3f\n",
f_star,
quantile(boot_kelly, 0.05),
quantile(boot_kelly, 0.95)
))
# Plot the bootstrap distribution
hist(boot_kelly,
breaks = 50,
main = "Bootstrap Distribution of Kelly Fraction",
xlab = "Kelly f*",
col = "#4E79A7",
border = "white")
abline(v = f_star, col = "red", lwd = 2, lty = 2)
abline(v = quantile(boot_kelly, c(0.05, 0.95)), col = "orange", lwd = 1.5, lty = 3)When a strategy has 300 trades, the 90% bootstrap interval for the Kelly fraction typically spans 15 percentage points or more. Sizing to the upper end of that range is ruin territory. This is why practitioners universally apply a fractional Kelly.
See the related discussion on overfitting and estimation bias in our post on combinatorial purged cross-validation, which applies the same resampling logic to strategy selection.
Half-Kelly and Fractional Kelly in Practice
The solution is to trade a fixed fraction of the Kelly recommendation. Half-Kelly (f = 0.5 * f*) is the most common choice, and it has attractive theoretical properties: it retains roughly 75% of the full Kelly growth rate while halving the variance of outcomes and reducing the severity of drawdowns substantially.
compare_sizing_methods <- function(trades, account_size = 10000,
pip_value = 10, stop_loss_pips = 10) {
wins <- trades |> filter(profit > 0)
losses <- trades |> filter(profit < 0)
p_est <- nrow(wins) / nrow(trades)
b_est <- mean(wins$profit) / abs(mean(losses$profit))
f_k <- kelly_fraction(p_est, b_est)
methods <- tibble(
method = c("Full Kelly", "Half Kelly", "Fixed 2%", "Fixed 1%"),
fraction = c(f_k, f_k / 2, 0.02, 0.01),
lots = map_dbl(fraction, \(f)
lot_size_from_kelly(account_size, f, pip_value, stop_loss_pips))
)
methods
}
sizing_table <- compare_sizing_methods(trades)
print(sizing_table)The fixed 1% and 2% rules are simple and widely used, including by many prop firms and retail brokers operating in East Africa. They work, but they are arbitrary. If your strategy has a Kelly fraction of 4%, the 1% rule is overly conservative and leaves growth on the table. If your Kelly fraction is 1%, the 2% rule is over-leveraged.
Fractional Kelly anchors your sizing to your actual edge rather than an arbitrary number.
Simulating Growth Trajectories
Simulating forward shows the practical difference between methods. The following code runs a Monte Carlo on your estimated trade distribution:
simulate_equity <- function(trades, fraction, n_trades = 500,
n_sims = 200, start_equity = 10000) {
# Normalise profits to fraction-of-equity units
wins_pct <- trades |> filter(profit > 0) |> pull(profit) / start_equity
losses_pct <- trades |> filter(profit < 0) |> pull(profit) / start_equity
replicate(n_sims, {
equity <- start_equity
for (i in seq_len(n_trades)) {
if (runif(1) < win_rate) {
r <- sample(wins_pct, 1)
} else {
r <- sample(losses_pct, 1)
}
equity <- equity * (1 + fraction * sign(r) * abs(r) /
(abs(trades$profit |> mean())))
if (equity < 0) { equity <- 0; break }
}
equity
})
}
set.seed(42)
results <- list(
full_kelly = simulate_equity(trades, f_star),
half_kelly = simulate_equity(trades, f_star / 2),
fixed_2pct = simulate_equity(trades, 0.02),
fixed_1pct = simulate_equity(trades, 0.01)
)
# Median terminal equity per method
sapply(results, median)Across typical East African retail forex strategy parameters (win rates 50-58%, payoff ratios 1.2-1.8), half-Kelly consistently outperforms fixed rules on both median growth and ruin rate over 500-trade horizons. Full Kelly produces the highest median but with dramatically fatter left tails.
This connects to the broader framework for evaluating whether your strategy has genuine edge in the first place, covered in our Deflated Sharpe Ratio post. Position sizing on a false edge accelerates ruin rather than growth.
Live MT5 Integration
For traders running Expert Advisors on MetaTrader 5, the Kelly lot calculation needs to happen at order submission time, using current account equity rather than initial capital. A clean pattern:
# Run this from a Plumber API endpoint called by the EA before order submission
# See our MT5-R bridge architecture in the systematic trading post
current_equity <- 12500 # fetched from MT5 account info
kelly_lots <- lot_size_from_kelly(
balance_usd = current_equity,
kelly_f = f_star / 2, # half-Kelly
pip_value = 10,
stop_loss_pips = 15 # from strategy signal
)
# Return to EA
list(recommended_lots = kelly_lots, method = "half-kelly", fraction = f_star / 2)This pattern updates position sizing dynamically as account equity changes, which is exactly what Kelly requires. A growing account takes larger positions; a shrinking account cuts back automatically. For a deeper look at building R-based trading infrastructure that connects to MT5, see Systematic Trading Infrastructure in R.
The Bottom Line
Kelly sizing is not a silver bullet. It requires honest estimates of your edge, bootstrap intervals to understand estimation uncertainty, and the discipline to apply fractional Kelly rather than the full formula. Applied properly, it gives you a principled answer to the question that matters most in long-run trading: not which direction to trade, but how much.
The traders on Nairobi’s retail forex desks who run consistent edge lose that edge to poor sizing more often than to poor signals. If you have a backtested strategy with documented win rate and payoff data, run the numbers. A 15-minute R script will tell you whether your current sizing is leaving growth on the table or pushing you toward ruin.
What does your bootstrap Kelly interval look like? If you share your trade log, the Kwiz Quants team can run a full sizing analysis as part of our strategy evaluation service.