Coverage for src / cvx / risk / sample / sample.py: 100%
88 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 06:46 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 06:46 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Risk models based on the sample covariance matrix.
16This module provides the SampleCovariance class, which implements a risk model
17based on the Cholesky decomposition of the sample covariance matrix. This is
18one of the most common approaches to portfolio risk estimation.
20Example:
21 Create and use a sample covariance risk model:
23 >>> import numpy as np
24 >>> from cvx.risk.sample import SampleCovariance
25 >>> # Create risk model for up to 3 assets
26 >>> model = SampleCovariance(num=3)
27 >>> # Update with a covariance matrix
28 >>> cov = np.array([[1.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 1.0]])
29 >>> model.update(
30 ... cov=cov,
31 ... lower_assets=np.zeros(3),
32 ... upper_assets=np.ones(3)
33 ... )
34 >>> # Estimate risk for a given portfolio
35 >>> weights = np.array([0.4, 0.3, 0.3])
36 >>> risk = model.estimate(weights)
37 >>> isinstance(risk, float)
38 True
40"""
42from __future__ import annotations
44from dataclasses import dataclass
45from typing import Any
47import clarabel
48import numpy as np
49from cvx.linalg import cholesky
50from scipy import sparse
52from cvx.core import Bounds, Model, Parameter, Variable
55@dataclass
56class SampleCovariance(Model):
57 """Risk model based on the Cholesky decomposition of the sample covariance matrix.
59 This model computes portfolio risk as the L2 norm of the product of the
60 Cholesky factor and the weights vector. Mathematically, if R is the upper
61 triangular Cholesky factor of the covariance matrix (R^T @ R = cov), then:
63 risk = ||R @ w||_2 = sqrt(w^T @ cov @ w)
65 This represents the portfolio standard deviation (volatility).
67 Attributes:
68 num: Maximum number of assets the model can handle. The model can be
69 updated with fewer assets, but not more.
71 Example:
72 Basic usage:
74 >>> import numpy as np
75 >>> from cvx.risk.sample import SampleCovariance
76 >>> model = SampleCovariance(num=2)
77 >>> model.update(
78 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]),
79 ... lower_assets=np.zeros(2),
80 ... upper_assets=np.ones(2)
81 ... )
82 >>> # Equal weight portfolio
83 >>> weights = np.array([0.5, 0.5])
84 >>> risk = model.estimate(weights)
85 >>> # Risk should be sqrt(0.5^2 * 1 + 0.5^2 * 2 + 2 * 0.5 * 0.5 * 0.5)
86 >>> bool(np.isclose(risk, 1.0))
87 True
89 Using in optimization:
91 >>> from cvx.risk.portfolio import minrisk_problem
92 >>> from cvx.core.variable import Variable
93 >>> weights = Variable(2)
94 >>> problem = minrisk_problem(model, weights)
95 >>> problem.solve()
96 >>> # Lower variance asset gets higher weight
97 >>> bool(weights.value[0] > weights.value[1])
98 True
100 Mathematical verification - the risk estimate equals sqrt(w^T @ cov @ w):
102 >>> model = SampleCovariance(num=3)
103 >>> cov = np.array([[0.04, 0.01, 0.02],
104 ... [0.01, 0.09, 0.01],
105 ... [0.02, 0.01, 0.16]])
106 >>> model.update(
107 ... cov=cov,
108 ... lower_assets=np.zeros(3),
109 ... upper_assets=np.ones(3)
110 ... )
111 >>> w = np.array([0.4, 0.35, 0.25])
112 >>> # Model estimate
113 >>> model_risk = model.estimate(w)
114 >>> # Manual calculation: sqrt(w^T @ cov @ w)
115 >>> manual_risk = np.sqrt(w @ cov @ w)
116 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-6))
117 True
119 """
121 num: int = 0
122 """Maximum number of assets the model can handle."""
124 def __post_init__(self) -> None:
125 """Initialize the parameters after the class is instantiated.
127 Creates the Cholesky decomposition parameter and initializes the bounds.
128 The Cholesky parameter is a square matrix of size (num, num), and bounds
129 are created for asset weights.
131 Example:
132 >>> from cvx.risk.sample import SampleCovariance
133 >>> model = SampleCovariance(num=5)
134 >>> # Parameters are automatically created
135 >>> model.parameter["chol"].shape
136 (5, 5)
138 """
139 self.parameter["chol"] = Parameter(
140 shape=(self.num, self.num),
141 name="cholesky of covariance",
142 )
143 self.bounds = Bounds(m=self.num, name="assets")
145 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float:
146 """Estimate the portfolio risk using the Cholesky decomposition.
148 Computes the L2 norm of the product of the Cholesky factor and the
149 weights vector. This is equivalent to the square root of the portfolio
150 variance (i.e., portfolio volatility).
152 Args:
153 weights: Numpy array representing portfolio weights.
154 **kwargs: Additional keyword arguments (not used).
156 Returns:
157 Float representing the portfolio risk (standard deviation).
159 Example:
160 >>> import numpy as np
161 >>> from cvx.risk.sample import SampleCovariance
162 >>> model = SampleCovariance(num=2)
163 >>> # Identity covariance (uncorrelated assets with unit variance)
164 >>> model.update(
165 ... cov=np.eye(2),
166 ... lower_assets=np.zeros(2),
167 ... upper_assets=np.ones(2)
168 ... )
169 >>> risk = model.estimate(np.array([0.5, 0.5]))
170 >>> isinstance(risk, float)
171 True
173 """
174 return float(np.linalg.norm(self.parameter["chol"].value @ np.asarray(weights)))
176 def update(self, **kwargs: Any) -> None:
177 """Update the Cholesky decomposition parameter and bounds.
179 Computes the Cholesky decomposition of the provided covariance matrix
180 and updates the model parameters. The covariance matrix can be smaller
181 than num x num.
183 Args:
184 **kwargs: Keyword arguments containing:
185 - cov: Covariance matrix (numpy.ndarray). Must be positive definite.
186 - lower_assets: Array of lower bounds for asset weights.
187 - upper_assets: Array of upper bounds for asset weights.
189 Example:
190 >>> import numpy as np
191 >>> from cvx.risk.sample import SampleCovariance
192 >>> model = SampleCovariance(num=5)
193 >>> # Update with a 3x3 covariance (smaller than max)
194 >>> cov = np.array([[1.0, 0.3, 0.1],
195 ... [0.3, 1.0, 0.2],
196 ... [0.1, 0.2, 1.0]])
197 >>> model.update(
198 ... cov=cov,
199 ... lower_assets=np.zeros(3),
200 ... upper_assets=np.ones(3)
201 ... )
202 >>> # Cholesky factor is updated
203 >>> model.parameter["chol"].value[:3, :3].shape
204 (3, 3)
206 """
207 cov = kwargs["cov"]
208 n = cov.shape[0]
210 chol = np.zeros((self.num, self.num))
211 chol[:n, :n] = cholesky(cov)
212 self.parameter["chol"].value = chol
213 self.bounds.update(**kwargs)
215 def solve_minrisk(
216 self,
217 weights: Variable,
218 base: np.ndarray,
219 extra_constraints: list[tuple[np.ndarray, float | None, float | None]],
220 y_var: Variable | None = None,
221 ) -> tuple[float | None, float | None, str]:
222 """Build and solve the Clarabel SOC problem for this model."""
223 n = weights.n
224 chol = self.parameter["chol"].value
225 lb, ub = self.bounds.get_bounds()
227 n_vars = 1 + n
228 P = sparse.csc_matrix((n_vars, n_vars)) # noqa: N806
229 q = np.zeros(n_vars)
230 q[0] = 1.0
232 A_rows: list[np.ndarray] = [] # noqa: N806
233 b_rows: list[np.ndarray] = []
234 cones: list[Any] = []
236 A_soc = np.zeros((n + 1, n_vars)) # noqa: N806
237 A_soc[0, 0] = -1.0
238 A_soc[1:, 1:] = -chol
239 b_soc = np.zeros(n + 1)
240 b_soc[1:] = -chol @ base
241 A_rows.append(A_soc)
242 b_rows.append(b_soc)
243 cones.append(clarabel.SecondOrderConeT(n + 1))
245 A_eq = np.zeros((1, n_vars)) # noqa: N806
246 A_eq[0, 1:] = 1.0
247 A_rows.append(A_eq)
248 b_rows.append(np.array([1.0]))
249 cones.append(clarabel.ZeroConeT(1))
251 A_lb = np.zeros((n, n_vars)) # noqa: N806
252 A_lb[:, 1:] = -np.eye(n)
253 A_rows.append(A_lb)
254 b_rows.append(-lb)
255 cones.append(clarabel.NonnegativeConeT(n))
257 A_ub = np.zeros((n, n_vars)) # noqa: N806
258 A_ub[:, 1:] = np.eye(n)
259 A_rows.append(A_ub)
260 b_rows.append(ub)
261 cones.append(clarabel.NonnegativeConeT(n))
263 for a, lb_val, ub_val in extra_constraints:
264 a = np.asarray(a)
265 if lb_val is not None and ub_val is not None and lb_val == ub_val:
266 A_extra = np.zeros((1, n_vars)) # noqa: N806
267 A_extra[0, 1:] = a
268 A_rows.append(A_extra)
269 b_rows.append(np.array([lb_val]))
270 cones.append(clarabel.ZeroConeT(1))
271 else:
272 if lb_val is not None:
273 A_extra = np.zeros((1, n_vars)) # noqa: N806
274 A_extra[0, 1:] = -a
275 A_rows.append(A_extra)
276 b_rows.append(np.array([-lb_val]))
277 cones.append(clarabel.NonnegativeConeT(1))
278 if ub_val is not None:
279 A_extra = np.zeros((1, n_vars)) # noqa: N806
280 A_extra[0, 1:] = a
281 A_rows.append(A_extra)
282 b_rows.append(np.array([ub_val]))
283 cones.append(clarabel.NonnegativeConeT(1))
285 A = sparse.csc_matrix(np.vstack(A_rows)) # noqa: N806
286 b = np.concatenate(b_rows)
288 settings = clarabel.DefaultSettings()
289 settings.verbose = False
290 sol = clarabel.DefaultSolver(P, q, A, b, cones, settings).solve()
291 status = str(sol.status)
293 if "Solved" in status:
294 weights.value = np.array(sol.x[1 : 1 + n])
295 return float(sol.obj_val), float(sol.x[0]), status
296 return None, None, status