Coverage for src/cvx/risk/sample/sample.py: 100%
53 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-22 10:34 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-22 10:34 +0000
1# Copyright (c) 2025 Jebel Quant Research
2#
3# Licensed under the MIT License. See the LICENSE file in the project root
4# for the full license text.
5"""Risk models based on the sample covariance matrix.
7This module provides the SampleCovariance class, which implements a risk model
8based on the Cholesky decomposition of the sample covariance matrix. This is
9one of the most common approaches to portfolio risk estimation.
11Example:
12 Create and use a sample covariance risk model:
14 >>> import numpy as np
15 >>> from cvx.risk.sample import SampleCovariance
16 >>> # Create risk model for up to 3 assets
17 >>> model = SampleCovariance(num=3)
18 >>> # Update with a covariance matrix
19 >>> cov = np.array([[1.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 1.0]])
20 >>> model.update(
21 ... cov=cov,
22 ... lower_assets=np.zeros(3),
23 ... upper_assets=np.ones(3)
24 ... )
25 >>> # Estimate risk for a given portfolio
26 >>> weights = np.array([0.4, 0.3, 0.3])
27 >>> risk = model.estimate(weights)
28 >>> isinstance(risk, float)
29 True
31"""
33from __future__ import annotations
35from dataclasses import dataclass
36from typing import Any
38import clarabel
39import numpy as np
40from cvx.linalg import cholesky, norm
42from cvx.core import Bounds, ConeProgramBuilder, Model, Parameter, Variable
45@dataclass
46class SampleCovariance(Model):
47 """Risk model based on the Cholesky decomposition of the sample covariance matrix.
49 This model computes portfolio risk as the L2 norm of the product of the
50 Cholesky factor and the weights vector. Mathematically, if R is the upper
51 triangular Cholesky factor of the covariance matrix (R^T @ R = cov), then:
53 risk = ||R @ w||_2 = sqrt(w^T @ cov @ w)
55 This represents the portfolio standard deviation (volatility).
57 Attributes:
58 num: Maximum number of assets the model can handle. The model can be
59 updated with fewer assets, but not more.
61 Example:
62 Basic usage:
64 >>> import numpy as np
65 >>> from cvx.risk.sample import SampleCovariance
66 >>> model = SampleCovariance(num=2)
67 >>> model.update(
68 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]),
69 ... lower_assets=np.zeros(2),
70 ... upper_assets=np.ones(2)
71 ... )
72 >>> # Equal weight portfolio
73 >>> weights = np.array([0.5, 0.5])
74 >>> risk = model.estimate(weights)
75 >>> # Risk should be sqrt(0.5^2 * 1 + 0.5^2 * 2 + 2 * 0.5 * 0.5 * 0.5)
76 >>> bool(np.isclose(risk, 1.0))
77 True
79 Using in optimization:
81 >>> from cvx.risk.portfolio import minrisk_problem
82 >>> from cvx.core.variable import Variable
83 >>> weights = Variable(2)
84 >>> problem = minrisk_problem(model, weights)
85 >>> problem.solve()
86 >>> # Lower variance asset gets higher weight
87 >>> bool(weights.value[0] > weights.value[1])
88 True
90 Mathematical verification - the risk estimate equals sqrt(w^T @ cov @ w):
92 >>> model = SampleCovariance(num=3)
93 >>> cov = np.array([[0.04, 0.01, 0.02],
94 ... [0.01, 0.09, 0.01],
95 ... [0.02, 0.01, 0.16]])
96 >>> model.update(
97 ... cov=cov,
98 ... lower_assets=np.zeros(3),
99 ... upper_assets=np.ones(3)
100 ... )
101 >>> w = np.array([0.4, 0.35, 0.25])
102 >>> # Model estimate
103 >>> model_risk = model.estimate(w)
104 >>> # Manual calculation: sqrt(w^T @ cov @ w)
105 >>> manual_risk = np.sqrt(w @ cov @ w)
106 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-6))
107 True
109 """
111 num: int = 0
112 """Maximum number of assets the model can handle."""
114 def __post_init__(self) -> None:
115 """Initialize the parameters after the class is instantiated.
117 Creates the Cholesky decomposition parameter and initializes the bounds.
118 The Cholesky parameter is a square matrix of size (num, num), and bounds
119 are created for asset weights.
121 Example:
122 >>> from cvx.risk.sample import SampleCovariance
123 >>> model = SampleCovariance(num=5)
124 >>> # Parameters are automatically created
125 >>> model.parameter["chol"].shape
126 (5, 5)
128 """
129 self.parameter["chol"] = Parameter(
130 shape=(self.num, self.num),
131 name="cholesky of covariance",
132 )
133 self.bounds = Bounds(m=self.num, name="assets")
135 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float:
136 """Estimate the portfolio risk using the Cholesky decomposition.
138 Computes the L2 norm of the product of the Cholesky factor and the
139 weights vector. This is equivalent to the square root of the portfolio
140 variance (i.e., portfolio volatility).
142 Args:
143 weights: Numpy array representing portfolio weights.
144 **kwargs: Additional keyword arguments (not used).
146 Returns:
147 Float representing the portfolio risk (standard deviation).
149 Example:
150 >>> import numpy as np
151 >>> from cvx.risk.sample import SampleCovariance
152 >>> model = SampleCovariance(num=2)
153 >>> # Identity covariance (uncorrelated assets with unit variance)
154 >>> model.update(
155 ... cov=np.eye(2),
156 ... lower_assets=np.zeros(2),
157 ... upper_assets=np.ones(2)
158 ... )
159 >>> risk = model.estimate(np.array([0.5, 0.5]))
160 >>> isinstance(risk, float)
161 True
163 """
164 return norm(self.parameter["chol"].value @ np.asarray(weights))
166 def update(self, **kwargs: Any) -> None:
167 """Update the Cholesky decomposition parameter and bounds.
169 Computes the Cholesky decomposition of the provided covariance matrix
170 and updates the model parameters. The covariance matrix can be smaller
171 than num x num.
173 Args:
174 **kwargs: Keyword arguments containing:
175 - cov: Covariance matrix (numpy.ndarray). Must be positive definite.
176 - lower_assets: Array of lower bounds for asset weights.
177 - upper_assets: Array of upper bounds for asset weights.
179 Raises:
180 ValueError: If ``cov`` is missing, not square, or larger than the
181 model capacity ``num``.
183 Example:
184 >>> import numpy as np
185 >>> from cvx.risk.sample import SampleCovariance
186 >>> model = SampleCovariance(num=5)
187 >>> # Update with a 3x3 covariance (smaller than max)
188 >>> cov = np.array([[1.0, 0.3, 0.1],
189 ... [0.3, 1.0, 0.2],
190 ... [0.1, 0.2, 1.0]])
191 >>> model.update(
192 ... cov=cov,
193 ... lower_assets=np.zeros(3),
194 ... upper_assets=np.ones(3)
195 ... )
196 >>> # Cholesky factor is updated
197 >>> model.parameter["chol"].value[:3, :3].shape
198 (3, 3)
200 """
201 if "cov" not in kwargs:
202 msg = "update() requires a 'cov' argument"
203 raise ValueError(msg)
204 cov = np.asarray(kwargs["cov"])
205 if cov.ndim != 2 or cov.shape[0] != cov.shape[1]:
206 msg = f"cov must be a square matrix, got shape {cov.shape}"
207 raise ValueError(msg)
208 if cov.shape[0] > self.num:
209 msg = f"Too many assets: cov is {cov.shape[0]}x{cov.shape[0]} but the model capacity is num={self.num}"
210 raise ValueError(msg)
211 num_assets = cov.shape[0]
213 padded_chol = np.zeros((self.num, self.num))
214 padded_chol[:num_assets, :num_assets] = cholesky(cov)
215 self.parameter["chol"].value = padded_chol
216 self.bounds.update(**kwargs)
218 def solve_minrisk(
219 self,
220 weights: Variable,
221 base: np.ndarray,
222 extra_constraints: list[tuple[np.ndarray, float | None, float | None]],
223 y_var: Variable | None = None, # noqa: ARG002 -- shared solve_minrisk interface; only factor models use y_var
224 ) -> tuple[float | None, float | None, str]:
225 """Build and solve the Clarabel SOC problem for this model.
227 Raises:
228 ValueError: If the weights dimension does not match the model
229 capacity ``num``.
231 """
232 n = weights.n
233 if n != self.num:
234 msg = f"weights has dimension {n} but the model capacity is num={self.num}"
235 raise ValueError(msg)
236 chol = self.parameter["chol"].value
237 lb, ub = self.bounds.get_bounds()
239 # Variables: x = [t, w] with t bounding the portfolio volatility.
240 w_cols = slice(1, 1 + n)
241 builder = ConeProgramBuilder(n_vars=1 + n)
243 # SOC: || chol @ (w - base) ||_2 <= t
244 a_soc = builder.block(n + 1)
245 a_soc[0, 0] = -1.0
246 a_soc[1:, w_cols] = -chol
247 b_soc = np.zeros(n + 1)
248 b_soc[1:] = -chol @ base
249 builder.add(a_soc, b_soc, clarabel.SecondOrderConeT(n + 1))
251 builder.add_sum_constraint(w_cols)
252 builder.add_variable_bounds(w_cols, lb, ub)
253 builder.add_linear_constraints(extra_constraints, w_cols)
255 q = np.zeros(builder.n_vars)
256 q[0] = 1.0
257 return self._solve_and_unpack(
258 builder,
259 q,
260 weights,
261 w_cols,
262 lambda sol: (float(sol.obj_val), float(sol.x[0])),
263 )