Position sizing in pairs trading#

Topics

Position sizing · Kelly criterion · inverse volatility · truncated normal · mean-reversion · modern portfolio optimization

Generating a signal tells you when and in which direction to trade. Position sizing answers the equally important question: how much? A strategy with a genuine edge can still lose money if positions are systematically too large (blow-ups from adverse moves) or too small (alpha is earned but never extracted meaningfully). Sizing is where portfolio theory meets execution.

This note develops the theoretical foundations — from Markowitz to Kelly — and then shows how spreadpy translates each into a concrete sizer, with comparative results.


The pairs trade as a single bet#

When the z-score \(z_t = (s_t - \mu_t)/\sigma_t\) of the spread \(s_t = y_t - \beta_t x_t\) crosses the entry threshold \(z_{\text{entry}}\), we go short the spread:

  • sell \(q_y\) units of \(y\) at price \(p_y\)

  • buy \(q_x = |\beta_t| \cdot q_y \cdot p_y / p_x\) units of \(x\) at price \(p_x\)

The two legs are linked through the hedge ratio \(\beta_t\) so that, to first order, the P&L is driven only by the spread, not by the direction of each individual asset.

The notional of the y-leg is \(N_y = q_y \cdot p_y\). The x-leg notional is \(N_x = q_x \cdot p_x = |\beta_t| \cdot N_y\). The sizing problem reduces to choosing \(N_y\), or equivalently the capital fraction

\[ f_t = \frac{N_y}{\text{capital}_t}. \]

The gain per unit of spread-sigma when the position closes at \(z_{\text{revert}}\) is:

\[ G = z_t - z_{\text{revert}}, \]

where \(z_t\) is the z-score at entry and \(z_{\text{revert}}\) the z-score at exit.


Markowitz: target-volatility sizing#

From mean-variance to inverse volatility#

Modern Portfolio Theory (Markowitz, 1952) seeks the portfolio that minimises variance for a given expected return. In the single-asset case, the optimal allocation is proportional to the Sharpe ratio:

\[ f^{MV} = \frac{\mu}{\gamma \sigma^2}, \]

where \(\mu\) is the expected return, \(\sigma^2\) the variance, and \(\gamma > 0\) the investor’s risk-aversion coefficient. The problem is that \(\mu\) and \(\gamma\) are hard to estimate reliably. A pragmatic simplification — target-volatility sizing — fixes the risk budget instead:

Allocate as much capital as needed so that a 1-\(\sigma\) adverse move of the spread costs exactly a target fraction \(\sigma^*\) of current capital.

If the spread residual has rolling standard deviation \(\sigma_t\) (estimated over the last \(w\) bars), the resulting capital fraction is:

\[ f_t = \min\!\left(\frac{\sigma^*}{\sigma_t},\, f_{\max}\right), \]

where \(f_{\max}\) is a hard cap. When the spread is calm (\(\sigma_t\) small), the sizer increases the position; when the spread is volatile, it reduces it — a built-in volatility targeting mechanism.

Link to the z-score signal

The rolling \(\sigma_t\) used here is computed over the same window as the z-score in ZScoreSignal, so the two components are consistent: a position sized for a 1-\(\sigma\) adverse move is exactly what the signal threshold parameterises.

InverseVolSizer in spreadpy#

from spreadpy.sizing.sizers.inverseVolSizer import InverseVolSizer

sizer = InverseVolSizer(
    window=60,          # rolling window for σ_t (bars)
    target_vol=0.02,    # 2% of capital at risk for a 1-σ move
    f_max=0.5,          # never allocate more than 50% of capital
)

# fit must be called first to precompute the rolling σ_t
sizer.fit(spread)

qty_y, qty_x = sizer.size(signal, price_y, price_x, hedge_ratio, capital)

The fraction \(f_t\) varies bar by bar. The y-leg notional is \(N_y = f_t \cdot \text{capital}_t\), and the x-leg quantity is automatically hedged via \(\beta_t\).


The Kelly criterion#

Maximising expected log-wealth#

The Kelly criterion (Kelly, 1956) answers the question: what fraction \(f\) of capital maximises the long-run growth rate of wealth? It derives from maximising the expected log-utility of terminal wealth:

\[ f^* = \operatorname{argmax}_f \; \mathbb{E}[\log(1 + f G)], \]

where \(G\) is the random gain per dollar risked. Under Gaussian returns, this gives the celebrated result:

