While working at a prestigious mutual fund, I had the privilege of accessing the portfolios of other managers. I ran my algorithms to try and help them with a seller's perspective. My unbridled ambition was to help my colleagues improve their performance by a whopping 0.01%. While it does not look like much, 1 basis point even on every other week compounded over a year would be enough to lift a ranking from the second quartile to the rarefied atmosphere of top-decile performers.
It quickly became apparent that the same stocks kept popping up across all portfolios. Put smart, passionate people together in a collegial atmosphere, and healthy cross-pollination naturally ensues.
What was more intriguing was the disparity in performance despite the low dispersion in holdings. One would expect similar holdings to generate similar performance. There were, however, differences in both performance and tracking errors—that is, the volatility of returns versus the benchmark.
Since the same issues were present in most portfolios, stock picking was clearly not the primary driver of performance and volatility. The difference that really made the difference was position sizing. Money is made in the money management module.
At the end of the day, the primary determinant of long-term geometric returns is position sizing. The expanding nature of longs does not compel market participants to think about position sizing. After all, one good Apple (AAPL) can make a basket of rotten apples look good. They can survive despite bad position-sizing algorithms. Short sellers do not have the luxury of logarithmic price declines.
The objective of this chapter is to show the impact of position sizing on the equity curve and drawdowns. We will simulate various position sizing algorithms across the automotive stocks' universe. Everything else will stay the same. We will use the exact same signals on the exact same investment universe with the exact same starting capital. We will not even try to manage gross and net exposures. In execution trader English, we will not impose limits on how big or small, how bullish or bearish the portfolio gets. One warning though: the code contains lookahead bias. We will show you where it appears and how to correct it. Without the lookahead bias, returns would be underwhelming. We would drift on a tangent about how to optimize the strategy, generate more consistent returns instead of focusing on the core subject: position sizing. So, we made the conscious choice to keep the lookahead bias and show good returns. As such, we can focus on the impact of position sizing on the equity curve.
But first, we will talk about popular bad position sizing algorithms. Those are the four horsemen of apocalyptic position sizing. We will explain the symbiotic relationship between financial and emotional capital. We will then recycle the data from the previous chapters. This chapter will follow along with a Jupyter Notebook.
In this chapter, we will cover the following topics:
You can access color versions of all images in this chapter via the following link: .
You can also access the source code for this chapter via the book's GitHub repository. To access the repository link, follow the steps in the Download the example code files section in the Preface.
Let us look at four damaging money management techniques currently embraced by the industry.
"Only listen to advice from people you want to look like."
– Gilbert Bernut, father, superhero, unsung 20th-century philosopher
The topic of position sizing is like gears in a car. Everyone knows it is somewhat important, but it is not appealing. The module that will generate money is also the most boring part of a strategy. Everyone wants to shout out to the world that they shorted Disney or Harley Davidson after their poor strategic choices. Yet, no one will admit they did not have the courage to put on big positions. Consequently, the discipline of money management has been relegated to a distant afterthought. This explains why some disastrous position sizing algorithms endure. Let's go through four of those bad position sizing algorithms.
"You can check out anytime you want, but you can never leave."
– Don Henley, Hotel California
Large positions in illiquid stocks have sunk many a talented fund manager. Small caps and illiquid stocks are like boats and luxury toys. You can buy anytime you want, but you can never leave. If you can't get out of a position without damaging market impact, you don't own anything. It owns you.
Liquidity is the currency of bear markets. When the "redemption song" starts playing, managers are forced to liquidate whatever they can, not necessarily what they would like to.
This leaves them with illiquid debris, which perpetuates the vicious cycle of redemption. Small caps are particularly vulnerable in bear markets. Liquidity evaporates, the bid/ask spread widens. Those who tried to cash out of illiquid assets during the Great Financial Crisis (GFC) quickly realized that the bid/ask spread was wide enough to dock a supertanker without a scratch.
In 2007, some large multi-strategy hedge funds could not liquidate their Collateralized Debt Obligations and Credit Default Swaps (CDOs and CDS). They were forced to liquidate their stock portfolios to meet margin requirements. This precipitated the drops in equity markets. This in turn reduced the market values of portfolios, which triggered additional margin calls. That initial liquidity crisis in the subprime market contaminated the rest of the capital markets.
A side note on capacity. One of the first questions from investors is about capacity: how many assets a manager can take on before returns start to dwindle. Investors know that the strongest returns happen in the earlier years, when funds are small and managers hungry. Yet, they want to see some track record before pulling the trigger. So, they wait, and see as much as possible, but not too much to make sure they will still enjoy some decent returns. More than a mathematical approximation, a real-life acid test is inertia. When managers start to pass up trades because of market impact, they have reached their saturation point. As my mentor June-Yon Kim always said: "capacity sets in when inertia creeps in." If you find yourself passing up signals because of inertia, and this seems like the right thing to do, either you are lazy, or your asset size is too big. Either way, it is a wake-up call.
"Losers average losers."
– Paul Tudor Jones
Averaging down is still a popular method among fundamental market participants. As prices come down, valuations look cheaper, so they add to their existing loss-making positions. Adding to a losing position worsens three of the four variables in the trading edge formula: loss rate and average loss increase, win rate goes down. The only variable that remains unaffected is average win, something which no one has any beginning of control over anyhow. Moreover, additional capital allocated to losers has to come from somewhere. It either comes from fresh infusions of cash or profit-making positions cut short. In summary, averaging down can be summarized as: "cut your winners, run your losers." Wasn't the secret to success: "cut your losers, run your winners"?
Averaging down is known as a martingale. Every rookie gambler has come up with a variation of the same theme, a system supposed to break the casino. Double your bet size after each loss, and you will eventually get your money back.
First, this strategy ignores the theory of runs. Over the long run, coin tosses have a 50% probability. Yet, it is not a neat succession of heads and tails. Every turn is independent of the previous one. Sometimes heads can come up 8, 9, or 10 times in a row. This presupposes infinite capital. Secondly, the most favorable outcome is breakeven. This means that any outcome before that has an interesting statistical property called certainty of ruin. Bottom line, there is a reason why casinos have marble, paintings, and free booze, and rookie gamblers end up blowing up their capital.
If averaging down is demonstrably statistically bankrupt, then why is it still so popular among professional investors? In his fascinating autobiography A Man for All Markets, Edward Thorp describes averaging down as anchoring bias. Market participants anchor an assumption about the value of a stock at the moment they enter a position. Value is the subjective meaning they ascribe to valuation, or as Warren Buffett said: "Price is what you pay. Value is what you get." Subsequently, their judgment will always be colored by the initial cost. The logic goes as follows:
If a stock looked cheap when they first bought at $10, it must be a bargain at $9. Market participants assume that their analysis is right and the market is temporarily wrong. Adding another tranche before the market corrects this inefficiency could potentially lead to bigger profits. In theory, it makes sense.
In practice, "it's complicated." When bets are small, emotional involvement remains minimal. Adding another tranche increases the stress level. The brain suddenly jumps from a $1 buy-in to the high roller table. A whole slew of emotions hijack the thinking brain. Stakes are high now, and the ego cannot afford to be wrong. Unfortunately, the "need to be right" tends to supersede the obligation to be profitable.
Legendary investors emphasize the importance of risk management. They also talk about humility before the markets. Market wizards are synthetically long math and short ego. Meanwhile, partisans of the martingale follow a statistically bankrupt method: short math, long ego.
"I feel good!"
– James Brown, Godfather
Conviction is a position-sizing algorithm practiced by both the worst and the best investors. The worst investors develop an investment thesis. They then broadcast their conviction to the world with some "chutzpah" bet: "go big or go home."
Unfortunately, t-stat, a measure of statistical robustness, does not provide mental robustness. When market participants take on big bets, they lose impartiality. Their ego craves validation. High conviction is a "feel-good" bet size with no statistical validity. Sure enough, obvious trades that feel good are rarely the most profitable ones.
Now, the most successful investors also take big, high-conviction bets. The difference is they express conviction in units of risk. This means that they develop a thesis, quantify risk first, and then size positions accordingly. George Soros is famous for taking on massive bets. While everyone remembers the short pound trade, few people know about the Long-Term Capital Management (LTCM) story. According to legendary trader Victor Niederhoffer, who was working in Mr. Soros' shop at the time, he made a lot of traders' bonuses by cutting his losses during the LTCM debacle. Those are calculated units of risk.
Equal weight is a staple among fundamental stock pickers. They rely on their stock-picking ability to generate performance. All stock picks are roughly equally good, so they do not perceive the need to size positions differently.
Portfolio management is not an exercise in democracy. Equal opportunity will not bring about ruin, but it may prevent you from achieving your long-term objectives. Not all stocks have the same beta. Sleepy utilities do not have the same volatility signature as racy internet stocks. By ignoring beta at the position sizing level, volatility resurfaces at the portfolio level. Volatile stocks will drive the overall portfolio volatility. Since investors react to volatility, it is therefore advisable to size positions according to their volatility or Beta. In execution trader English, remember that if you give equal rights to your ideas, this will come with equal lefts to your equity curve.
This leads us down an interesting path. Is there an optimal position-sizing algorithm that institutional investors are aware of?
"This is a great experiment for many reasons. It ought to become part of the basic education of anyone interested in finance or gambling."
– Edward Thorp, a (super)man for all markets
Victor Haghani, founder of Elm Capital and former trader at LTCM, conducted an experiment on 61 volunteers, bright students in finance and sophisticated investment professionals. Participants were given $25 starting capital and were told to flip a virtual coin for 30 minutes, being told, "the coin is biased to come up heads with a 60% probability, and you can bet as much as you like on heads or tails on each flip." How much would you bet? It appears there is a formula to calculate the optimal bet size that would maximize long-term geometric returns. The Kelly criterion formula is:

