fast-minimum-variance 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """fast_minimum_variance."""
@@ -0,0 +1,37 @@
1
+ """Convex optimization solver for the minimum variance portfolio."""
2
+
3
+ import cvxpy as cp
4
+
5
+
6
+ def minvar_cvxpy(R): # noqa: N803
7
+ """Solve the minimum variance portfolio via CVXPY.
8
+
9
+ Solves the long-only minimum variance problem::
10
+
11
+ min ||R w||_2^2
12
+ s.t. sum(w) = 1, w >= 0
13
+
14
+ using CVXPY with its default solver.
15
+
16
+ Args:
17
+ R: Return matrix of shape (T, N).
18
+
19
+ Returns:
20
+ Weight vector of shape (N,) summing to 1 with all non-negative entries.
21
+
22
+ Examples:
23
+ >>> import numpy as np
24
+ >>> from fast_minimum_variance.random import make_returns
25
+ >>> R = make_returns(100, 5, seed=0)
26
+ >>> w = minvar_cvxpy(R)
27
+ >>> w.shape
28
+ (5,)
29
+ >>> float(round(w.sum(), 6))
30
+ 1.0
31
+ >>> bool((w >= -1e-6).all())
32
+ True
33
+ """
34
+ n = R.shape[1]
35
+ w = cp.Variable(n)
36
+ cp.Problem(cp.Minimize(cp.sum_squares(R @ w)), [cp.sum(w) == 1, w >= 0]).solve()
37
+ return w.value
@@ -0,0 +1,81 @@
1
+ """KKT system construction for the minimum variance portfolio."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def build_kkt(R): # noqa: N803
7
+ """Build the KKT system matrix and RHS for the minimum variance problem.
8
+
9
+ Constructs the (N+1) x (N+1) indefinite KKT system for::
10
+
11
+ min w^T (R^T R) w
12
+ s.t. sum(w) = 1
13
+
14
+ The system has the form::
15
+
16
+ [ 2 R^T R 1 ] [ w ] [ 0 ]
17
+ [ 1^T 0 ] [ λ ] = [ 1 ]
18
+
19
+ Args:
20
+ R: Return matrix of shape (T, N).
21
+
22
+ Returns:
23
+ Tuple (A, b) where A is the (N+1) x (N+1) KKT matrix and b is the
24
+ (N+1,) right-hand side vector.
25
+
26
+ Examples:
27
+ >>> import numpy as np
28
+ >>> R = np.eye(3)
29
+ >>> A, b = build_kkt(R)
30
+ >>> A.shape
31
+ (4, 4)
32
+ >>> b
33
+ array([0., 0., 0., 1.])
34
+ """
35
+ n_a = R.shape[1]
36
+ A = np.zeros((n_a + 1, n_a + 1)) # noqa: N806
37
+ A[:n_a, :n_a] = 2 * R.T @ R
38
+ A[:n_a, n_a] = 1
39
+ A[n_a, :n_a] = 1
40
+ b = np.zeros(n_a + 1)
41
+ b[n_a] = 1
42
+ return A, b
43
+
44
+
45
+ def minvar_kkt(R): # noqa: N803
46
+ """Solve the minimum variance portfolio via the KKT system with active-set method.
47
+
48
+ Iteratively drops assets with negative weights until all remaining weights
49
+ are non-negative, solving the KKT system exactly at each iteration via
50
+ ``numpy.linalg.solve``.
51
+
52
+ Args:
53
+ R: Return matrix of shape (T, N).
54
+
55
+ Returns:
56
+ Weight vector of shape (N,) summing to 1 with all non-negative entries.
57
+
58
+ Examples:
59
+ >>> import numpy as np
60
+ >>> from fast_minimum_variance.random import make_returns
61
+ >>> R = make_returns(100, 5, seed=0)
62
+ >>> w = minvar_kkt(R)
63
+ >>> w.shape
64
+ (5,)
65
+ >>> float(round(w.sum(), 10))
66
+ 1.0
67
+ >>> bool((w >= 0).all())
68
+ True
69
+ """
70
+ n = R.shape[1]
71
+ active = np.ones(n, dtype=bool)
72
+ while True:
73
+ A, b = build_kkt(R[:, active]) # noqa: N806
74
+ sol = np.linalg.solve(A, b)
75
+ w_a = sol[: active.sum()]
76
+ if np.all(w_a >= -1e-10):
77
+ break
78
+ active[np.where(active)[0][w_a < 0]] = False
79
+ w = np.zeros(n)
80
+ w[active] = np.maximum(w_a, 0)
81
+ return w
@@ -0,0 +1,183 @@
1
+ """Krylov subspace solvers for the minimum variance portfolio."""
2
+
3
+ import numpy as np
4
+ from scipy.sparse.linalg import LinearOperator, cg, minres
5
+
6
+
7
+ def minvar_minres(R, c=1.0, gamma=0.0): # noqa: N803
8
+ """Solve the minimum variance portfolio via MINRES with active-set method.
9
+
10
+ Applies the active-set method, dropping assets with negative weights, and
11
+ solves the KKT saddle-point system at each iteration using MINRES. The KKT
12
+ matrix is applied as a LinearOperator — no explicit matrix is ever formed.
13
+ The matvec for x = [v; mu] is::
14
+
15
+ out[:n_a] = 2 (c R^T(Rv) + gamma v) + mu * 1
16
+ out[n_a] = 1^T v
17
+
18
+ With the defaults ``c=1, gamma=0`` this solves the standard sample-covariance
19
+ problem. To apply dimension-based Ledoit-Wolf shrinkage without materialising
20
+ a stacked return matrix, compute::
21
+
22
+ T, N = R.shape
23
+ frob_sq = (R * R).sum()
24
+ alpha = N / (N + T) # shrinkage intensity
25
+ c = 1.0 - alpha # = T / (N + T)
26
+ gamma = frob_sq / (N + T) # diagonal regularisation (= alpha * frob_sq / N)
27
+
28
+ and call ``minvar_minres(R, c=c, gamma=gamma)``.
29
+
30
+ Args:
31
+ R: Return matrix of shape (T, N).
32
+ c: Scaling factor for R^T R (default 1.0).
33
+ gamma: Diagonal regularisation added to the (1,1) block (default 0.0).
34
+
35
+ Returns:
36
+ Tuple (w, n_iters) where w is the weight vector of shape (N,) summing
37
+ to 1 with all non-negative entries and n_iters is the total number of
38
+ MINRES iterations across all active-set steps.
39
+
40
+ Examples:
41
+ >>> import numpy as np
42
+ >>> from fast_minimum_variance.random import make_returns
43
+ >>> R = make_returns(100, 5, seed=0)
44
+ >>> w, iters = minvar_minres(R)
45
+ >>> w.shape
46
+ (5,)
47
+ >>> float(round(w.sum(), 6))
48
+ 1.0
49
+ >>> bool((w >= 0).all())
50
+ True
51
+ >>> iters > 0
52
+ True
53
+ """
54
+ n = R.shape[1]
55
+ active = np.ones(n, dtype=bool)
56
+ total_iters = 0
57
+ while True:
58
+ r_a = R[:, active]
59
+ n_a = r_a.shape[1]
60
+
61
+ def _matvec(x, ra=r_a, na=n_a, cc=c, gam=gamma):
62
+ """Apply KKT operator [[2(c R^TR + gamma I), 1],[1^T, 0]] to x."""
63
+ out = np.empty(na + 1)
64
+ out[:na] = 2.0 * (cc * (ra.T @ (ra @ x[:na])) + gam * x[:na]) + x[na]
65
+ out[na] = x[:na].sum()
66
+ return out
67
+
68
+ b = np.zeros(n_a + 1)
69
+ b[n_a] = 1.0
70
+ kkt = LinearOperator(shape=(n_a + 1, n_a + 1), matvec=_matvec) # type: ignore[call-arg]
71
+ iters = [0]
72
+ sol, _ = minres(kkt, b, callback=lambda _x: iters.__setitem__(0, iters[0] + 1)) # noqa: B023
73
+ total_iters += iters[0]
74
+ w_a = sol[:n_a]
75
+ if np.all(w_a >= -1e-10):
76
+ break
77
+ active[np.where(active)[0][w_a < 0]] = False
78
+ w = np.zeros(n)
79
+ w[active] = np.maximum(w_a, 0)
80
+ w /= w.sum()
81
+ return w, total_iters
82
+
83
+
84
+ def minvar_cg(R, c=1.0, gamma=0.0): # noqa: N803
85
+ """Solve the minimum variance portfolio via CG in the constraint-reduced space.
86
+
87
+ Projects the problem onto the constraint-satisfying subspace using an
88
+ implicit Householder reflector as the null-space basis of the budget
89
+ constraint, then applies CG to the reduced positive-definite system
90
+ ``P^T (c R^T R + gamma I) P``. An active-set loop drops assets with
91
+ negative weights until feasibility is reached.
92
+
93
+ The Householder vector ``v = [1+sqrt(n_a), 1, ..., 1]`` with
94
+ ``beta = 1/(n_a + sqrt(n_a))`` defines the reflector H = I - beta*v*v^T.
95
+ Its last n_a-1 columns span the null space of 1^T and form the implicit
96
+ basis P. Applying P or P^T costs O(n_a) instead of O(n_a^2) for the
97
+ explicit QR matrix, and no O(n_a^2) matrix is ever formed. Because P has
98
+ orthonormal columns, ``P^T (gamma I) P = gamma I``, so the gamma term
99
+ adds only a scalar shift to each matvec.
100
+
101
+ With the defaults ``c=1, gamma=0`` this solves the standard sample-covariance
102
+ problem. See ``minvar_minres`` for the Ledoit-Wolf shrinkage recipe.
103
+
104
+ Args:
105
+ R: Return matrix of shape (T, N).
106
+ c: Scaling factor for R^T R (default 1.0).
107
+ gamma: Diagonal regularisation added to the objective (default 0.0).
108
+
109
+ Returns:
110
+ Tuple (w, n_iters) where w is the weight vector of shape (N,) summing
111
+ to 1 with all non-negative entries and n_iters is the total number of
112
+ CG iterations across all active-set steps.
113
+
114
+ Examples:
115
+ >>> import numpy as np
116
+ >>> from fast_minimum_variance.random import make_returns
117
+ >>> R = make_returns(100, 5, seed=0)
118
+ >>> w, iters = minvar_cg(R)
119
+ >>> w.shape
120
+ (5,)
121
+ >>> float(round(w.sum(), 6))
122
+ 1.0
123
+ >>> bool((w >= 0).all())
124
+ True
125
+ >>> iters > 0
126
+ True
127
+ """
128
+ n = R.shape[1]
129
+ active = np.ones(n, dtype=bool)
130
+ total_iters = 0
131
+ while True:
132
+ r_a = R[:, active]
133
+ n_a = r_a.shape[1]
134
+
135
+ if n_a == 1:
136
+ w_a = np.array([1.0])
137
+ break
138
+
139
+ # Implicit Householder basis for null(1^T): v = [1+sqrt(n_a), 1,...,1]
140
+ sqrt_na = np.sqrt(float(n_a))
141
+ beta = 1.0 / (n_a + sqrt_na)
142
+ v0 = 1.0 + sqrt_na
143
+
144
+ def _p_apply(y, b=beta, vv=v0, na=n_a):
145
+ """Apply implicit P (n_a x n_a-1) to y: O(n_a)."""
146
+ s = y.sum()
147
+ out = np.empty(na)
148
+ out[0] = -b * vv * s
149
+ out[1:] = y - (b * s)
150
+ return out
151
+
152
+ def _pt_apply(u, b=beta, vv=v0):
153
+ """Apply implicit P^T (n_a-1 x n_a) to u: O(n_a)."""
154
+ s = vv * u[0] + u[1:].sum()
155
+ return u[1:] - (b * s)
156
+
157
+ w0 = np.ones(n_a) / n_a
158
+ r0 = r_a @ w0
159
+
160
+ def _matvec(y, ra=r_a, b=beta, vv=v0, na=n_a, cc=c, gam=gamma):
161
+ """Apply P^T (c R^T R + gamma I) P to y via implicit Householder."""
162
+ s = y.sum()
163
+ pv = np.empty(na)
164
+ pv[0] = -b * vv * s
165
+ pv[1:] = y - (b * s)
166
+ rpv = cc * (ra.T @ (ra @ pv)) + gam * pv
167
+ sv = vv * rpv[0] + rpv[1:].sum()
168
+ return rpv[1:] - (b * sv)
169
+
170
+ g0 = c * (r_a.T @ r0) + gamma * w0
171
+ rhs = -_pt_apply(g0)
172
+ op = LinearOperator(shape=(n_a - 1, n_a - 1), matvec=_matvec) # type: ignore[call-arg]
173
+ iters = [0]
174
+ sol, _ = cg(op, rhs, callback=lambda _x: iters.__setitem__(0, iters[0] + 1)) # noqa: B023
175
+ total_iters += iters[0]
176
+ w_a = w0 + _p_apply(sol)
177
+ if np.all(w_a >= -1e-10):
178
+ break
179
+ active[np.where(active)[0][w_a < 0]] = False
180
+ w = np.zeros(n)
181
+ w[active] = np.maximum(w_a, 0)
182
+ w /= w.sum()
183
+ return w, total_iters
@@ -0,0 +1,26 @@
1
+ """Return matrix generation utilities."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def make_returns(T, N, seed=42): # noqa: N803
7
+ """Generate a T x N matrix of standard normal returns.
8
+
9
+ Args:
10
+ T: Number of time steps (rows).
11
+ N: Number of assets (columns).
12
+ seed: Random seed for reproducibility.
13
+
14
+ Returns:
15
+ Array of shape (T, N) with i.i.d. standard normal entries.
16
+
17
+ Examples:
18
+ >>> R = make_returns(100, 5, seed=0)
19
+ >>> R.shape
20
+ (100, 5)
21
+ >>> import numpy as np
22
+ >>> np.allclose(R.mean(axis=0), np.zeros(5), atol=0.3)
23
+ True
24
+ """
25
+ rng = np.random.default_rng(seed)
26
+ return rng.standard_normal((T, N))
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: fast-minimum-variance
3
+ Version: 0.2.0
4
+ Summary: Add your description here
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: cvxpy>=1.0
8
+ Requires-Dist: numpy>=2.0.0
9
+ Requires-Dist: scipy>=1.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # [fast-minimum-variance](https://jebel-quant.github.io/fast_minimum_variance): Solving Minimum Variance Portfolios Fast
13
+
14
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://pypi.org/project/fast-minimum-variance/)
15
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/Jebel-Quant/fast_minimum_variance/blob/main/LICENSE)
16
+ [![Rhiza](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FJebel-Quant%2Ffast_minimum_variance%2Fmain%2F.rhiza%2Ftemplate.yml&query=%24.ref&label=rhiza)](https://github.com/jebel-quant/rhiza)
17
+
18
+ ## Overview
19
+
20
+ **fast-minimum-variance** is a Python library for computing long-only minimum variance
21
+ portfolios without ever forming the sample covariance matrix. By operating directly on
22
+ the returns matrix $R \in \mathbb{R}^{T \times N}$, it exposes a clean hierarchy of
23
+ solvers — from an exact direct KKT solve to matrix-free Krylov methods — that scale
24
+ gracefully as $N$ grows.
25
+
26
+ The core insight is that minimising portfolio variance is equivalent to minimising
27
+ $\|Rw\|^2$, which can be evaluated using two matrix-vector products $w \mapsto R^\top(Rw)$
28
+ without constructing $R^\top R$ explicitly. This reframing connects the portfolio
29
+ optimisation literature directly to Krylov subspace methods.
30
+
31
+ The long-only constraint $w \geq 0$ is handled throughout via an **active-set method**:
32
+ solve the unconstrained problem on the current active set, drop assets with negative
33
+ weights, and repeat. The process terminates in at most $N$ iterations.
34
+
35
+ ## Solvers
36
+
37
+ | Solver | Module | Method | Notes |
38
+ |---|---|---|---|
39
+ | `minvar_kkt` | `kkt` | Direct KKT via `numpy.linalg.solve` | Exact; baseline for accuracy comparisons |
40
+ | `minvar_minres` | `krylov` | MINRES on the indefinite KKT system | Matrix-free capable; handles indefiniteness correctly |
41
+ | `minvar_cg` | `krylov` | CG in the constraint-reduced space | Positive-definite reduced system; no indefinite solver needed |
42
+ | `minvar_cvxpy` | `cvx` | General-purpose convex solver via CVXPY | Reference implementation; slowest but most flexible |
43
+
44
+ All solvers return a weight vector $w \in \mathbb{R}^N$ satisfying $\sum_i w_i = 1$ and $w_i \geq 0$.
45
+
46
+ ## Quick Start
47
+
48
+ ```python
49
+ from fast_minimum_variance.random import make_returns
50
+ from fast_minimum_variance.kkt import minvar_kkt
51
+ from fast_minimum_variance.krylov import minvar_cg, minvar_minres
52
+ from fast_minimum_variance.cvx import minvar_cvxpy
53
+
54
+ # Generate a synthetic return matrix: 500 daily returns, 20 assets
55
+ R = make_returns(T=500, N=20, seed=42)
56
+
57
+ # Solve with any of the available solvers
58
+ w_kkt = minvar_kkt(R) # exact KKT solve
59
+ w_minres = minvar_minres(R) # MINRES on the indefinite KKT system
60
+ w_cg = minvar_cg(R) # CG in the constraint-reduced space
61
+ w_cvxpy = minvar_cvxpy(R) # CVXPY reference
62
+
63
+ # All solutions satisfy the portfolio constraints
64
+ assert abs(w_kkt.sum() - 1.0) < 1e-8
65
+ assert (w_kkt >= 0).all()
66
+ ```
67
+
68
+ ## The KKT System
69
+
70
+ The equality-constrained minimum variance problem yields the $(N+1) \times (N+1)$ KKT system:
71
+
72
+ $$\begin{pmatrix} 2R^\top R & \mathbf{1} \cr \mathbf{1}^\top & 0 \end{pmatrix} \begin{pmatrix} w \cr \lambda \end{pmatrix} = \begin{pmatrix} \mathbf{0} \cr 1 \end{pmatrix}$$
73
+
74
+ This system is **symmetric but indefinite** — the zero in the bottom-right corner of the
75
+ KKT matrix introduces a negative eigenvalue. This rules out standard CG on the full system,
76
+ but it opens the door to MINRES. Alternatively, the CG solver eliminates the constraint
77
+ entirely by parameterising $w = w_0 + Pv$ where $P$ spans the null space of
78
+ $\mathbf{1}^\top$, yielding a positive-definite reduced system of size $(N-1) \times (N-1)$.
79
+
80
+ ## Installation
81
+
82
+ ```bash
83
+ pip install fast-minimum-variance
84
+ ```
85
+
86
+ For development:
87
+
88
+ ```bash
89
+ git clone https://github.com/Jebel-Quant/fast_minimum_variance
90
+ cd fast_minimum_variance
91
+ make install
92
+ ```
93
+
94
+ ## Requirements
95
+
96
+ - Python 3.11+
97
+ - numpy
98
+ - scipy
99
+ - cvxpy
100
+
101
+ ## Citing
102
+
103
+ If you use this library in academic work or research, please cite:
104
+
105
+ ```bibtex
106
+ @software{fast_minimum_variance,
107
+ author = {Schmelzer, Thomas},
108
+ title = {fast-minimum-variance: Solving Minimum Variance Portfolios Fast},
109
+ url = {https://github.com/Jebel-Quant/fast_minimum_variance},
110
+ year = {2026},
111
+ license = {MIT}
112
+ }
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT License — see [LICENSE](https://github.com/Jebel-Quant/fast_minimum_variance/blob/main/LICENSE) for details.
@@ -0,0 +1,9 @@
1
+ fast_minimum_variance/__init__.py,sha256=TFCNorOmpOBZIzu8rVZgpAH9LPlw-4YDsQeDsV50BsU,29
2
+ fast_minimum_variance/cvx.py,sha256=bXtYiJaJtGHdhcsv3MAj-c9Vg5fknGXxPSbMcgkA5sw,957
3
+ fast_minimum_variance/kkt.py,sha256=BYjr4HkpV3_b2jJ0j5oX1l5_3XN7vFw6BHadOqYAWo8,2139
4
+ fast_minimum_variance/krylov.py,sha256=lxmWNnhLwcG_QTTv5zEJt5xTwJ0CHtLAJyghZjxM5Pw,6665
5
+ fast_minimum_variance/random.py,sha256=76M-13ExNggETggw44CuQ43JTARpOkCA8u-03-LzME0,677
6
+ fast_minimum_variance-0.2.0.dist-info/METADATA,sha256=DRqIAS7_Y046DODPpYAF6qUEQgiIgB6_YuQry51oGeM,4672
7
+ fast_minimum_variance-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ fast_minimum_variance-0.2.0.dist-info/licenses/LICENSE,sha256=4m5X7LhqX-6D0Ks79Ys8CLpmza8cxDG34g4S9XSNAGY,1077
9
+ fast_minimum_variance-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jebel Quant Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.