fast-minimum-variance: Solving Minimum Variance Portfolios Fast
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
Working directly with the returns matrix
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, iters = 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 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:
T, N = X.shape
w, iters = Problem(X, alpha=N / (N + T)).solve_cg()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.
All solvers are methods on Problem and return (w, iters) where
| Method | Approach | When to use |
|---|---|---|
solve_cg() |
Matrix-free conjugate gradients on the SPD reduced system | Default — fastest for large |
solve_kkt() |
Direct dense factorisation via numpy.linalg.solve
|
Small problems or when an exact solve is needed |
solve_nnls() |
Non-negative least squares via Lawson-Hanson | Single-shot; useful when no outer loop is desired |
solve_clarabel() |
Clarabel interior-point solver (direct API) | Comparison baseline without CVXPY overhead |
solve_osqp() |
OSQP operator-splitting QP solver (direct API) | Alternative QP baseline; faster than Clarabel on medium problems |
solve_cvxpy() |
CVXPY + Clarabel | Ground-truth reference |
The inner step builds a LinearOperator that applies
to a vector using two matrix-vector products with the active-asset submatrix
Assembles numpy.linalg.solve. Exact to machine precision. Scales as
Reformulates the problem as a non-negative least squares problem on an augmented matrix:
where solve_nnls slower with
shrinkage than without.
Calls the Clarabel interior-point solver directly, bypassing CVXPY's problem-construction
overhead. Assembles solve_cvxpy's time is
Python interface overhead, not solving. CG is still 15× faster than Clarabel direct.
Calls the OSQP operator-splitting QP solver directly, bypassing CVXPY overhead. Assembles
iters return value is the ADMM iteration count.
CG is still 4–6× faster than OSQP for large
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
The same solver handles a range of portfolio construction problems by choosing
| Problem | alpha |
rho |
mu |
|---|---|---|---|
| Minimum variance | — | ||
| Mean-variance (Markowitz) | any | expected returns | |
| Minimum tracking error to benchmark |
any | X.T @ (X @ b) |
|
| LW-regularised minimum variance | — |
# 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:
For problems beyond budget + long-only (sector limits, turnover bounds, factor-exposure constraints), pass explicit constraint matrices:
A = np.ones((N, 1)) # budget: 1'w = 1
b = np.ones(1)
C = -np.eye(N) # long-only: w >= 0
d = np.zeros(N)
w, _ = Problem(X, A=A, b=b, C=C, d=d).solve_kkt()This routes to a general active-set solver that handles arbitrary linear equality and
inequality constraints. Use this path sparingly — the default path (no A, b, C, d)
is significantly faster for the standard long-only problem.
All timings on Apple M4 Pro, Python 3.12, NumPy 2.4, SciPy 1.17.
| Method | Time (s) | Speedup vs CVXPY |
|---|---|---|
solve_cvxpy |
8.16 | 1× |
solve_clarabel |
0.28 | 29× |
solve_osqp |
0.12 | 68× |
solve_kkt |
0.063 | 129× |
solve_cg |
0.019 | 430× |
solve_nnls |
1.69 | 5× |
With Ledoit-Wolf shrinkage ($\alpha = 0.333$), 56 CG iterations.
| Method | Time (s) | Speedup vs CVXPY |
|---|---|---|
solve_cvxpy |
1.48 | 1× |
solve_clarabel |
0.067 | 22× |
solve_osqp |
0.036 | 41× |
solve_kkt |
0.018 | 84× |
solve_cg |
0.0091 | 162× |
solve_nnls |
0.088 | 17× |
With Ledoit-Wolf shrinkage ($\alpha = 0.293$), 205 CG iterations.
pip install fast-minimum-varianceFor development:
git clone https://github.com/Jebel-Quant/fast_minimum_variance
cd fast_minimum_variance
make install- Python 3.11+
- numpy
- scipy
- cvxpy
- clarabel
- osqp
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}
}MIT License — see LICENSE for details.