Social media is replete with examples of people showing how much money they made going all-in on Bitcoin, Tesla, Gamestop, Dogecoin or whatever new fad is out there. These are the lucky ones. Most people bet too large and eventually blow up their account, losing years of hard work and wealth in the process.

If you’re going to make an investment, how much should you put into it? Putting everything into it is going to be taking on a lot of risk (not to mention the stress you’ll be under knowing everything could be taken away) and putting in 0.0001% of your account is likely too little to make any meaningful impact.

Position sizing, e.g. how much to bet on each trade, is an important topic for controlling risk while also trying to maximize your returns. We’re going to explore it with a few different strategies and a simplified, binary example, with code, of course.

Place your Bets!

In essence, every trade or investment is a bet. You’re putting money down on stock X because you think there’s a decent probability you’ll be able to sell it at a higher price sometime in the future. We never really know what those probabilities are — although with a good backtest, we can come up with some estimates — so most people just go by their gut, or some standard, one-size-fits-all formula.

Let’s imagine we have a simple game where we can guess heads or tails on a coin toss. The thing is, this coin isn’t fair. It has a 52% chance of hitting heads (where we win) and a 48% chance of coming up tails (we lose). If we win a dollar for every dollar we bet, how much should we bet each time?

Position Sizing Strategies

We’re going to explore 4 position sizing strategies in this simple game: fixed wager, constant portfolio fraction, Martingale, and the Kelly Criterion.

For a fixed wager bet, we’re going to bet the same dollar amount every time up. Many new traders employee this strategy by only betting up to a fixed limit (e.g. $100) to control their losses and emotions.

Under the constant portfolio fraction policy, we’ll bet the same percentage of our capital every time. This is a common strategy, even by very experienced traders. For example, it’s common to limit each trade to 2% of one’s trading capital.

The Martingale betting system works where we bet a fixed amount unless we lose. If we lose, then we double our bet until we win. So we start with a $1 bet and if we win, then we bet $1 again. If we lose, then we bet $2 to try to get our account back up. If we lose again, then it’s $4, $8, $16, and so forth until we either win or go bust.

The Kelly Criterion is another betting strategy, originally devised for betting on horse races, which maximizes the expected value of your wealth over time. It is calculated by taking the probability of a win (52% in our case) and subtracting the probability of a loss (48%) divided by the gross odds of winning minus 1. So if someone gives you 1:1 odds (like in our example) your gross odds are 2 because you win $1 and you get your $1 back ($2 for every $1 bet). If you win $0.50 for a $1 bet, then your gross odds are 1.5 ($1.50 for every $1 bet). We can write the formula as:

$$f^* = p - \frac{q}{b-1}$$

where f* is the optimal fraction of our portfolio to bet, p is the probability of winning, q is the probability of losing (1−p), and b are our gross odds. To determine our bet size, we just multiply f* by our capital and we have the result!

We’ll run a Monte Carlo simulation with these four models to see how position sizing impacts our results.

Simulating Position Sizing Strategies in Python

Start by importing some Python packages.

import numpy as np
import matplotlib.pyplot as plt
from copy import copy

The next step is going to require a simple betting game class. It will return 1 if we win and -1 if we lose. I’ll set the win_odds at 52% which means we have a slight edge in this game.

class BettingGame:

  def __init__(self, win_odds=0.52):
    self.win_odds = win_odds

  def play(self):
    return np.random.choice([-1, 1], p=[1-self.win_odds,
      self.win_odds])

Now we can write four classes, one for each strategy, that will inherit our BettingGame class.

These classes are quite simple. Each will have a bet() method where they implement their betting policy and reset() to delete the data they've acquired so they can give it another go. We also have it set up so that if at any point the strategy's starting capital goes to 0 or below, then this strategy is bankrupt and can't make any more bets.

# Fixed wager, constant fraction, martingale, kelly criterion