\[ f^* = \frac{\mathbb{E}[G]}{\mathrm{Var}(G)}, \]

which is proportional to the Sharpe ratio of the bet. Full Kelly maximises growth but is extremely aggressive — a single bad run can permanently destroy a large fraction of capital. In practice, half-Kelly (\(f = f^*/2\)) is more common.

Second-order approximation#

For small gains, \(\log(1 + fG) \approx fG - \frac{1}{2}f^2G^2\), which gives:

\[ \mathbb{E}[\log(1 + fG)] \approx f\,\mathbb{E}[G] - \frac{f^2}{2}\,\mathbb{E}[G^2]. \]

Maximising over \(f\) (first-order condition) yields the second-order Kelly fraction:

\[ f^* \approx \frac{\mathbb{E}[G]}{\mathbb{E}[G^2]} = \frac{\mathbb{E}[G]}{\mathrm{Var}(G) + \mathbb{E}[G]^2}, \]

which is well-defined even when \(G\) has a non-Gaussian distribution, and easier to estimate than the exact optimum.

Kelly for mean-reversion: the truncated normal model#

In pairs trading the spread z-score is approximately standard normal. We introduce two fixed threshold parameters:

  • \(z_e > 0\) — the entry threshold: a position opens when \(|z_t| \geq z_e\)

  • \(z_r \in [0, z_e)\) — the reversion target: the position closes when \(|z_t|\) returns to \(z_r\)

The actual z-score values at the moment of entry and exit are random variables — we do not know in advance at exactly which bar the signal will fire or when the spread will revert. Denote:

  • \(z\) — the realized entry z-score (random, \(z \geq z_e\))

  • \(\tilde{z}\) — the realized exit z-score (random, \(\tilde{z} \leq z_r\))

The per-trade gain per unit of spread-sigma is then:

\[ G = z - \tilde{z}. \]

Modelling the distributions of \(z\) and \(\tilde{z}\) as truncated normals lets us compute \(\mathbb{E}[G]\) and \(\mathrm{Var}(G)\) in closed form.

Key tool — Inverse Mills ratios. For a truncated standard normal, the conditional mean involves the ratio of the PDF \(\varphi\) to the CDF \(\Phi\). Define:

\[ \lambda_+(a) = \frac{\varphi(a)}{1 - \Phi(a)}, \qquad \lambda_-(b) = \frac{\varphi(b)}{\Phi(b)}, \]

where \(\varphi\) and \(\Phi\) are the standard normal PDF and CDF, \(\lambda_+(a)\) is the left-truncation inverse Mills ratio (for a variable conditioned on being \(\geq a\)), and \(\lambda_-(b)\) is the right-truncation ratio (conditioned on being \(\leq b\)). Both are positive and grow as the truncation point moves into the tail.


The three Kelly sizers in spreadpy#

The three sizers differ in which of the two unknowns — entry level \(z\) or exit level \(\tilde{z}\) — is treated as random.

KellyTruncatedEntry — random entry, deterministic exit#

A trade opens at bar \(t\) only when \(z_t \geq z_e\). Conditioned on this event, the entry z-score is a left-truncated standard normal. Setting \(\lambda_+ = \lambda_+(z_e) = \varphi(z_e)/(1-\Phi(z_e))\), the standard result for truncated normals gives:

\[ \mathbb{E}[z_t \mid z_t \geq z_e] = \lambda_+, \qquad \mathrm{Var}(z_t \mid z_t \geq z_e) = 1 - \lambda_+(\lambda_+ - z_e). \]

The exit \(z_r\) is treated as a deterministic constant. The per-trade gain \(G = z_t - z_r\), conditioned on a trade having opened, has moments:

\[ \mathbb{E}[G \mid z_t \geq z_e] = \lambda_+ - z_r, \qquad \mathrm{Var}(G \mid z_t \geq z_e) = 1 - \lambda_+(\lambda_+ - z_e). \]

The Kelly objective is maximised conditionally on the trade being active, giving:

\[ f^* = \frac{\lambda_+ - z_r}{1 - \lambda_+(\lambda_+ - z_e) + (\lambda_+ - z_r)^2}. \]

This fraction is constant — computed once at construction from \(z_e\) and \(z_r\).

KellyTruncatedExit — deterministic entry, random exit#

The position closes when the spread reverts below \(z_r\). Conditioned on this event, the exit z-score is a right-truncated standard normal. Setting \(\lambda_- = \lambda_-(z_r) = \varphi(z_r)/\Phi(z_r)\), the standard result gives:

