fast-minimum-variance: Solving Minimum Variance Portfolios Fast¶
Overview¶
fast-minimum-variance solves the long-only minimum variance portfolio without ever forming the sample covariance matrix. The key observation is that the KKT stationarity condition $2\Sigma w = \lambda\mathbf{1}$ immediately gives $w \propto \Sigma^{-1}\mathbf{1}$: the entire problem reduces to one symmetric positive definite linear system $\Sigma v = \mathbf{1}$, solved matrix-free by conjugate gradients. The budget constraint is recovered by a single rescaling $w = v / (\mathbf{1}^\top v)$.
Working directly with the returns matrix $X \in \mathbb{R}^{T \times N}$ — rather than the assembled covariance $X^\top X$ — has two consequences. First, each conjugate gradient iteration costs $O(TN)$ rather than $O(N^2)$, and $X^\top X$ is never stored. Second, Ledoit-Wolf shrinkage enters as a simple row-augmentation of $X$: stacking $[\sqrt{1-\alpha}\,X;\,\sqrt{\gamma}\,I]$ yields a matrix whose Gram matrix equals $\Sigma_{\text{LW}}$. The same CG code handles both the plain and shrunk problem without modification.
Quick Start¶
import numpy as np
from fast_minimum_variance import Problem
# 500 daily returns, 20 assets
X = np.random.default_rng(42).standard_normal((500, 20))
w, outer, inner = Problem(X).solve_cg() # matrix-free CG — recommended
w, iters = Problem(X).solve_kkt() # direct dense solve — exact baseline
assert abs(w.sum() - 1.0) < 1e-8
assert (w >= 0).all()
Ledoit-Wolf Shrinkage¶
Ledoit-Wolf shrinkage plays a dual role: statistically it reduces estimation error; numerically
it compresses the eigenvalue spectrum and directly cuts CG iteration counts. Use
alpha = N / (N + T) as a simple analytical estimate of the optimal shrinkage intensity:
On S&P 500 equity data (495 assets, 1192 days), shrinkage cuts CG iterations from 685 to 205 and makes the matrix-free solver the fastest option by a wide margin.
Solvers¶
All solvers are methods on Problem and return (w, ...) where
$w \in \mathbb{R}^N$, $\sum_i w_i = 1$, $w_i \geq 0$.
solve_cg returns (w, outer_steps, inner_iters); all others return (w, iters).
| Method | Approach | When to use |
|---|---|---|
solve_cg() |
Matrix-free conjugate gradients on the SPD reduced system | Default — fastest for large $N$, especially with shrinkage |
solve_kkt() |
Direct dense factorisation via numpy.linalg.solve |
Small problems or when an exact solve is needed |
solve_cvxpy() |
CVXPY + Clarabel | Ground-truth reference |
solve_cg — matrix-free conjugate gradients¶
The inner step builds a LinearOperator that applies
$$v \;\mapsto\; (1-\alpha)\,X_a^\top(X_a v) + \gamma v, \qquad \gamma = \frac{\alpha|X|_F^2}{N}$$
to a vector using two matrix-vector products with the active-asset submatrix $X_a$, without ever forming $\Sigma_a = X_a^\top X_a$. Standard CG then solves $\Sigma_a v = \mathbf{1}$. Ledoit-Wolf shrinkage ($\alpha > 0$) compresses the eigenvalue spectrum and reduces iteration counts dramatically — from nearly 2000 iterations at $\alpha \approx 0$ to single digits at $\alpha \approx 1$ in rank-deficient settings.
solve_kkt — direct dense solve¶
Assembles $\Sigma_a = (1-\alpha)X_a^\top X_a + \gamma I$ explicitly and calls
numpy.linalg.solve. Exact to machine precision. Scales as $O(N^3)$ in the active
portfolio size, so it becomes expensive for $N \gtrsim 500$ without shrinkage (which
reduces the number of active assets). With shrinkage, the active-set outer loop converges
in 2–4 steps and the inner systems are small, making the direct solve competitive.
solve_cvxpy — CVXPY reference¶
Builds the problem with CVXPY and solves it with the Clarabel backend. This is the ground-truth reference used to validate the fast solvers; it carries substantial Python problem-construction overhead and is not intended for production use.
The Primal-Dual Active-Set Loop¶
Long-only weights are enforced by an outer loop that wraps any inner solver:
- Primal step. Solve the budget-only equality system over the current active asset set. Drop any asset with weight below $-\varepsilon$ (multiple assets at once if violations are large).
- Dual step. Once all active weights are non-negative, compute the gradient $\nabla_i f(w) = 2[(1-\alpha)(X^\top X w)_i + \gamma w_i] - \rho\mu_i$ for every excluded asset. If any excluded asset has $\nabla_i f(w) < \lambda$ (the budget multiplier), it would decrease variance if added — re-insert the most-violated asset and repeat.
- Termination. The loop exits when primal and dual feasibility hold simultaneously. Combined with stationarity from the inner solve, this is sufficient for global optimality.
With Ledoit-Wolf shrinkage at the analytically optimal $\alpha$, the loop typically converges in 2–4 outer iterations on real equity data.
Problem Variants¶
The same solver handles a range of portfolio construction problems by choosing $\alpha$, $\rho$, $\mu$:
| Problem | alpha |
rho |
mu |
|---|---|---|---|
| Minimum variance | $0$ | $0$ | — |
| Mean-variance (Markowitz) | any | $> 0$ | expected returns |
| Minimum tracking error to benchmark $b$ | any | $2$ | X.T @ (X @ b) |
| LW-regularised minimum variance | $N/(N+T)$ | $0$ | — |
# Mean-variance
mu = np.random.default_rng(0).standard_normal(N) # expected returns, shape (N,)
w, *_ = Problem(X, rho=1.0, mu=mu).solve_cg()
# Minimum tracking error to benchmark b
b = np.ones(N) / N # equal-weight benchmark
mu_te = X.T @ (X @ b)
w, *_ = Problem(X, rho=2.0, mu=mu_te).solve_cg()
When rho != 0, two SPD solves are performed per outer step: $\Sigma_a v_1 = \mathbf{1}$
and $\Sigma_a v_2 = \mu_a$. The budget multiplier $\lambda$ is recovered analytically
from the budget constraint, avoiding the full saddle-point system.
Balance Systems¶
To replace the default budget constraint $\mathbf{1}^\top w = 1$ with a general set of
linear equality constraints $B w = c$ (e.g. sleeve budgets, factor-exposure targets),
pass a balance system (B, c):
B = np.zeros((2, N)); B[0, :N // 2] = 1.0; B[1, N // 2:] = 1.0 # each half holds...
c = np.array([0.5, 0.5]) # ...half of the budget
w, _ = Problem(X, B=B, c=c).solve_kkt()
Long-only ($w \ge 0$) is still enforced. B must have full row rank on every active set
the shrinking loop visits. Use this path only when you need it — the default path (no B,
c) is faster for the standard budget + long-only problem.
Benchmarks¶
All timings on Apple M4 Pro, Python 3.12, NumPy 2.4, SciPy 1.17.
Synthetic: $N=1000$, $T=2000$, i.i.d. Gaussian returns¶
| Method | Time (s) | Speedup vs CVXPY |
|---|---|---|
solve_cvxpy |
8.16 | 1× |
solve_kkt |
0.063 | 129× |
solve_cg |
0.019 | 430× |
With Ledoit-Wolf shrinkage ($\alpha = 0.333$), 56 CG iterations.
S&P 500: $N=495$, $T=1192$ (Jul 2021–Apr 2026)¶
| Method | Time (s) | Speedup vs CVXPY |
|---|---|---|
solve_cvxpy |
1.48 | 1× |
solve_kkt |
0.018 | 84× |
solve_cg |
0.0091 | 162× |
With Ledoit-Wolf shrinkage ($\alpha = 0.293$), 205 CG iterations.
Installation¶
For development:
git clone https://github.com/Jebel-Quant/fast_minimum_variance
cd fast_minimum_variance
make install
Requirements¶
- Python 3.11+
- numpy
- scipy
- cvxpy
Citing¶
If you use this library in academic work or research, please cite:
@software{fast_minimum_variance,
author = {Schmelzer, Thomas},
title = {fast-minimum-variance: Solving Minimum Variance Portfolios Fast},
url = {https://github.com/Jebel-Quant/fast_minimum_variance},
year = {2026},
license = {MIT}
}
License¶
MIT License — see LICENSE for details.