class FixedWagerStrategy(BettingGame):

  def __init__(self, capital=10, wager=1, win_odds=0.52):
    super().__init__()
    self.init_capital = capital
    self.wager = 1
    self.win_odds = win_odds
    self.reset()

  def bet(self):
    if self.bankrupt:
      self.capital.append(0)
    else:
      size = self.wager
    result = self.play()
    self._capital += result * size
    self.capital.append(self._capital)
  if self._capital <= 0:
    self.bankrupt = True

  def reset(self):
    self._capital = self.init_capital
    self.capital = [self._capital]
    self.bankrupt = False


class PortfolioFractionStrategy(BettingGame):

  def __init__(self, capital=10, fraction=0.02, win_odds=0.52):
    super().__init__()
    self.init_capital = capital
    self.fraction = fraction
    self.win_odds = win_odds
    self.reset()

  def bet(self):
    if self.bankrupt:
      self.capital.append(0)
    else:
      size = self.fraction * self._capital

    result = self.play()
    self._capital += result * size
    self.capital.append(self._capital)

    if self._capital <= 0:
      self.bankrupt = True
  
  def reset(self):
    self._capital = self.init_capital
    self.capital = [self._capital]
    self.bankrupt = False


class MartingaleStrategy(BettingGame):

  def __init__(self, capital=10, win_odds=0.52):
    super().__init__()
    self.init_capital = capital
    self.win_odds = win_odds
    self.reset()
  
  def bet(self):
    if self.bankrupt:
      self.capital.append(0)
    else:
      if self.win:
        self.size = 1
      else:
        self.size *= 2

    result = self.play()
    self._capital += result * self.size
    self.win = True if result == 1 else False
    self.capital.append(self._capital)

    if self._capital <= 0:
      self.bankrupt = True
  
  def reset(self):
    self._capital = self.init_capital
    self.capital = [self._capital]
    self.win = True
    self.bankrupt = False
    self.size = 1


class KellyStrategy(BettingGame):

  def __init__(self, capital=10, win_odds=0.52):
    super().__init__()
    self.init_capital = capital
    self.win_odds = win_odds
    self.reset()

  def bet(self):
    if self.bankrupt:
      self.capital.append(0)
    else:
      f_star = 2 * self.win_odds - 1

    self.size = self._capital * f_star
    result = self.play()
    self._capital += result * self.size
    self.capital.append(self._capital)

    if self._capital <= 0:
      self.bankrupt = True
  
  def reset(self):
    self._capital = self.init_capital
    self.capital = [self._capital]
    self.bankrupt = False

We’ll start with $10 and toss 1,000 coins. The fixed wager strategy is going to bet $1 every time. The constant portfolio fraction strategy is going to bet 2%, and the Martingale and Kelly strategies are going to operate according to their unique rules.

colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
np.random.seed(10)

FW = FixedWagerStrategy(win_odds=0.52)
PF = PortfolioFractionStrategy(win_odds=0.52)
MG = MartingaleStrategy(win_odds=0.52)
KC = KellyStrategy(win_odds=0.52)

for i in range(1000):
  FW.bet()
  MG.bet()
  KC.bet()
  PF.bet()

plt.figure(figsize=(20, 8))
plt.plot(FW.capital, label='Fixed Wager')
plt.plot(PF.capital, label='Constant Fraction')
plt.plot(MG.capital, label='Martingale')
plt.plot(KC.capital, label='Kelly')
plt.xlabel('Number of Bets')
plt.ylabel('Account Size ($)')
plt.legend()
plt.show()
money-management-comp1.png

In this run we see the martingale strategy takes off quickly, but then goes broke shortly after 200 tosses. This is an issue with Martingale strategies, while they can compound very quickly, they’re also subject to frequent, catastrophic losses, even when the odds are in your favor.

For this round, the fixed wager strategy turned out to be best followed by Kelly and the constant fraction betting systems.

Now this is just a single trial, so we can’t draw many conclusions from these results. In the code below, we’re going to run 1,000 trials of 1,000 tosses each to get a better understanding of the probabilities at play.

