Mean-Variance Position Sizing
Size positions optimally as μ/σ² scaled by risk aversion — Markowitz meets dynamic trading.
The Intuition
Mean-Variance Optimization, introduced by Harry Markowitz (1952), is the mathematical foundation of modern portfolio theory. Applied as a dynamic single-asset strategy, it determines the theoretically optimal position size at each point in time given estimates of expected return (mu) and variance (sigma²). Unlike binary long/short signals, it produces a continuously variable position that shrinks as uncertainty increases and grows as the risk-adjusted return improves.
The Merton (1969) intertemporal consumption model shows that the optimal risky asset allocation for a CRRA investor is exactly w* = mu / (sigma² × risk_aversion). This is the formula we implement. With risk_aversion = 1, the position is the unconstrained Kelly fraction — the growth-optimal bet size. Higher risk aversion shrinks the position, acting as a conservative multiplier on the Kelly fraction.
The key advantage over a binary signal: position sizing is proportional to the signal strength. When mu is large relative to sigma², the strategy takes a large long position. When mu and sigma² are both large (high-momentum but also high-volatility), the position is moderated by the risk. This volatility-targeting property means the strategy naturally de-risks during volatile periods — unlike fixed-size strategies that maintain full exposure through market turbulence.
Key assumptions: (1) The rolling estimates of mu and sigma² are unbiased predictors of future return and variance. In practice, expected return estimation is notoriously noisy — the signal-to-noise ratio in mu estimates is very low at daily to weekly horizons. (2) Returns are approximately normally distributed — fat tails mean the Gaussian variance understates true risk. (3) The position is continuously rebalanced — in reality, transaction costs create a "no-trade zone" around the optimal position.
The strategy's practical failure mode is estimation error in mu: rolling sample means of returns are extremely noisy. A 60-day rolling mean of daily returns has a standard error of approximately sigma / sqrt(60), which is typically larger than the true expected daily return. This noise creates excessive position turnover and whipsaw losses. Practitioners often use shrinkage estimators (e.g., James-Stein) or factor-based expected return models to reduce estimation error.
The Math
Read this as a compact model summary: what the signal sees, what it ignores, and where fragility can creep in.
mu(t) = mean(r[t-n:t]) × 252 [annualised return] sigma2(t) = var(r[t-n:t]) × 252 [annualised variance] w*(t) = mu(t) / (sigma2(t) × risk_aversion) Position(t) = clip(w*(t), -1, +1)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| window | int | 60 | Rolling window for estimating μ and σ |
| risk_aversion | float | 1.0 | Risk aversion coefficient (higher = smaller positions). Applied to a scale-normalised Merton weight, so it changes average exposure across roughly 0.25-8 instead of being absorbed by the leverage cap. |
| scale_window | int | 252 | Trailing window used to normalise the mu/sigma^2 ratio to an O(1) scale. Backward-looking only. |
| max_leverage | float | 1.0 | Absolute cap on the resulting position size. |
Source Code
def run(ticker: str, start: str, end: str, **params) -> dict:
window = int(params.get("window", 60))
risk_aversion = max(1e-6, float(params.get("risk_aversion", 1.0)))
scale_window = max(window, int(params.get("scale_window", 252)))
max_leverage = max(1e-6, float(params.get("max_leverage", 1.0)))
df = fetch_ohlcv(ticker, start, end)
log_ret = np.log(df["Close"] / df["Close"].shift(1))
mu = log_ret.rolling(window).mean() * 252
sigma2 = (log_ret.rolling(window).std() ** 2) * 252
# Merton/Markowitz optimal weight, mu / (gamma * sigma^2).
merton = mu / sigma2.replace(0, np.nan)
# Rescaling. Raw mu/sigma^2 off a short rolling window is enormous and wildly
# dispersed (annualising a 60-day drift routinely gives |mu/sigma^2| in the
# tens), so `.clip(-1, 1)` bound on almost every bar and the strategy
# collapsed to sign(mu) — risk_aversion had no effect whatsoever.
# Normalising by the trailing median magnitude of the same ratio puts the
# signal on a stable O(1) scale, so risk_aversion genuinely modulates average
# exposure across its documented range instead of being swallowed by the clip.
# The normaliser is backward-looking (rolling median of realised values), so
# it introduces no look-ahead.
scale = merton.abs().rolling(scale_window, min_periods=window).median()
scale = scale.replace(0, np.nan)
raw_pos = merton / (scale * risk_aversion)
pos = raw_pos.clip(-max_leverage, max_leverage).fillna(0.0)
return run_backtest(df, pos, ticker=ticker, start=start, end=end,
strategy=METADATA["slug"],
params={
"window": window,
"risk_aversion": risk_aversion,
"scale_window": scale_window,
"max_leverage": max_leverage,
},
**resolve_friction(params))
Further Reading
- Markowitz, H. (1952). Portfolio Selection. Journal of Finance, 7(1), 77–91.
- Merton, R. (1969). Lifetime Portfolio Selection Under Uncertainty: The Continuous-Time Case. Review of Economics and Statistics, 51(3), 247–257.
- Grinold, R. & Kahn, R. (2000). Active Portfolio Management, 2nd ed. McGraw-Hill.
- Chan, E. (2013). Algorithmic Trading, Ch. 5. Wiley.
When It Works / When It Fails
- Instruments with stable estimated mean and variance
- Portfolios where risk-budgeting adds measurable value
- When Gaussian return assumptions approximately hold
- High-uncertainty environments with noisy mean estimates
- Fat-tailed return distributions (Gaussian assumption violated)
- Regime shifts where parameters move faster than the window