Despite the pedigree and alleged sophistication of the participants, results were vastly underwhelming. Only 21% of the players hit the maximum cap. Despite odds of 60% in their favor, a whopping 28% of those fearless, sharp financial professionals managed to go bust!
Now, let's play the same game, but up the ante this time. Let's start with your entire lifetime savings. Last time, the expected return on a meager $25 was north of $3 million after 300 flips. Now, you know the odds and the formula. This time, betting your life savings should put you well ahead of Bill Gates, Warren Buffett, and Jeff Bezos.
If it is so easy, why does no one get that rich that quickly? There is a catch: losing streaks. Coin tosses have no memory. Every flip is independent from the previous one. Even though the long-term probability is 60%, there will be some streaks of consecutive losses in a row.
After 5 losses, only a third would remain. Will you still stick with the mathematically correct plan after half of your hard-earned life savings (or ill-acquired gains in the case of finance professionals) have been erased in less than 1 minute? More likely than not, at some point, your brain will say stop.
Here are four important lessons:
It is now time we dived into the code implementation. Let's start with importing libraries.
For this chapter and the rest of the book, we will be working with the pandas, numpy, yfinance, and matplotlib libraries. So, please remember to import them first:
import mathimport pandas as pdimport numpy as npimport pathlibimport arcticdb as adbfrom arcticdb import LibraryOptionsimport yfinance as yf%matplotlib inlineimport matplotlib.pyplot as plt Now, let's initialize the arctic database.
In this section, we will recycle the arctic database of the automotives stock we created in . Let's paste a few functions already defined in chapter 04 arcticdb setup.
def initialise_adb_library_local(uri_path, library_name): uri = f"lmdb://{uri_path}" # this will set up the storage using the local file system ac = adb.Arctic(uri) library = ac.get_library(library_name, create_if_missing=True,)# library_options=LibraryOptions(dynamic_schema=True)) return library def adb_concat_single_column(library, symbols, column_name): symbols_list = [symbol for symbol in symbols if symbol in library.list_symbols()] comp_list_from_adb = [library.read(symbol, columns=[column_name]).data.rename(columns={column_name: symbol}) for symbol in symbols_list] return pd.concat(comp_list_from_adb, axis=1) def rohlc(df,relative = False): if relative==True: rel = 'r' else: rel= '' if 'Open' in df.columns: _o,_h,_l,_c = f'{rel}Open',f'{rel}High',f'{rel}Low',f'{rel}Close' elif 'open' in df.columns: _o,_h,_l,_c = f'{rel}open',f'{rel}high',f'{rel}low',f'{rel}close' else: _o=_h=_l=_c= np.nan return _o,_h,_l,_c def remove_duplicates(df): return df.loc[:,~df.columns.duplicated(keep='last')] Let's briefly go over the functions:
initialise_adb_library_local(uri_path, library_name): arcticdb library. Sets up a local arcticdb library for storing and retrieving time-series dataURI for the LMDB storage using the provided uri_patharcticdb instance using the URIlibrary_name (if it doesn't already exist)adb_concat_single_column(library, symbols, column_name): arcticdb library into a single DataFramecolumn_name for each symbol and renames the column to the symbol's namerohlc(df, relative=False): r) to the column names if relative=Truenp.nan if not foundNext, let's download and process the data.
We will now download data from the autos arcticdb database we created in Chapter 3. This is a small investment universe size with multiple currencies, benchmarked to the S&P 500 index.
We use the signals generated on the relative series in USD. The postulate of this book is that a long/short portfolio is composed of two relative books: outperformers on the long side and underperformers on the short side. We generate signals on a relative basis. We still need to calculate round lots in local currency while managing exposures across multiple markets.
library_autos = initialise_adb_library_local('data', 'autos') tickers_list = list(library_autos.list_symbols()) tickers_fx_dict = {'7203.T': 'USDJPY', '7201.T': 'USDJPY', '1211.HK':'USDHKD', '005380.KS':'USDKRW', 'VOW3.DE' :'EURUSD', 'RNO.PA':'EURUSD', 'F':'local' ,'TSLA':'local' ,'GM':'local' } print('autos: ',len(library_autos.list_symbols()[:]), 'tickers_list:', tickers_list, end =', ') px_df = adb_concat_single_column(library_autos, library_autos.list_symbols(), 'Close').ffill() px_df_rel = adb_concat_single_column(library_autos, library_autos.list_symbols(), 'rClose').ffill() score_rel = adb_concat_single_column(library_autos, library_autos.list_symbols(), 'score_rel').ffill() look_ahead = True if look_ahead == False: score_rel = score_rel.shift(+1) # shift to avoid lookahead bias data = pd.concat([px_df.add_suffix('_abs'), px_df_rel.add_suffix('_rel'), score_rel.add_suffix('_signal')], axis=1) score_rel[-252:].plot(figsize=(15,3), grid = True) print("data shape:",data.shape, 'look_ahead:', look_ahead) This is the first time a lookahead bias appears. Before we go through the code, let's explain the rationale behind leaving the lookahead bias. In previous chapters, we did not try to optimize the signal by "massaging" the parameters or assigning different weights. It is therefore unrealistic to expect a robust money-making strategy from generic signals.
We could remove the lookahead bias, but then the attention would instinctively shift to "let's make the signal stronger" as opposed to "how does position sizing affect the equity curve?" This would be a distraction from the main message: with everything else being equal, how does position sizing impact the equity curve, drawdowns, exposures, and the overall risk profile?
Now, let's understand the elements seen in the code above:
arcticdb Library: library_autos = initialise_adb_library_local('data', 'autos'): Initializes a local arcticdb library named autos stored in the data directory.tickers_list = list(library_autos.list_symbols()): Retrieves a list of all symbols (tickers) stored in the autos library.tickers_fx_dict = {...}: Creates a dictionary mapping each ticker to its corresponding currency or exchange rate.px_df, px_df_rel, score_rel: Retrieve the Close, rClose' (relative close), score_rel (relative score) columns for all tickers and forward-fills missing values.px_df), relative prices (px_df_rel), and signals (score_rel) into a single DataFrame named datalook_ahead to Truelook_ahead is False, shift the score_rel df by +1 day to avoid lookahead biasscore_rel DataFrame to visualize the relative scores over time.This block of code produces the following chart:

Figure 6.1: Relative Signals in the Autos Universe
This graph looks busy with lines ranging anywhere from –1 to +1. This is a good indicator of a diversified portfolio.
Next, let's use the currency dictionary and convert the local currency to USD.
We already calculated the relative series in USD. What we need now are absolute prices converted into the fund currency. We will be using the prices converted to USD to calculate the number of round lots later.
This goes as follows:
for ticker in tickers_list: df = library_autos.read(ticker).data ccy = tickers_fx_dict[ticker] px_ccy = round(df['Close'].div(df[ccy]),4) data[f'{ticker}_fx'] = px_ccy data = data.loc[:, ~data.columns.duplicated(keep='last')] data = data.ffill().dropna(how='all', axis=0) data.iloc[-3:,-10:] The logic is fairly straightforward:
tickers_list: For each ticker, it retrieves the corresponding data from the library_autos arcticdb library.tickers_fx_dict to calculate the price in a common currency, rounding to 4 decimal places._fx.We have now prepared the data. Next, let's define the position sizing algorithms and make use of this data.
"After spending many years in Wall Street and after making and losing millions of dollars I want to tell you this: It never was my thinking that made the big money for me. It always was my sitting. Got that? My sitting tight!"
– Edwin Lefèvre, Reminiscence of a stock operator
This is a departure from the first edition. In the first edition, we advocated for a scale-out system. The idea is to enter with a full position, take a small profit on high probability and let the remainder run until stopped out.
In this edition, we will use the relative score as a continuous signal. The rules are simple. We will compare today's theoretical number of round lots with yesterday's position and trade the difference. This continuous position is much nimbler at managing risk and volatility.
The added benefit is it is not boring. Many traders, including algorithmic traders, find it difficult to "sit and wait" as Jesse Livermore used to say in his biography "Reminiscences of a Stock Operator." Some people feel compelled to trade, not to honor a signal, but simply because they show up at work. The continuous position sizing scratches that itch.
This continuous adjustment has two main drawbacks. It increases transaction costs and adversely impacts the average price. The constant in and out drags the average price in the direction of the trend. Positions are accounted as First In First Out (FIFO). When position sizes are continuously adjusted, the average price converges toward the most recent price.
The main takeaway is that every position sizing algorithm boils down to two variables: capital allocation of the Net Asset Value (NAV) and the denominator used to calculate the number of shares. First, we want to calculate how much of the capital we want to allocate to any specific trade. Then, we need to know which number to divide this allocation by to arrive at the number of shares and contracts. It could be the price, a distance to stop loss, volatility, and so on.
Let's illustrate this with an example. Let's say we have a capital of $1,000,000. We want to risk no more than 2% of this capital. This would be a capital allocation of $20,000. Next, let's figure out which number we are going to use in the denominator:
These simple examples simply show that we need to define how much capital needs to be allocated and the logic we use to calculate the number of shares.
Now let's go through the code and then explain the purpose of each function.
def size_limit(nav_pct, limit_pct): return nav_pct.clip(-limit_pct, limit_pct)def nav_allocation(nav, nav_pct): return nav * nav_pctdef nav_signal(nav_pct, nav, signal): signal_nav = np.multiply(nav_pct, np.multiply(nav, signal)) return np.nan_to_num(signal_nav)def shares_target(target_mv, fraction): if pd.isna(fraction) or fraction == 0: return 0 shares = target_mv / fraction if math.isinf(shares) or pd.isna(shares): return 0 return sharesdef round_lot(raw_shares, lot_size): return math.floor(abs(raw_shares) / lot_size) * lot_size * np.sign(raw_shares)def trading_value(round_lots, price): return round_lots * pricedef trading_value_local(round_lots, price, fx): return round_lots * price * fxdef calculate_drawdown(nav_series): peak = nav_series.cummax() drawdown = (nav_series - peak) / peak return drawdown Next, let's explain the purpose of each function.
size_limit(nav_pct, limit_pct): clips the position size percentage (nav_pct) to a specified limit (limit_pct), ensuring it stays within the range [‑limit_pct, limit_pct]. This prevents over-allocation beyond the allowed bounds.nav_allocation(nav, nav_pct): calculates the dollar amount allocated to a position by multiplying the NAV by the position size percentage (nav_pct).nav_signal(nav_pct, nav, signal): computes the signal-adjusted NAV allocation by multiplying nav_pct, nav, and the trading signal (signal). Replaces any NaN values with 0 to handle missing data.shares_target(target_mv, fraction): determines the target number of shares based on the target market value (target_mv) divided by the price or fraction (fraction). Returns 0 if fraction is NaN, zero, or if the result is infinite or NaN.round_lot(raw_shares, lot_size): rounds the raw number of shares (raw_shares) down to the nearest multiple of the lot size (lot_size), preserving the original sign (positive or negative). trading_value(round_lots, price): calculates the total trading value by multiplying the rounded lots (round_lots) by the price (price).trading_value_local(round_lots, price, fx): calculates the trading value in local currency by multiplying the rounded lots (round_lots) by the price (price) and the exchange rate (fx).calculate_drawdown(nav_series): computes the drawdown series for a NAV series by finding the cumulative maximum (peak) and calculating the percentage decline from that peak. Returns the drawdown as a percentage.Now that we have defined the functions necessary to calculate position sizes, we will define a function that will loop through the entire investment universe bar by bar, calculate positions, daily profit and loss (P&L), and the equity curve. This function will be recycled for all the position sizing algorithms.
def simulate_position_sizing(start_K, lot_size, com_rate, pct, look_ahead,fraction_type, constant= None, ratio='_2std'): # for pct in pct_list: nav = start_K cash_balance = start_K position_dict = {ticker: 0 for ticker in tickers_list} # Tracks positions for each ticker pl_dict = {ticker: 0 for ticker in tickers_list} # Tracks P&L for each ticker # daily_pl_sum = 0 # Tracks total daily P&L across all tickers nav_list = [] ; long_mv_list = [] ; short_mv_list = [] # Tracks NAV over time for t in data.index[2:]: # Loop through each bar daily_pl_sum = 0 ; long_mv = 0 ; short_mv = 0 # Reset at every bar for ticker in tickers_list[:]: # Loop through each ticker price_fx = data.at[t,f'{ticker}_fx'] price_fx_prev = data[f'{ticker}_fx'].shift(1).at[t] signal = data.at[t,f'{ticker}_signal'] # Compute fraction based on type if fraction_type == 1: fraction = price_fx elif fraction_type == 2: fraction = price_fx * constant elif fraction_type == 3: fraction = price_fx * data.at[t,f'{ticker}{ratio}'] elif fraction_type == 4: fraction = data.at[t,f'{ticker}{ratio}'] else: fraction = price_fx # default to option 1 # Calculate position size percentage,target market value and shares target_mv = nav_signal(pct, nav, signal) shares = shares_target(target_mv, fraction) # # Calculate daily P&L if not look_ahead: daily_pl = position_dict[ticker] * (price_fx - price_fx_prev) pl_dict[ticker] += daily_pl daily_pl_sum += daily_pl # Calculate theoretical lots and round lots: theoretical_lots = round_lot(shares, lot_size) round_lots = theoretical_lots - position_dict[ticker] # Calculate trade value and commission: trade_value = trading_value(round_lots, price_fx) commission = abs(trade_value) * com_rate # Update position and cash balance position_dict[ticker] += round_lots cash_balance -= (trade_value + commission) # Calculate daily P&L if look_ahead: daily_pl = position_dict[ticker] * (price_fx - price_fx_prev) pl_dict[ticker] += daily_pl daily_pl_sum += daily_pl # Calculate Long and Short exposure if position_dict[ticker] > 0: # Long exposure long_mv += position_dict[ticker] * price_fx elif position_dict[ticker] < 0: # Short exposure short_mv += position_dict[ticker] * price_fx # Update NAV nav += daily_pl_sum nav_list.append(nav) long_mv_list.append(long_mv) short_mv_list.append(short_mv) return nav_list, long_mv_list, short_mv_list The simulate_position_sizing function simulates a trading strategy's performance over time for multiple tickers, incorporating position sizing, commissions, and P&L calculations. It iterates through each date in data.index[2:] and each ticker in tickers_list, adjusting positions based on signals and fraction types, then updates NAV, long/short exposures, and returns lists of these values.
Key steps per date and ticker:
price_fx) and previous (price_fx_prev) prices, and the trading signal.fraction_type: fraction = price_fxfraction = price_fx * constantfraction = price_fx * data.at[t, f'{ticker}{ratio}']fraction = data.at[t, f'{ticker}{ratio}']target_mv) using nav_signal(pct, nav, signal).shares) via shares_target(target_mv, fraction).lot_size using round_lot, then compute round_lots as the difference from current position.The function assumes nav_signal, shares_target, round_lot, and trading_value are defined elsewhere (e.g., in earlier cells).
This is the second time we encounter the lookahead bias. Remember that we kept the lookahead bias to make the returns look better and therefore exclusively focus on the returns. If we want to strip the lookahead bias, we need to calculate the daily P&L before adding the marginal position. For the purpose of the exercise, we adjusted the position size and calculated the daily P&L on the same bar. In practice, we process today's data, compare with yesterday's position size, then we will trade tomorrow. This deliberate simplification in the above function creates an overly optimistic scenario called lookahead bias. We start with optimistic returns so that we stay concentrated on position sizing on the equity curve.
Next, we will kick off with the fixed percentage or fixed dollar position sizing.
This is the default position sizing algorithm for a lot of retail traders. A fixed percentage or dollar amount of the account's NAV is allocated to each trade. When the strategy works and the NAV grows, so does the amount or percentage. This is typically used by market participants who do not use margin or leverage.
We will loop through the list of percentages to illustrate how the equity curve evolves.
Let's publish the code:
start_K = 1000000 ; lot_size = 100 ; com_rate = 0.001 pct_list = [0.02 ,0.05, 0.1, 0.2] nav_df = pd.DataFrame(index=data.index[2:]) # Fixed Percentage algo = 'fix_pct_' nav_cols = [f'nav_fix_pct{pct}' for pct in pct_list] gross_exposure_cols = [f'gross_{algo}{pct}' for pct in pct_list] net_exposure_cols = [f'net_{algo}{pct}' for pct in pct_list] chunks = [] for pct in pct_list: nav_list, long_mv_list, short_mv_list = simulate_position_sizing(start_K, lot_size, com_rate, pct, look_ahead, fraction_type = 1, constant= None, ratio= None) chunk = pd.DataFrame({ f'nav_{algo}_{pct}': nav_list, f'longMV_{algo}_{pct}': long_mv_list, f'shortMV_{algo}_{pct}': short_mv_list, }, index=data.index[2:]) chunk[f'drawdown_{algo}_{pct}'] = calculate_drawdown(chunk[f'nav_{algo}_{pct}']) chunk[f'net_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] + chunk[f'shortMV_{algo}_{pct}']) / (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) chunk[f'gross_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) / chunk[f'nav_{algo}_{pct}'] chunks.append(chunk) nav_df = pd.concat([nav_df] + chunks, axis=1) nav_df.dropna().filter(like=f'nav_{algo}').plot(figsize=(15, 5), grid=True, secondary_y=[f'nav_{algo}{pct}'], title=f'NAV - Fixed Percentage: {pct_list}') nav_df.dropna().filter(like=f'drawdown_{algo}').plot(figsize=(15, 5), grid=True, title=f'Drawdowns - Fixed Percentage: {pct_list}') nav_df.dropna().filter(like=f'gross_{algo}').plot(figsize=(15, 5), grid=True, title=f'Gross Exposure - Fixed Percentage: {pct_list}') nav_df.dropna().filter(like=f'net_{algo}').plot(figsize=(15, 5), grid=True, title=f'Net Exposure - Fixed Percentage: {pct_list}') nav_df.columns This code block implements a fixed percentage position sizing algorithm for a trading simulation across multiple tickers. It simulates portfolio performance for different fixed percentages of NAV allocated to each position, calculates key metrics like NAV, drawdowns, and exposures, and visualizes the results.
Now, let's explain how this block of code works.
start_K = 1000000: Starting NAV of $1,000,000lot_size = 100: Minimum trade size (e.g., shares must be multiples of 100)com_rate = 0.001: Commission rate (0.1% per trade)pct_list = [0.02, 0.05, 0.1, 0.2]: List of fixed percentages (2%, 5%, 10%, 20%) of NAV to allocate per positionnav_df: A new pandas DataFrame with index matching data.index[2:] (skipping the first two rows, for data alignment)algo = 'fix_pct_': Prefix for column names to indicate the algorithm typeFor each pct in pct_list:
simulate_position_sizing(start_K, lot_size, com_rate, pct, look_ahead, fraction_type=1, constant=None, ratio='_2std'):tickers_listfraction_type=1: Uses the current price (price_fx) as the fraction for share calculation (i.e., fixed dollar allocation scaled by price)nav_list (NAV over time), long_mv_list (long market value)short_mv_list (short market value).Instantiating chunk dataframenav_df: nav_{algo}{pct}: NAV serieslongMV_{algo}{pct}: Long market valueshortMV_{algo}{pct}: Short market valuedrawdown_{algo}{pct}: Drawdown (calculated using calculate_drawdown, which computes peak-to-trough declines)net_{algo}{pct}: Net exposure = (long MV + short MV) / NAV (measures directional bias: bullish/bearish)gross_{algo}{pct}: Gross exposure = (long MV - short MV) / NAV (measures total exposure or leverage)Plots NAV series for all pct values (from row 500 onward to skip initial data):
figsize=(15, 5), grid, and titles for claritynav_df.columns: Prints the list of all columns in nav_df (e.g., for verification)score_rel.look_ahead (a boolean variable) affects whether signals are shifted to avoid lookahead bias. Results show how higher percentages (e.g., 20%) lead to higher NAV growth but also greater drawdowns and exposures.
Next, let's visualize the results. We will start with the overly optimistic equity curve. Remember that we have allowed the lookahead bias to persist.