\[ \mathbb{E}[\tilde{z}_t \mid \tilde{z}_t \leq z_r] = -\lambda_-, \qquad \mathrm{Var}(\tilde{z}_t \mid \tilde{z}_t \leq z_r) = 1 - \lambda_-(\lambda_- + z_r). \]

The entry z-score \(z_t\) is the value observed when the signal fires — a known constant at sizing time. The per-trade gain \(G = z_t - \tilde{z}_t\), conditioned on the exit event, has moments:

\[ \mathbb{E}[G \mid \tilde{z}_t \leq z_r] = z_t + \lambda_-, \qquad \mathrm{Var}(G \mid \tilde{z}_t \leq z_r) = 1 - \lambda_-(\lambda_- + z_r). \]

The Kelly objective is maximised conditionally on the exit being a reversion below \(z_r\):

\[ f^*(z_t) = \frac{z_t + \lambda_-}{1 - \lambda_-(\lambda_- + z_r) + (z_t + \lambda_-)^2}. \]

This fraction depends on \(z_t\) and is recomputed at each bar: the deeper the observed entry z-score, the larger \(\mathbb{E}[G \mid \tilde{z}_t \leq z_r]\), and the larger the allocated fraction.

KellyTruncatedBoth — both random, independent#

Both the entry and exit z-scores are conditioned on their respective threshold events, and modelled as independent:

\[ z_t \mid z_t \geq z_e \;\perp\; \tilde{z}_t \mid \tilde{z}_t \leq z_r. \]

With \(\lambda_+ = \lambda_+(z_e)\) and \(\lambda_- = \lambda_-(z_r)\), the per-trade gain \(G = z_t - \tilde{z}_t\), conditioned on both the entry and exit events, has moments:

\[ \mathbb{E}[G \mid z_t \geq z_e,\, \tilde{z}_t \leq z_r] = \lambda_+ + \lambda_-, \]
\[\begin{split} \begin{align*} \mathrm{Var}(G \mid z_t \geq z_e,\, \tilde{z}_t \leq z_r) &= \mathrm{Var}(z_t \mid z_t \geq z_e) + \mathrm{Var}(\tilde{z}_t \mid \tilde{z}_t \leq z_r) \\ &= \bigl[1 - \lambda_+(\lambda_+ - z_e)\bigr] + \bigl[1 - \lambda_-(\lambda_- + z_r)\bigr], \end{align*} \end{split}\]

where independence allows the conditional variances to add. The Kelly fraction is:

\[ f^* = \frac{\lambda_+ + \lambda_-}{2 - \lambda_+(\lambda_+ - z_e) - \lambda_-(\lambda_- + z_r) + (\lambda_+ + \lambda_-)^2}. \]

Like KellyTruncatedEntry, this fraction is constant — both conditioning events are fully characterised by the fixed thresholds \(z_e\) and \(z_r\).


How \(f^*\) behaves as a function of \(z_e\)#

Setting \(z_r = 0\) (exit at the mean), \(\lambda_- = \lambda_-(0) = \varphi(0)/\Phi(0) \approx 0.80\) (fixed), and varying \(z_e\):

\(z_e\)

\(\lambda_+(z_e)\)

\(f^*\) (Entry, uncapped)

\(f^*\) (Both, uncapped)

0.5

1.14

0.73

0.44

1.0

1.53

0.60

0.39

1.5

1.94

0.50

0.34

2.0

2.37

0.41

0.30

2.5

2.84

0.35

0.27

Values computed from the closed-form formulas; a hard cap \(f_{\max} = 0.5\) would clip the first two rows of the Entry column. The \(\lambda_-\) term in the Both column pulls \(f^*\) down relative to Entry, because the randomness in the exit adds variance to \(G\) without changing \(\mathbb{E}[G]\) proportionally.

As \(z_e\) increases, \(\lambda_+(z_e)\) grows (the conditional mean of the entry z-score is higher), but the variance of the truncated distribution shrinks rapidly, and \(\mathbb{E}[G]^2\) dominates the denominator \(\mathbb{E}[G^2]\). The net effect is that \(f^*\) decreases with \(z_e\). Counterintuitively, waiting for a very large z-score does not justify a larger position — the distribution becomes more concentrated but the probability of occurrence drops exponentially, and the optimal fraction shrinks accordingly.


Comparing the sizers in spreadpy#

Setup#

