Coverage for src / cvx / risk / sample / sample.py: 100%
26 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-09 03:39 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-09 03:39 +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 cvxpy as cp
24 >>> import numpy as np
25 >>> from cvx.risk.sample import SampleCovariance
26 >>> # Create risk model for up to 3 assets
27 >>> model = SampleCovariance(num=3)
28 >>> # Update with a covariance matrix
29 >>> cov = np.array([[1.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 1.0]])
30 >>> model.update(
31 ... cov=cov,
32 ... lower_assets=np.zeros(3),
33 ... upper_assets=np.ones(3)
34 ... )
35 >>> # Estimate risk for a given portfolio
36 >>> weights = np.array([0.4, 0.3, 0.3])
37 >>> risk = model.estimate(weights).value
38 >>> isinstance(risk, float)
39 True
41"""
43from __future__ import annotations
45from dataclasses import dataclass
46from typing import Any, cast
48import cvxpy as cvx
49import numpy as np
51from cvx.risk.bounds import Bounds
52from cvx.risk.linalg import cholesky
53from cvx.risk.model import Model
56@dataclass
57class SampleCovariance(Model):
58 """Risk model based on the Cholesky decomposition of the sample covariance matrix.
60 This model computes portfolio risk as the L2 norm of the product of the
61 Cholesky factor and the weights vector. Mathematically, if R is the upper
62 triangular Cholesky factor of the covariance matrix (R^T @ R = cov), then:
64 risk = ||R @ w||_2 = sqrt(w^T @ cov @ w)
66 This represents the portfolio standard deviation (volatility).
68 Attributes:
69 num: Maximum number of assets the model can handle. The model can be
70 updated with fewer assets, but not more.
72 Example:
73 Basic usage:
75 >>> import cvxpy as cp
76 >>> import numpy as np
77 >>> from cvx.risk.sample import SampleCovariance
78 >>> model = SampleCovariance(num=2)
79 >>> model.update(
80 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]),
81 ... lower_assets=np.zeros(2),
82 ... upper_assets=np.ones(2)
83 ... )
84 >>> # Equal weight portfolio
85 >>> weights = np.array([0.5, 0.5])
86 >>> risk = model.estimate(weights).value
87 >>> # Risk should be sqrt(0.5^2 * 1 + 0.5^2 * 2 + 2 * 0.5 * 0.5 * 0.5)
88 >>> bool(np.isclose(risk, 1.0))
89 True
91 Using in optimization:
93 >>> from cvx.risk.portfolio import minrisk_problem
94 >>> weights = cp.Variable(2)
95 >>> problem = minrisk_problem(model, weights)
96 >>> _ = problem.solve(solver="CLARABEL")
97 >>> # Lower variance asset gets higher weight
98 >>> bool(weights.value[0] > weights.value[1])
99 True
101 Mathematical verification - the risk estimate equals sqrt(w^T @ cov @ w):
103 >>> model = SampleCovariance(num=3)
104 >>> cov = np.array([[0.04, 0.01, 0.02],
105 ... [0.01, 0.09, 0.01],
106 ... [0.02, 0.01, 0.16]])
107 >>> model.update(
108 ... cov=cov,
109 ... lower_assets=np.zeros(3),
110 ... upper_assets=np.ones(3)
111 ... )
112 >>> w = np.array([0.4, 0.35, 0.25])
113 >>> # Model estimate
114 >>> model_risk = model.estimate(w).value
115 >>> # Manual calculation: sqrt(w^T @ cov @ w)
116 >>> manual_risk = np.sqrt(w @ cov @ w)
117 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-6))
118 True
120 Using with correlation matrix and volatilities:
122 >>> # Construct covariance from correlation and volatilities
123 >>> vols = np.array([0.15, 0.20, 0.25]) # 15%, 20%, 25% annual vol
124 >>> corr = np.array([[1.0, 0.3, 0.1],
125 ... [0.3, 1.0, 0.4],
126 ... [0.1, 0.4, 1.0]])
127 >>> cov = np.outer(vols, vols) * corr
128 >>> model.update(
129 ... cov=cov,
130 ... lower_assets=np.zeros(3),
131 ... upper_assets=np.ones(3)
132 ... )
133 >>> equal_weight = np.array([1/3, 1/3, 1/3])
134 >>> portfolio_vol = model.estimate(equal_weight).value
135 >>> # Portfolio vol should be less than weighted average vol (diversification)
136 >>> bool(portfolio_vol < np.mean(vols))
137 True
139 """
141 num: int = 0
142 """Maximum number of assets the model can handle."""
144 def __post_init__(self) -> None:
145 """Initialize the parameters after the class is instantiated.
147 Creates the Cholesky decomposition parameter and initializes the bounds.
148 The Cholesky parameter is a square matrix of size (num, num), and bounds
149 are created for asset weights.
151 Example:
152 >>> from cvx.risk.sample import SampleCovariance
153 >>> model = SampleCovariance(num=5)
154 >>> # Parameters are automatically created
155 >>> model.parameter["chol"].shape
156 (5, 5)
158 """
159 self.parameter["chol"] = cvx.Parameter(
160 shape=(self.num, self.num),
161 name="cholesky of covariance",
162 value=np.zeros((self.num, self.num)),
163 )
164 self.bounds = Bounds(m=self.num, name="assets")
166 def estimate(self, weights: cvx.Variable, **kwargs: Any) -> cvx.Expression:
167 """Estimate the portfolio risk using the Cholesky decomposition.
169 Computes the L2 norm of the product of the Cholesky factor and the
170 weights vector. This is equivalent to the square root of the portfolio
171 variance (i.e., portfolio volatility).
173 Args:
174 weights: CVXPY variable or numpy array representing portfolio weights.
175 **kwargs: Additional keyword arguments (not used).
177 Returns:
178 CVXPY expression representing the portfolio risk (standard deviation).
180 Example:
181 >>> import cvxpy as cp
182 >>> import numpy as np
183 >>> from cvx.risk.sample import SampleCovariance
184 >>> model = SampleCovariance(num=2)
185 >>> # Identity covariance (uncorrelated assets with unit variance)
186 >>> model.update(
187 ... cov=np.eye(2),
188 ... lower_assets=np.zeros(2),
189 ... upper_assets=np.ones(2)
190 ... )
191 >>> weights = cp.Variable(2)
192 >>> risk = model.estimate(weights)
193 >>> isinstance(risk, cp.Expression)
194 True
196 """
197 return cast(
198 cvx.Expression,
199 cvx.norm2(self.parameter["chol"] @ weights),
200 )
202 def update(self, **kwargs: Any) -> None:
203 """Update the Cholesky decomposition parameter and bounds.
205 Computes the Cholesky decomposition of the provided covariance matrix
206 and updates the model parameters. The covariance matrix can be smaller
207 than num x num.
209 Args:
210 **kwargs: Keyword arguments containing:
211 - cov: Covariance matrix (numpy.ndarray). Must be positive definite.
212 - lower_assets: Array of lower bounds for asset weights.
213 - upper_assets: Array of upper bounds for asset weights.
215 Example:
216 >>> import numpy as np
217 >>> from cvx.risk.sample import SampleCovariance
218 >>> model = SampleCovariance(num=5)
219 >>> # Update with a 3x3 covariance (smaller than max)
220 >>> cov = np.array([[1.0, 0.3, 0.1],
221 ... [0.3, 1.0, 0.2],
222 ... [0.1, 0.2, 1.0]])
223 >>> model.update(
224 ... cov=cov,
225 ... lower_assets=np.zeros(3),
226 ... upper_assets=np.ones(3)
227 ... )
228 >>> # Cholesky factor is updated
229 >>> model.parameter["chol"].value[:3, :3].shape
230 (3, 3)
232 """
233 cov = kwargs["cov"]
234 n = cov.shape[0]
236 chol = np.zeros((self.num, self.num))
237 chol[:n, :n] = cholesky(cov)
238 self.parameter["chol"].value = chol
239 self.bounds.update(**kwargs)
241 def constraints(self, weights: cvx.Variable, **kwargs: Any) -> list[cvx.Constraint]:
242 """Return constraints for the sample covariance model.
244 Returns the asset bounds constraints from the internal bounds object.
246 Args:
247 weights: CVXPY variable representing portfolio weights.
248 **kwargs: Additional keyword arguments (not used).
250 Returns:
251 List of CVXPY constraints from the bounds object (lower and upper bounds).
253 Example:
254 >>> import cvxpy as cp
255 >>> import numpy as np
256 >>> from cvx.risk.sample import SampleCovariance
257 >>> model = SampleCovariance(num=3)
258 >>> model.update(
259 ... cov=np.eye(3),
260 ... lower_assets=np.array([0.1, 0.0, 0.0]),
261 ... upper_assets=np.array([0.5, 0.6, 0.4])
262 ... )
263 >>> weights = cp.Variable(3)
264 >>> constraints = model.constraints(weights)
265 >>> len(constraints)
266 2
268 """
269 return self.bounds.constraints(weights)