FW = FixedWagerStrategy(win_odds=0.52)
PF = PortfolioFractionStrategy(win_odds=0.52)
MG = MartingaleStrategy(win_odds=0.52)
KC = KellyStrategy(win_odds=0.52)

kc_runs = None
fw_runs = None
pf_runs = None
mg_runs = None

for i in range(1000):
  FW.reset()
  PF.reset()
  KC.reset()
  MG.reset()
  for j in range(1000):
    FW.bet()
    PF.bet()
    MG.bet()
    KC.bet()

  if pf_runs is None:
    pf_runs = np.array(copy(PF.capital))
  else:
    pf_runs = np.vstack([pf_runs, copy(PF.capital)])

  if kc_runs is None:
    kc_runs = np.array(copy(KC.capital))
  else:
    kc_runs = np.vstack([kc_runs, copy(KC.capital)])

  if fw_runs is None:
    fw_runs = np.array(copy(FW.capital))
  else:
    fw_runs = np.vstack([fw_runs, copy(FW.capital)])

  if mg_runs is None:
    mg_runs = np.array(copy(MG.capital))
  else:
    mg_runs = np.vstack([mg_runs, copy(MG.capital)])

plt.figure(figsize=(20, 8))
plt.plot(fw_runs.mean(axis=0), label='Fixed Wager')
plt.plot(pf_runs.mean(axis=0), label='Constant Fraction')
plt.plot(mg_runs.mean(axis=0), label='Martingale')
plt.plot(kc_runs.mean(axis=0), label='Kelly')
plt.xlabel('Number of Bets')
plt.ylabel('Account Size ($)')
plt.title('Mean Returns for Various Money Management Strategies')
plt.legend()
plt.show()
money-management-mean-comp1.png

After our 1,000 trials, the mean of the Martingale system outperforms all others. It does about 35% better than the Kelly Criterion strategy, over 60% better than the fixed wager strategy and over 150% better the constant fraction strategy.

But that doesn’t settle it. As we saw above, the Martingale model went bust over time. We can see how many of our simulations ended in bankruptcy:

# Percentage of runs that went bankrupt
fw_bankrupt = (fw_runs <= 0).any(axis=1).sum() / fw_runs.shape[0] * 100
pf_bankrupt = (pf_runs <= 0).any(axis=1).sum() / pf_runs.shape[0] * 100
kc_bankrupt = (kc_runs <= 0).any(axis=1).sum() / kc_runs.shape[0] * 100
mg_bankrupt = (mg_runs <= 0).any(axis=1).sum()/ mg_runs.shape[0] * 100

print("Percentage of Simulations that Ended in Bankruptcy:")
print(f"Martingale:\t\t{mg_bankrupt:.1f}%")
print(f"Fixed Wager:\t\t{fw_bankrupt:.1f}%")
print(f"Kelly Criterion:\t{kc_bankrupt:.1f}%")
print(f"Constant Fraction:\t{pf_bankrupt:.1f}%")
Percentage of Simulations that Ended in Bankruptcy
Martingale:                89.2%
Fixed Wager:            44.8%
Kelly Criterion:          0.0%
Constant Fraction:    0.0%

Looking at the bankruptcy totals, we see some of what the mean returns are hiding: ~90% of the Martingale bettors blow up. Surprisingly, 45% of the fixed wager bettors do too.

Let that sink in for a moment - even in games where you have a known edge, the fixed wager bettors go bankrupt nearly 45% of the time. Not just lose money, but lose it all.

Because Kelly and the constant fraction methods scale the size according to the capital on hand, they never get to a point where they blow up.

Let's slice this data another way by looking at this is by using the median returns rather than the mean in order to see how these models performed. We'll also plot the 10th and 90th percentiles, which will cover 80% of our outcomes in between them to give a feel for what these strategies look like for the majority of participants.