import numpy as np
import yfinance as yf
from spreadpy.data import PriceTimeSeries
from spreadpy.spread.hedgeRatio.kalmanFilter import KalmanFilter
from spreadpy.spread import SpreadSeries
from spreadpy.signal.zScoreSignal import ZScoreSignal
from spreadpy.sizing.sizers.linearSizer import LinearSizer
from spreadpy.sizing.sizers.inverseVolSizer import InverseVolSizer
from spreadpy.sizing.sizers.kellySizers import (
    KellyTruncatedEntry, KellyTruncatedExit, KellyTruncatedBoth
)
from spreadpy.backtest.engine import BacktestEngine

raw = yf.download(["GLD", "GDX"], start="2018-01-01", end="2024-01-01",
                  auto_adjust=True)["Close"].dropna()
y = PriceTimeSeries(raw["GLD"], name="GLD")
x = PriceTimeSeries(raw["GDX"], name="GDX")

One signal, four sizers#

estimator  = KalmanFilter(alpha=1e-5)
signal_gen = ZScoreSignal(window=60, entry_threshold=1.0, revert_threshold=0.0)

sizers = {
    "Linear":       LinearSizer(max_notional=50_000),
    "Inverse-Vol":  InverseVolSizer(window=60, target_vol=0.02, f_max=0.5),
    "Kelly-Entry":  KellyTruncatedEntry(z_entry=1.0, z_revert=0.0, f_max=0.5),
    "Kelly-Exit":   KellyTruncatedExit(z_revert=0.0, f_max=0.5),
    "Kelly-Both":   KellyTruncatedBoth(z_entry=1.0, z_revert=0.0, f_max=0.5),
}

results = {}
for name, sizer in sizers.items():
    engine = BacktestEngine(
        estimator=estimator,
        signal_gen=signal_gen,
        sizer=sizer,
        initial_capital=1_000_000,
        train_frac=0.6,
        val_frac=0.2,
        log_prices=True,
    )
    _, test = engine.run(y, x)
    results[name] = test.metrics

Reading the results#

import pandas as pd
summary = pd.DataFrame(results).T[
    ["annualised_return", "volatility", "sharpe", "max_drawdown", "sortino"]
]
print(summary.round(3))

Expected output (illustrative — actual results depend on the period):

Sizer

Ann. Return

Volatility

Sharpe

Max DD

Sortino

Linear

+6.1%

8.4%

0.73

−9.2%

1.02

Inverse-Vol

+7.8%

7.1%

1.10

−7.4%

1.58

Kelly-Entry

+8.3%

9.2%

0.90

−10.1%

1.31

Kelly-Exit

+9.7%

10.8%

0.90

−12.3%

1.29

Kelly-Both

+8.6%

9.5%

0.91

−10.8%

1.33

What these numbers tell us#

Linear sizer is the weakest: it sizes by z-score magnitude, ignoring both volatility and the probabilistic structure of the trade. It earns alpha but leaves return on the table.

Inverse-vol achieves the best risk-adjusted metrics (Sharpe, Sortino, drawdown) because it explicitly targets a volatility budget. It is the right default for a risk-constrained mandate.

Kelly sizers maximise expected log-growth, not the Sharpe ratio — so they tend to be more aggressive. KellyTruncatedExit is the most dynamic: the position size scales directly with the observed \(z_t\) at entry, concentrating capital at the most favourable opportunities. This boosts raw returns at the cost of higher drawdown.

Which sizer to choose?

  • Risk-constrained mandate (target vol, regulatory capital): InverseVolSizer

  • Growth-oriented mandate (maximise CAGR): KellyTruncatedBoth with \(f_{\max} = 0.3\)

  • Dynamic sizing (position scales with signal strength): KellyTruncatedExit

  • Baseline / sanity check: LinearSizer

In all cases, cap the fraction at \(f_{\max} \leq 0.5\) and combine with a walk-forward validation to ensure the assumed distributions (\(z_{\text{entry}}\), \(z_{\text{revert}}\)) match the actual trading behaviour.


References#

  • Kelly, J. L. (1956). A new interpretation of information rate. Bell System Technical Journal, 35(4), 917–926.

  • Markowitz, H. (1952). Portfolio selection. Journal of Finance, 7(1), 77–91.

  • MacLean, L. C., Thorp, E. O. & Ziemba, W. T. (2010). The Kelly Capital Growth Investment Criterion. World Scientific.

  • Palomar, D. P. (2024). Portfolio Optimization: Theory and Application. Ch. 15.