Figure 6.2: Equity Curve, Fixed Percentage
Unsurprisingly, the bigger the position size, the higher the compounded returns. Next let's look at drawdowns.

Figure 6.3: Drawdowns, Fixed Percentage
Drawdowns are a direct function of position size. The bigger the position size, the deeper the drawdown.
We did not impose a limit on the size of the portfolio. We assume unlimited margin. Now, let's look at how big or small the portfolio gets over time.

Figure 6.4: Gross Exposure, Fixed Percentage
The investment universe only comprises 9 stocks. Equal weight would be around 11%. So, anything below 11% will result in underutilization of the capital. Interestingly enough, 10% fixed percentage results in the gross exposure hovering around 50%. The signal does too good of a job at dampening the optimism of the position sizer.
Next, let's look at the net exposure, the delta between the long and short exposure. Positive net exposure means the long book is bigger than the short one. This indicates a bullish stance and vice versa when the net exposure is negative.

Figure 6.5: Net Exposure, Fixed Percentage
Next exposure is the same regardless of the leverage. So, all lines merge into one. Net exposure is unlikely to change much with various position sizes. So, we will not show the graphs with other methodologies.
We have looked at fixed dollar, regardless of risk. Next, let's look at what happens when we budget size as a function of risk.
The next evolution is fixed risk position sizing. Instead of dividing the capital by the entire share price, we only consider a distance to stop loss instead. In this example, we set the distance to stop loss at a flat 10%.
This is a little more capital effective than the previous method. Since we only use 10% of the share price, this returns much bigger position sizes. This explains why the percentages in the pct_list are much smaller than the fixed percentage.
Let's publish the code:
start_K = 1000000 ; lot_size = 100 ; com_rate = 0.001 pct_list = [0.005 ,0.01, 0.02, 0.05] # Fixed Risk algo = 'fix_risk_' nav_cols = [f'nav_{algo}{pct}' for pct in pct_list] gross_exposure_cols = [f'gross_{algo}{pct}' for pct in pct_list] net_exposure_cols = [f'net_{algo}{pct}' for pct in pct_list] dsl = 0.1 chunks = [] for pct in pct_list: nav_list, long_mv_list, short_mv_list = simulate_position_sizing(start_K, lot_size, com_rate, pct, look_ahead, fraction_type= 2, constant= dsl, ratio=None) chunk = pd.DataFrame({ f'nav_{algo}_{pct}': nav_list, f'longMV_{algo}_{pct}': long_mv_list, f'shortMV_{algo}_{pct}': short_mv_list, }, index=nav_df.index) chunk[f'drawdown_{algo}_{pct}'] = calculate_drawdown(chunk[f'nav_{algo}_{pct}']) chunk[f'net_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] + chunk[f'shortMV_{algo}_{pct}']) / (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) chunk[f'gross_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) / chunk[f'nav_{algo}_{pct}'] chunks.append(chunk) nav_df = pd.concat([nav_df] + chunks, axis=1) nav_df.dropna().filter(like=f'nav_{algo}').plot(figsize=(15, 5), grid=True, secondary_y = [f'nav_{algo}_{pct}'], title=f'NAV - Fixed Risk dsl= {dsl}: {pct_list}') nav_df.dropna().filter(like=f'drawdown_{algo}').plot(figsize=(15, 5), grid=True, title = f'Drawdowns - Fixed Risk: {pct_list}') nav_df.dropna().filter(like=f'gross_{algo}').plot(figsize=(15, 5), grid=True, title = f'Gross Exposure - Fixed Risk dsl= {dsl}: {pct_list}') nav_df.dropna().filter(like=f'net_{algo}').plot(figsize=(15, 5), grid=True, title = f'Net Exposure - Fixed Risk dsl= {dsl}: {pct_list}') The two notable differences with the preceding code block are:
nav_df. We want every position sizing algorithm to populate the nav_df We want to aggregate all the position algorithms and visualize them. dsl = 0.1: this is the constant risk, 10%.Fraction_type = 2. This means we multiply the share price by a constant.This produces the following chart:

Figure 6.6: Equity Curve, Fixed Risk
There is abundant literature dating back to the 80s that it is unwise to risk more than 2% on a single trade. This time-honoured advice comes for three reasons. First, losing streaks may fast erode the financial capital. Risking more than 2% on a single trade will introduce a level of volatility that few traders and much fewer clients can withstand.
Trend following systems have win rates around 1/3. In practice, this translates into a high probability of 25 consecutive losing trades and routinely going through 10 losses in a row. At 2% risk per trade, this would result in routine drawdowns of 30-50%. This is beyond the breaking point of most individuals. Let's illustrate this concept with the chart below.

Figure 6.7: Drawdowns, Fixed Risk
One thing stands out. Even with the overly optimistic signals in this simulation, the system will still wipe out capital when risk is set too high. This reinforces the argument that position sizing is the link between emotional and financial capital.
Let's proceed with the gross exposure.

Figure 6.8: Gross Exposure, Fixed Risk
The investment universe is concentrated. At 2% risk per trade and 10% distance to stop loss, the gross exposure revolves around 1.
A flat 10% rate is a blunt instrument. Let's see if we can refine this by including volatility.
Markets can remain quiet longer than you can stay hysterical and vice versa. A flat rate fails to capture the volatile mood swings of the market. In this section, we will go through a few classic volatility measures. There are broadly two types of volatility: realized and implied. Realized volatility is calculated from recent historical data. Implied volatility is also calculated from recent history but projected into the future, hence the name implied. Realized and implied end up converging toward realized over time.
In the following example, we will focus on a few simple functions. If You feel like going down a rabbit hole, feel free to explore newer developments like Garman Klass, Yang Zhang, Hodges-Tompkins and so on. For now, let's define a few functions related to volatility.
Investors rarely ponder how much they are willing to fluctuate on a daily basis. They have some idea of how much volatility they can tolerate on an annual basis. So, the first step for us is to translate the broad annual volatility budget into a daily target for each stock.
def target_vol_daily(annual_target, len_tickers): daily_target = annual_target / np.sqrt(252) target_vol_daily = daily_target / np.sqrt(len_tickers) return target_vol_dailydef average_true_range(df, _h, _l, _c, n): atr = (df[_h].combine(df[_c].shift(), max) - df[_l].combine(df[_c].shift(), min)).rolling(window=n).mean() return atrdef raw_log_volatility(df, _c, n): daily_log_returns = np.log(df[_c] / df[_c].shift(1)) log_realised = daily_log_returns.rolling(n).std(ddof=0) return log_realised Let's explain the purpose of each function:
target_vol_daily(annual_target, len_tickers): Converts the annual target to a daily target by dividing by the square root of 252 (approximate trading days in a year) and further divides by the square root of the number of tickers to distribute risk equally across them. Those are the inputs: annual_target: A float representing the desired annual volatility (e.g., 0.2 for 20%).len_tickers: An integer for the number of tickers (e.g., from len(tickers_list)) average_true_range(df, _h, _l, _c, n): is a staple in any trader's quiver. Average True Range (ATR) measures market volatility by considering the range between the high, low, and previous close prices.raw_log_volatility(df, _c, n): Calculates the rolling standard deviation of daily logarithmic returns over n periods, representing realized volatility.Next, let's take those functions for a spin and visualize the results. We will start with raw volatility.
n =21raw_vol = raw_log_volatility(px_df_rel, n)raw_vol = raw_vol.ffill().dropna(how='all', axis=0).round(4)ax =raw_vol[-500:].plot(figsize=(16,3), grid=True, title=f'Realised Volatility over {n} days')ax.legend(loc='lower left')plt.show() We calculate the log returns instead of the arithmetic returns. This gives a log-normal distribution of returns. We calculate volatility over a period of 21 days or one business month. This produces the following chart:

Figure 6.9: Raw Volatility
Next, let's plot the standard deviation:
stdev = px_df_rel.rolling(n).std(ddof=0).div(px_df_rel) stdev = stdev.ffill().dropna(how='all', axis=0).round(4) ax =stdev[-500:].plot(figsize=(16,3), grid=True, title=f'Standard Deviation over {n} days') ax.legend(loc='upper left') plt.show() Instead of using returns, we calculate the standard deviation of price. If we used absolute prices or issues in the same currency, we could directly use the standard deviation of prices. Since we use the relative series, we must convert this number into a percentage.

Figure 6.10: Standard deviation
Spikes are more pronounced than the raw volatility. The raw volatility used the log returns, while these series use prices instead.
Next, let's calculate the average true range. We will be using OHLC prices. We will therefore have to download data from the arctic database for each stock.
atr_df = pd.DataFrame(index=data.index) _o,_h,_l,_c = rohlc(df, relative=True) for ticker in tickers_list[:]: df = library_autos.read(ticker).data atr_risk = average_true_range(df, _h, _l, _c, n ).div(df[_c]) atr_df[f'{ticker}_atr'] = atr_risk atr_df = atr_df.ffill().dropna(how='all', axis=0).round(4) ax = atr_df[-500:].plot(figsize=(16,3), grid=True, title=f'ATR over {n} days') ax.legend(loc='upper left') plt.show() This produces the following chart:

Figure 6.11: ATR
ATR generally produces more stable and lower volatility results than the previous methods.
In practice, market participants generally use a multiple such as 2 or 2.5 standard deviations or ATR. Since we use a continuous signal, position sizes are adjusted in real time. This enables a tighter fit: 1 unit of volatility instead of the traditional 2.
Without further ado, let's calculate the equity curves, drawdowns, and gross exposures for each volatility metric.
Let's kick off with volatility of the log returns. We start with a list of annual targets. This is how much annual volatility we can stomach. This will produce a list of daily volatility targets. And then, we just run the algorithm.
annual_target_list = [0.05, 0.1, 0.15, 0.2] len_tickers = len(tickers_list) pct_list = [round(target_vol_daily(annual_target, len_tickers),4) for annual_target in annual_target_list] # Raw Volatility algo = 'raw_vol' nav_cols = [f'nav_fix_pct{pct}' for pct in pct_list] gross_exposure_cols = [f'gross_{algo}_{pct}' for pct in pct_list] net_exposure_cols = [f'net_{algo}_{pct}' for pct in pct_list] # add raw volatility to data raw_vol.columns = [_c + '_raw_vol' for _c in raw_vol.columns] data = data.merge(raw_vol, how='left', left_index=True, right_index=True).ffill() chunks = [] for pct in pct_list: nav_list, long_mv_list, short_mv_list = simulate_position_sizing(start_K, lot_size, com_rate, pct, look_ahead, fraction_type= 3, constant= None, ratio='_raw_vol') chunk = pd.DataFrame({ f'nav_{algo}_{pct}': nav_list, f'longMV_{algo}_{pct}': long_mv_list, f'shortMV_{algo}_{pct}': short_mv_list, }, index=nav_df.index) chunk[f'drawdown_{algo}_{pct}'] = calculate_drawdown(chunk[f'nav_{algo}_{pct}']) chunk[f'net_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] + chunk[f'shortMV_{algo}_{pct}']) / (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) chunk[f'gross_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) / chunk[f'nav_{algo}_{pct}'] chunks.append(chunk) nav_df = pd.concat([nav_df] + chunks, axis=1) nav_df.dropna().filter(like=f'nav_{algo}').plot(figsize=(15, 5), grid=True, secondary_y = [f'nav_{algo}_{pct}'], title=f'NAV - Raw Volatility: {annual_target_list}') nav_df.dropna().filter(like=f'drawdown_{algo}').plot(figsize=(15, 5), grid=True, title = f'Drawdowns - Raw Volatility: {annual_target_list}') nav_df.dropna().filter(like=f'gross_{algo}').plot(figsize=(15, 5), grid=True, title = f'Gross Exposure - Raw Volatility: {annual_target_list}') nav_df.dropna().filter(like=f'net_{algo}').plot(figsize=(15, 5), grid=True, title = f'Net Exposure - Raw Volatility: {annual_target_list}') Since we multiply the price by another column, we update the fraction_type to 3. This produces the following chart:

Figure 6.12: Equity curve, Raw Volatility
Volatility is smoother than fixed risk. Volatility does a better job at capturing Ms. Market mood swings.
Next, let's see what drawdowns look like:

Figure 6.13: Drawdowns, Raw Volatility
Drawdowns remain well below the annual target in all cases. This is encouraging.
Let's see how much leverage this methodology puts on.

Figure 6.14: Gross Exposure, Raw Volatility
Gross exposure is higher than other methods so far. Volatility is on average lower than the flat rate of 10%. Therefore, position sizes are bigger. The high returns with seemingly shallower drawdowns come with big swings in the gross exposure.
Next, let's take a cursory look at the standard deviation and ATR. We replace algo = 'raw_vol' with algo = 'stdev' and algo = 'ATR'. Everything else remains the same. This produces the following charts.
Let's start with the equity curve:

Figure 6.15: Equity Curve Standard Deviation
Let's compare it with ATR:

Figure 6.16: Equity Curve ATR
As we saw in Figure 6.11, average true range returns lower volatility numbers. This translates into larger positions and higher returns.
Next, let's have a look at drawdowns:

Figure 6.17: Drawdowns Standard Deviation
Let's look at drawdowns of the ATR:

Figure 6.18: Drawdowns Average True Range
In both cases, drawdowns are subdued. Let's now look at the gross exposures:

Figure 6.19: Gross Exposure Standard Deviation
Below is the gross exposure for the ATR method:

Figure 6.20: Gross Exposure Average True Range
Gross exposure is bigger than the other methods simply because the volatility is smaller, which leads to bigger position sizes. The fluctuations in volatility cause large swings in gross exposure. In practice, everything is a trade-off. Transaction costs would considerably erode the equity curve.
So far, volatility, and particularly average true range, seem to generate higher risk-adjusted returns.
Next, let's explore the nec plus ultra of position sizing: the Kelly criterion.
The story of this position sizing algorithm reads like a suspense novel. It was originally discovered by Daniel Bernoulli. It was forgotten for centuries before being found out in the Bell labs in the 60s'. Legendary trader Ed Thorpe applied it to Blackjack and then to the markets. If You want to read more about Kelly, William Poundstone did a wonderful job in his book the Fortune's formula.
Personally, I never really understood how to apply Kelly until recently. Position sizes were either too big or null. Only when the formula was modified to accommodate the short side did it finally make sense. There is in fact immense wisdom in that formula.
The Kelly criterion is a betting system based on an interval of returns. It is originally derived from the gain expectancy we saw in the previous chapter. The more often and bigger the returns, the higher the bet size. When we roll that window over the length of a dataframe, not only do we get an optimal bet size at each bar, but we also get a fully functioning indicator that shows both the direction and strength of the trend.
Let's illustrate the concept with some practical examples. First, let's define a few functions based on the following code block.
def kelly_fraction(returns): wins = returns[returns > 0] losses = returns[returns <= 0] W = len(wins) / len(returns) if len(returns) > 0 else 0 avg_win = wins.mean() if len(wins) > 0 else 0 avg_loss = abs(losses.mean()) if len(losses) > 0 else 0 R = (avg_win / avg_loss) if avg_loss > 0 else 0 kelly = max(W - (1 - W) / R,-1) if R != 0 else 0 return kelly def rolling_function(returns, function, f_duration): return returns.rolling(window=f_duration).apply(function, raw=False) def kelly_lateral(returns, n): lateral_f = returns.apply(kelly_fraction, axis=1) if n > 1: kelly_lateral_df = lateral_f.rolling(n).mean() else: kelly_lateral_df = lateral_f return kelly_lateral_df Let's explain the above functions.
kelly_fraction(returns): Calculates the optimal fraction of capital to invest to maximize long-term geometric growth from a series of returns. This function computes win probability (W), average win, average loss, win/loss ratio (R), and Kelly fraction as max(W - (1 - W) / R, -1) if R > 0, else 0. This caps the fraction at -1 to avoid extreme shorting.rolling_function(returns, function, f_duration): Applies a given function (e.g., kelly_fraction) over a rolling window of f_duration periods.kelly_lateral(returns, n): Computes the Kelly fraction for each column (ticker) in the returns DataFrame using kelly_fraction. If n > 1, applies a rolling mean over n periods to smooth the fractions; otherwise, returns the raw fractions. This provides lateral (cross-sectional) Kelly values across tickers.Now that we have defined a few functions, let's take them for a spin. We will calculate the daily log returns. We will then run the Kelly criterion over 3 rolling windows: 33, 50 and 100 days. 1 day in the 33-day window represents 3% of the total, 2% in the 50-day segment, and 1% in the 100-day interval. We will also calculate a simple lateral average of the 3 durations.
We will plot each stock signal with the average Kelly criterion. Let's start with the code:
daily_log_rets = np.log(px_df_rel / px_df_rel.shift(1)) kelly_df = pd.DataFrame(index=data.index) kelly_df = pd.concat([px_df_rel.add_suffix('_rel'), score_rel.add_suffix('_signal')], axis=1) f_duration_list = [33, 50, 100] for f_duration in f_duration_list: print(f'value of 1 day for a duration of {f_duration} days: {round(1/f_duration, 2)} pct, ') kelly_log_df = rolling_function(daily_log_rets, kelly_fraction, f_duration) kelly_df = pd.concat([kelly_df, kelly_log_df.add_suffix(f'_f{f_duration}')], axis=1) kelly_gross = abs(kelly_log_df).sum(axis = 1) kelly_net = kelly_log_df.sum(axis = 1) kelly_df[f'gross_{f_duration}'] = kelly_gross kelly_df[f'net_{f_duration}'] = kelly_net for ticker in tickers_list: kelly_df[f'{ticker}_favg'] = kelly_df.filter(like=f'{ticker}_f').mean(axis=1) ax = kelly_df[[f'{ticker}_signal',f'{ticker}_favg']].dropna().plot(figsize=(15, 3), grid = True, secondary_y=[f'{ticker}_favg'], title=f'{ticker}: signal and Kelly average') ax.legend(loc='lower left') plt.show() kelly_df['gross_favg'] =abs(kelly_df.filter(like=f'gross')).mean(axis=1) kelly_df['net_favg'] = kelly_df.filter(like=f'net').mean(axis=1) kelly_df.dropna().filter(like='gross').plot(figsize=(15, 3), grid=True, title = f'Gross Exposure - Kelly: {f_duration_list}') kelly_df.dropna().filter(like='net').plot(figsize=(15, 3), grid=True, title = f'Net Exposure - Kelly: {f_duration_list}') This code computes and visualizes Kelly fractions for position sizing using the Kelly Criterion.
px_df_rel).kelly_df` with relative prices and signals.`kelly_df`In Chapter 4, we calculated an elaborate signal called score. It turns out that a simple average of Kelly across 3 durations (33, 50 and 100 days) would generate a more responsive signal with far less overhead.
Let's print a few charts to illustrate that point:

Figure 6.21: Tesla Signal and Average Kelly Criterion
Tesla has had a few rock n' roll years. The signal has done a good job at capturing those wild moves. It seems that Kelly does an even better job at spotting the inflections.
Let's print more charts to visualize the power of Kelly.

Figure 6.22: BYD Signal and Average Kelly Criterion
BYD is a Chinese automotive manufacturer. Kelly mirrors the signal. The average Kelly seems to be more reactive than the signal. Let's conclude with Renault:

Figure 6.23: Renault Signal and Average Kelly Criterion
Renault is a great example of what happens in trendless markets. Trend followers end up giving back a lot of their gains when they run into flat markets. They have a lot of false starts which erode the equity curve. Here, we can see a divergence between the signal stuck in either bullish or bearish territory and the average Kelly hovering around 0.
Kelly solves the problem of trendless markets by collapsing position size to zero. Trendless markets simply mean that advances and retreats cancel each other out. Rolling Kelly concludes there is no statistical edge either way.
Next, let's print the gross exposure:

Figure 6.24: Gross Exposure Kelly Criterion
We calculated the gross exposure by summing up the absolute position sizes of all positions. Kelly can command big gross exposures.
Next, we will run Kelly through the same process as the other algos. We need to modify the original function simulate_positionn_sizing() to accommodate the Kelly criterion. These are only 2 lines of code. We therefore do not publish the lengthy function, only the modifications. In the Notebook, the function is renamed simulate_kelly_position_sizing().
First, we replace the signal with the Kelly criterion as follows:
# signal = data.at[t,f'{ticker}_signal'] signal = kelly_df.at[t,f'{ticker}_f{pct}'].round(4) Then, we replace the target market value as follows:
# target_mv = nav_signal(pct, nav, signal) target_mv = np.multiply(nav, signal) Finally, we rename the function simulate_kelly_position_sizing. The rest remains unchanged, and we can now process the data.
pct_list = f_duration_list +['avg'] algo = 'kelly' nav_cols = [f'nav_fix_pct{pct}' for pct in pct_list] gross_exposure_cols = [f'gross_{algo}_{pct}' for pct in pct_list] net_exposure_cols = [f'net_{algo}_{pct}' for pct in pct_list] chunks = [] for pct in pct_list: nav_list, long_mv_list, short_mv_list = simulate_kelly_position_sizing(start_K, lot_size, com_rate, pct, look_ahead, fraction_type=1, constant=None, ratio=None) chunk = pd.DataFrame({ f'nav_{algo}_{pct}': nav_list, f'longMV_{algo}_{pct}': long_mv_list, f'shortMV_{algo}_{pct}': short_mv_list, }, index=nav_df.index) chunk[f'drawdown_{algo}_{pct}'] = calculate_drawdown(chunk[f'nav_{algo}_{pct}']) chunk[f'net_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] + chunk[f'shortMV_{algo}_{pct}']) / (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) chunk[f'gross_{algo}_{pct}'] = (chunk[f'longMV_{algo}_{pct}'] - chunk[f'shortMV_{algo}_{pct}']) / chunk[f'nav_{algo}_{pct}'] chunks.append(chunk) nav_df = pd.concat([nav_df] + chunks, axis=1) nav_df.dropna().filter(like=f'nav_{algo}').plot(figsize=(15, 5), grid=True, secondary_y = [f'nav_{algo}_{pct}'], title=f'NAV - {algo}: {annual_target_list}') nav_df.dropna().filter(like=f'drawdown_{algo}').plot(figsize=(15, 5), grid=True, title = f'Drawdowns - {algo}: {annual_target_list}') nav_df.dropna().filter(like=f'gross_{algo}').plot(figsize=(15, 5), grid=True, title = f'Gross Exposure - {algo}: {annual_target_list}') nav_df.dropna().filter(like=f'net_{algo}').plot(figsize=(15, 5), grid=True, title = f'Net Exposure - {algo}: {annual_target_list}') Now, let's explain the code:
f_duration_list and rename it pct_list.simulate_position_sizing with simulate_kelly_position_sizing.fraction_type at 1. This means we divide by the price. This is the most conservative approach. Some market participants like to trade fractional Kelly. This could be synthetically achieved by setting the fraction to 2 and modifying the constant.This produces the following chart:

Figure 6.25: Equity Curve, Kelly Criterion
Kelly is supposed to produce optimal geometric growth. It delivers on its promise. Growth is phenomenal with even the most basic simple average across three durations.
However, Kelly is also known for its gut-wrenching volatility. Let's proceed to drawdowns:

Figure 6.26: Drawdowns Kelly Criterion
Drawdowns are not for the faint-hearted. There will be times when Kelly torpedoes the NAV. However, there will also be times when Kelly prints money. The difficulty is to slavishly adhere to the strategy.
We already briefly saw how much leverage Kelly packs. Let's take a brief look at the gross exposure.

Figure 6.27: Gross Exposure Kelly Criterion
Kelly gloriously fails to disappoint. We did not impose a limitation on the gross exposure. Left to its own devices, leverage balloons up to 3.5 times the NAV. This is clearly not for your average pension fund manager.
Finally, let's have a look at the net exposure. We replaced the signal with a different Kelly criterion. Since the signal is not uniform, it will have an impact on the net exposure.
We will therefore show what the net exposure looks like at various durations.

Figure 6.28: Net Exposure Kelly Criterion
Net exposure effortlessly swings from bearish to bullish. Kelly has delivered on its promise. Not only is it a statistically robust position sizing algorithm, but it is also a powerful indicator of the direction and health of the market. Subsequent variations on the Kelly criterion theme are Ralph Vince Optimal f. The idea is to balance risk and reward by resizing position sizes based on past worst losses to optimize geometric growth. In practice, it is a bit difficult to implement as any seasoned market participant will tell you that the worst loss is yet to come.
Now that we have built a few position sizing algorithms, it is time we compared them visually. We will plot the equity curves, drawdowns, and gross exposures.
algo_list =['fix_pct', 'fix_risk', 'raw_vol', 'std', 'atr', 'kelly'] for i in range (4): plot_nav_cols = [nav_df.filter(like=f'nav_{algo}').columns[i] for algo in algo_list] nav_df[plot_nav_cols].dropna().plot(figsize=(15, 5), secondary_y=plot_nav_cols[-1:], grid=True, title=f'NAV Comparison {i+1}: {algo_list}') plot_dd_cols = [nav_df.filter(like=f'drawdown_{algo}').columns[i] for algo in algo_list] nav_df[plot_dd_cols].dropna().plot(figsize=(15, 5), grid=True, title=f'Drawdown Comparison {i+1}: {algo_list}') plot_gross_cols = [nav_df.filter(like=f'gross_{algo}').columns[i] for algo in algo_list] nav_df[plot_gross_cols].dropna().plot(figsize=(15, 5), grid=True, title=f'Gross Comparison {i+1}: {algo_list}') The code is fairly straightforward.
pct_list.We will only plot the last iteration to save some space. Let's start with the NAV

Figure 6.29: NAV Comparison
For every iteration, Kelly leaves everything else in the dust. It is unquestionably the superior method.
Next, let's look at drawdowns.

Figure 6.30: Drawdowns Comparison
This graph comes as a bit of a surprise. Kelly dwarfed everybody else in terms of performance. Yet, it did not come with the biggest drawdowns. The worst drawdowns were with the 5% fixed risk method. All it takes is a losing streak to rapidly erode the equity curve. This is why managing risk during drawdowns is essential for survival.
Next, let's look at the leverage in the portfolio and plot the gross exposures.

Figure 6.31: Gross Exposures Comparison
The biggest gross exposure is the fixed risk at 5%, followed by raw volatility of 20% annualized.
This exercise has hopefully renewed interest in exploring applications of the Kelly criterion. A simple average of 3 durations provides a robust signal and an optimal position size.
This chapter underscored the critical importance of position sizing as the cornerstone of risk management and portfolio performance. While stock selection often takes center stage in investment strategies, the chapter revealed that position sizing—the allocation of capital to individual trades—has a far greater impact on long-term geometric returns, equity curves, and drawdowns. The nuanced interplay between financial and emotional capital was explored, emphasizing that effective position sizing ensures not only portfolio growth but also psychological resilience during market volatility.
The chapter began with an introduction to common pitfalls in position sizing, termed the Four Horsemen of Apocalyptic Position Sizing. These include liquidity mismanagement, averaging down, high conviction without statistical backing, and equal weighting without respect to beta or volatility. Each of these flawed approaches was dissected to highlight their risks and limitations.
We then explored quantitative position sizing algorithms, starting with simple fixed-dollar and fixed-percentage allocations, gradually progressing to more sophisticated methods based on volatility (e.g., ATR, standard deviation), and culminating in the Kelly Criterion, which optimizes position size for maximum geometric return. Each method was tested and visualized, revealing how position sizing impacts portfolio performance and drawdowns. Notably, the Kelly Criterion emerged as the most statistically robust approach, though its high leverage and volatility demand discipline and emotional fortitude.
Finally, the chapter introduced volatility-based refinements. Taken together, these insights empower you to design better money management systems, balance risk and reward, and ultimately achieve more consistent performance in multi-asset portfolios. Position sizing, as this chapter demonstrated, is not just a mathematical exercise—it is the bridge between strategy and sustained success.
So far, we have only explored various position sizing algorithms. Seasoned market participants know that they need to pay close attention to the correlation between instruments in their portfolio. Securities that are highly correlated compound risk instead of reducing it. As the saying goes, diversification is the only free lunch in finance. This is a topic we will explore in the next chapter on the long/short tools.
In the next chapter, we will refine the investment universe from theoretically good to practically actionable ideas.
Scan the QR code (or go to ). Search for this book by name, confirm the edition, and then follow the steps on the page.


Note: Have your invoice handy. Purchases made directly from the Packt website don't require an invoice.