# Get 90th and 10th percentiles
kc_p90 = np.quantile(kc_runs, q=0.9, axis=0)
kc_p10 = np.quantile(kc_runs, q=0.1, axis=0)
mg_p90 = np.quantile(mg_runs, q=0.9, axis=0)
mg_p10 = np.quantile(mg_runs, q=0.1, axis=0)
fw_p90 = np.quantile(fw_runs, q=0.9, axis=0)
fw_p10 = np.quantile(fw_runs, q=0.1, axis=0)
pf_p90 = np.quantile(pf_runs, q=0.9, axis=0)
pf_p10 = np.quantile(pf_runs, q=0.1, axis=0)
x = np.arange(len(kc_p90))

plt.figure(figsize=(20, 8))
plt.plot(np.median(fw_runs, axis=0), label='Median Fixed Wager', c=colors[0])
plt.plot(np.median(pf_runs, axis=0), label='Median Constant Portfolio Fraction', c=colors[1])
plt.plot(np.median(mg_runs, axis=0), label='Median Martingale', c=colors[2])
plt.plot(np.median(kc_runs, axis=0), label='Median Kelly', c=colors[4])
plt.plot(fw_p90, c=colors[0], linewidth=0.1)
plt.plot(fw_p10, c=colors[0], linewidth=0.1)
plt.plot(pf_p90, c=colors[1], linewidth=0.1)
plt.plot(pf_p90, c=colors[1], linewidth=0.1)
plt.plot(mg_p90, c=colors[2], linewidth=0.1)
plt.plot(mg_p90, c=colors[2], linewidth=0.1)
plt.plot(kc_p90, c=colors[4], linewidth=0.1)
plt.plot(kc_p90, c=colors[4], linewidth=0.1)
plt.fill_between(x, fw_p90, fw_p10, alpha=0.3, color=colors[0])
plt.fill_between(x, pf_p90, pf_p10, alpha=0.3, color=colors[1])
plt.fill_between(x, mg_p90, mg_p10, alpha=0.3, color=colors[2])
plt.fill_between(x, kc_p90, kc_p10, alpha=0.3, color=colors[4])
plt.xlabel('Number of Bets')
plt.ylabel('Account Size ($)')
plt.title('Median, 90th and 10th Percentile Returns for Various Money Management Strategies')
plt.ylim([-10, 100])
plt.legend()
plt.show()
money-management-median-comp1.png

In the plot above, I showed the median along with the 90th and 10th percentile of returns. The Martingale strategy got lopped off because you can see it grows rapidly, squeezing everything else down. The lucky 10% that survive a Martingale strategy make tremendous returns, however everyone else goes bankrupt quickly.

The fixed wager strategy has a few that perform well, and, at least the majority of those playing this strategy make it to the end.

The Kelly and constant fraction perform similarly, however the Kelly strategy has a higher median (and mean) as well as a much higher top percentile implying that the Kelly strategy is better than the constant fraction method for this game and these settings.

We can view these results in a histogram to get another view of the differences.

fig, ax = plt.subplots(2, 2, figsize=(10, 10))
ax[0, 0].hist(fw_runs[:, -1], color=colors[0], bins=50)
ax[0, 0].axvline(fw_runs[:, -1].mean(), color='k')
ax[0, 0].axvline(np.median(fw_runs[:, -1]), color='g')
ax[0, 0].annotate(f'Mean = {fw_runs[:, -1].mean():.1f}', xy=(85, 400))
ax[0, 0].annotate(f'Median = {np.median(fw_runs[:, -1]):.1f}', xy=(85, 370))
ax[0, 0].annotate(f'Std Dev = {fw_runs[:, -1].std():.1f}', xy=(85, 340))
ax[0, 0].set_ylabel('Frequency')
ax[0, 0].set_title('Fixed Wager')

ax[0, 1].hist(pf_runs[:, -1], color=colors[1], bins=50)
ax[0, 1].axvline(pf_runs[:, -1].mean(), color='k', label='Mean')
ax[0, 1].axvline(np.median(pf_runs[:, -1]), color='g', label='Median')
ax[0, 1].annotate(f'Mean = {pf_runs[:, -1].mean():.1f}', xy=(85, 100))
ax[0, 1].annotate(f'Median = {np.median(pf_runs[:, -1]):.1f}', xy=(85, 90))
ax[0, 1].annotate(f'Std Dev = {pf_runs[:, -1].std():.1f}', xy=(85, 80))
ax[0, 1].set_title('Constant Portfolio Fraction')
ax[0, 1].legend()

ax[1, 0].hist(mg_runs[:, -1], color=colors[2], bins=50)
ax[1, 0].axvline(mg_runs[:, -1].mean(), color='k')
ax[1, 0].axvline(np.median(mg_runs[:, -1]), color='g')
ax[1, 0].annotate(f'Mean = {mg_runs[:, -1].mean():.1f}', xy=(300, 720))
ax[1, 0].annotate(f'Median = {np.median(mg_runs[:, -1]):.1f}', xy=(300, 660))
ax[1, 0].annotate(f'Std Dev = {mg_runs[:, -1].std():.1f}', xy=(300, 600))
ax[1, 0].set_xlabel('Account Size ($)')
ax[1, 0].set_ylabel('Frequency')
ax[1, 0].set_title('Martingale')

ax[1, 1].hist(kc_runs[:, -1], color=colors[4], bins=50)
ax[1, 1].axvline(kc_runs[:, -1].mean(), color='k')
ax[1, 1].axvline(np.median(kc_runs[:, -1]), color='g')
ax[1, 1].annotate(f'Mean = {kc_runs[:, -1].mean():.1f}', xy=(385, 300))
ax[1, 1].annotate(f'Median = {np.median(kc_runs[:, -1]):.1f}', xy=(385, 275))
ax[1, 1].annotate(f'Std Dev = {kc_runs[:, -1].std():.1f}', xy=(385, 250))
ax[1, 1].set_xlabel('Account Size ($)')
ax[1, 1].set_title('Kelly Criterion')

plt.tight_layout()
plt.show()
money-management-hist-comp1.png

Here we can see the skew of these different money management strategies. The fixed wager strategy has a large number of bankruptcies, but the survivors look somewhat normally distributed. The Martingale model leaves a very lucky (and wealthy) few on the right hand side, but as previously discussed, most end up in bankruptcy.

Most interesting are the constant fraction and Kelly methods. For the first, we’re always betting 2% of our account, for Kelly, we’re always betting 4% of our account (f*=2×0.52−1=0.04). The constant fraction bettors are actually engaging in half-Kelly bets, which is another common strategy to adopt. Simply doubling our bets greatly increased the variance of our results, primarily to the right hand side.

We can see this effect clearly by plotting the performance percentiles and final account size for both strategies.

plt.figure(figsize=(12, 8))
for i in range(10):
  q = (i+1) / 10
  if q == 1:
    q = 0.99
  kcq = np.quantile(kc_runs[:, -1], q=q)
  pfq = np.quantile(pf_runs[:, -1], q=q)

plt.scatter(q*100, kcq, c=colors[4], s=500)
plt.scatter(q*100, pfq, c=colors[1], marker='*', s=500)
plt.xlabel('Percentile')
plt.ylabel('Final Account Size ($)')
plt.legend(labels=['Kelly Criterion', 'Constant Fraction'])
plt.show()
money-management-kc-comp1.png

On the left-hand side, we see the 10th percentile (or bottom 10%) for each strategy, and towards the right, we have the luckier cohorts culminating in the 99th percentile. The unluckiest Kelly Criterion bettors wind up with final accounts of $4.49 vs $8.19 for the half-Kelly bettors for a 45% difference. Out-performance of the constant fraction crowd only occurs for the bottom 30% of cases at which point it flips to be decidedly on the full-Kelly betting side. Not only does it favor our Kelly strategy, but the luckiest Kelly bettors are much, much luckier than the luckiest half-Kelly bettors.

And again, all we did was use a simple formula to change our bet size from 2% of our account to 4%.

Playing the Odds

As stated before, the Kelly Criterion provides the optimal money management strategy to maximize your expected, long term winnings. It’s easy to implement and illustrate in a simple, binary-outcome model like this, but much more difficult to use in trading. For one, when you put on a trade, you don’t have just two outcomes, you have a wide range of outcomes and prices that you could get. If you’re going to apply it to a portfolio, then the multi-asset Kelly Criterion requires solving a quadratic programming problem. Another problem crops up because you don’t often know the odds of each trade.

This last problem is particularly difficult because it can lead to over/under betting. If we’re trying to avoid catastrophic losses to the downside, it’s better to err on the side of caution. For this reason, many traders size using half-Kelly rather than full-Kelly. We can illustrate this below by running our model with a few different levels.

kc1 = PortfolioFractionStrategy(fraction=0.06) # Set to 1.5 kelly
kc2 = PortfolioFractionStrategy(fraction=0.08) # Set to double kelly
kc2_runs, kc1_runs = None, None

for i in range(1000):
  kc1.reset()
  kc2.reset()
  for j in range(1000):
    kc1.bet()
    kc2.bet()
    if kc1_runs is None:
      kc1_runs = np.array(kc1.capital)
    else:
      kc1_runs = np.vstack([kc1_runs, kc1.capital])
    if kc2_runs is None:
      kc2_runs = np.array(kc2.capital)
    else:
      kc2_runs = np.vstack([kc2_runs, kc2.capital])

fig, ax = plt.subplots(2, figsize=(15, 10))
for i in range(10):
  q = (i+1) / 10
  if q == 1:
    q = 0.99
  kcq = np.quantile(kc_runs[:, -1], q=q)
  pfq = np.quantile(pf_runs[:, -1], q=q)
  kc1q = np.quantile(kc1_runs[:, -1], q=q)
  kc2q = np.quantile(kc2_runs[:, -1], q=q)
  ax[0].scatter(q*100, kcq/10, c=colors[4], s=500)
  ax[0].scatter(q*100, pfq/10, c=colors[1], marker='*', s=500)
  ax[0].scatter(q*100, kc1q/10, c=colors[0], marker='p', s=500)
  ax[0].scatter(q*100, kc2q/10, c=colors[2], marker='s', s=500)
 
ax[0].set_xlabel('Percentile')
ax[0].set_ylabel('Log Final Account Size ($)')
ax[0].set_title('Percentile Performance for Kelly Strategies')
ax[0].legend(labels=['Full-Kelly', 'Half-Kelly', '1.5-Kelly',
  'Double-Kelly'])
ax[0].semilogy()
ax[1].plot(kc_runs.mean(axis=0))
ax[1].plot(pf_runs.mean(axis=0))
ax[1].plot(kc1_runs.mean(axis=0))
ax[1].plot(kc2_runs.mean(axis=0))
ax[1].set_ylabel('Account Size ($)')
ax[1].set_xlabel('Number of Bets')
ax[1].set_title('Mean Account Size')
ax[1].legend(labels=['Full-Kelly', 'Half-Kelly', '1.5-Kelly',
  'Double-Kelly'])

plt.tight_layout()
plt.show()
money-management-kc-comp2.png

Moving beyond full-Kelly increases our mean returns, but it does harm our median case — where most people will wind up. Moreover, it also exposes us to worse outcomes on the downside, even if it does provide higher upside.

Money Management Matters

Position sizing is just one aspect of your risk management strategy, albeit an important one. Even with this simple, binary wager case, we can see some of the complexities involved in choosing different position sizing strategies.

There are other strategies that are open to us, of course, particularly in the context of a full trading system. Some traders like to size their position based on volatility, average true range (ATR), or just maintain a fixed position size. There’s more than just maximizing returns that need to be considered to be effective, such as your ability to stay the course when trading. If you’re taking on too much risk, you aren’t going to be able to stick to your plan when things get tough, so find a system that works for you, one that tests well and that enables you to sleep well.

In the future, we’ll roll out full explanations and examples of these different strategies in the context of a backtest, as well as Kelly Criterion examples for a portfolio. Don’t forget to follow to be alerted when new articles are released!