Coverage for src / cvx / risk / cvar / cvar.py: 100%
103 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"""Conditional Value at Risk (CVaR) risk model implementation.
3This module provides the CVar class, which implements the Conditional Value at Risk
4(also known as Expected Shortfall) risk measure for portfolio optimization.
6CVaR measures the expected loss in the tail of the portfolio's return distribution,
7making it a popular choice for risk-averse portfolio optimization.
9Example:
10 Create a CVaR model and compute the tail risk:
12 >>> import numpy as np
13 >>> from cvx.risk.cvar import CVar
14 >>> # Create CVaR model with 95% confidence level
15 >>> model = CVar(alpha=0.95, n=100, m=5)
16 >>> # Generate sample returns
17 >>> np.random.seed(42)
18 >>> returns = np.random.randn(100, 5)
19 >>> # Update model with returns data
20 >>> model.update(
21 ... returns=returns,
22 ... lower_assets=np.zeros(5),
23 ... upper_assets=np.ones(5)
24 ... )
25 >>> # The model is ready for use
26 >>> w = np.ones(5) / 5
27 >>> risk = model.estimate(w)
28 >>> isinstance(risk, float)
29 True
31"""
33# Copyright 2023 Stanford University Convex Optimization Group
34#
35# Licensed under the Apache License, Version 2.0 (the "License");
36# you may not use this file except in compliance with the License.
37# You may obtain a copy of the License at
38#
39# http://www.apache.org/licenses/LICENSE-2.0
40#
41# Unless required by applicable law or agreed to in writing, software
42# distributed under the License is distributed on an "AS IS" BASIS,
43# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
44# See the License for the specific language governing permissions and
45# limitations under the License.
46from __future__ import annotations
48from dataclasses import dataclass
49from typing import Any
51import clarabel
52import numpy as np
53from scipy import sparse
55from cvx.core import Bounds, Model, Parameter, Variable
58@dataclass
59class CVar(Model):
60 """Conditional Value at Risk (CVaR) risk model.
62 CVaR, also known as Expected Shortfall, measures the expected loss in the
63 worst (1-alpha) fraction of scenarios. For example, with alpha=0.95, CVaR
64 is the average of the worst 5% of returns.
66 This implementation uses historical returns to estimate CVaR, which is
67 computed as the negative average of the k smallest portfolio returns,
68 where k = n * (1 - alpha).
70 Attributes:
71 alpha: Confidence level, typically 0.95 or 0.99. Higher alpha means
72 focusing on more extreme tail events.
73 n: Number of historical return observations (scenarios).
74 m: Maximum number of assets in the portfolio.
76 Example:
77 Basic CVaR model setup:
79 >>> import numpy as np
80 >>> from cvx.risk.cvar import CVar
81 >>> from cvx.risk.portfolio import minrisk_problem
82 >>> from cvx.core.variable import Variable
83 >>> # Create model for 95% CVaR with 50 scenarios and 3 assets
84 >>> model = CVar(alpha=0.95, n=50, m=3)
85 >>> # Number of tail samples: k = 50 * (1 - 0.95) = 2.5 -> 2
86 >>> model.k
87 2
88 >>> # Generate sample returns
89 >>> np.random.seed(42)
90 >>> returns = np.random.randn(50, 3)
91 >>> model.update(
92 ... returns=returns,
93 ... lower_assets=np.zeros(3),
94 ... upper_assets=np.ones(3)
95 ... )
96 >>> # Create and solve optimization
97 >>> weights = Variable(3)
98 >>> problem = minrisk_problem(model, weights)
99 >>> problem.solve()
101 Mathematical verification of CVaR calculation:
103 >>> model = CVar(alpha=0.95, n=20, m=2)
104 >>> # Simple returns: asset 1 always returns 0.05, asset 2 returns vary
105 >>> returns = np.zeros((20, 2))
106 >>> returns[:, 0] = 0.05 # Asset 1 constant return
107 >>> returns[:, 1] = np.linspace(-0.20, 0.18, 20) # Asset 2 varying
108 >>> model.update(
109 ... returns=returns,
110 ... lower_assets=np.zeros(2),
111 ... upper_assets=np.ones(2)
112 ... )
113 >>> # k = 20 * (1 - 0.95) = 1, so we take the single worst return
114 >>> model.k
115 1
116 >>> # For 100% in asset 2, worst return is -0.20
117 >>> w = np.array([0.0, 1.0])
118 >>> cvar = model.estimate(w)
119 >>> expected_cvar = 0.20 # negative of worst return
120 >>> bool(np.isclose(cvar, expected_cvar, rtol=1e-6))
121 True
123 Different alpha values affect the tail focus:
125 >>> # Higher alpha = focus on more extreme events
126 >>> model_95 = CVar(alpha=0.95, n=100, m=2)
127 >>> model_95.k # Only 5 worst scenarios
128 5
129 >>> model_75 = CVar(alpha=0.75, n=100, m=2)
130 >>> model_75.k # 25 worst scenarios
131 25
133 """
135 alpha: float = 0.95
136 """Confidence level for CVaR (e.g., 0.95 for 95% CVaR)."""
138 n: int = 0
139 """Number of historical return observations (scenarios)."""
141 m: int = 0
142 """Maximum number of assets in the portfolio."""
144 def __post_init__(self) -> None:
145 """Initialize the parameters after the class is instantiated.
147 Calculates the number of samples in the tail (k) based on alpha,
148 creates the returns parameter matrix, and initializes the bounds.
150 Example:
151 >>> from cvx.risk.cvar import CVar
152 >>> model = CVar(alpha=0.95, n=100, m=5)
153 >>> # k is the number of samples in the tail
154 >>> model.k
155 5
156 >>> # Returns parameter is created
157 >>> model.parameter["R"].shape
158 (100, 5)
160 """
161 self.k = int(self.n * (1 - self.alpha))
162 self.parameter["R"] = Parameter(shape=(self.n, self.m), name="returns")
163 self.bounds = Bounds(m=self.m, name="assets")
165 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float:
166 """Estimate the Conditional Value at Risk (CVaR) for the given weights.
168 Computes the negative average of the k smallest returns in the portfolio,
169 where k is determined by the alpha parameter. This represents the expected
170 loss in the worst (1-alpha) fraction of scenarios.
172 Args:
173 weights: Numpy array representing portfolio weights.
174 **kwargs: Additional keyword arguments (not used).
176 Returns:
177 Float representing the CVaR (expected tail loss).
179 Example:
180 >>> import numpy as np
181 >>> from cvx.risk.cvar import CVar
182 >>> model = CVar(alpha=0.95, n=100, m=3)
183 >>> np.random.seed(42)
184 >>> returns = np.random.randn(100, 3)
185 >>> model.update(
186 ... returns=returns,
187 ... lower_assets=np.zeros(3),
188 ... upper_assets=np.ones(3)
189 ... )
190 >>> w = np.array([1/3, 1/3, 1/3])
191 >>> cvar = model.estimate(w)
192 >>> isinstance(cvar, float)
193 True
195 """
196 portfolio_returns = self.parameter["R"].value @ np.asarray(weights)
197 sorted_returns = np.sort(portfolio_returns)
198 # Take the k smallest (worst) returns and average them
199 return float(-np.mean(sorted_returns[: self.k]))
201 def update(self, **kwargs: Any) -> None:
202 """Update the returns data and bounds parameters.
204 Updates the returns matrix and asset bounds. The returns matrix can
205 have fewer columns than m (maximum assets), in which case only the
206 first columns are updated.
208 Args:
209 **kwargs: Keyword arguments containing:
210 - returns: Matrix of returns with shape (n, num_assets).
211 - lower_assets: Array of lower bounds for asset weights.
212 - upper_assets: Array of upper bounds for asset weights.
214 Example:
215 >>> import numpy as np
216 >>> from cvx.risk.cvar import CVar
217 >>> model = CVar(alpha=0.95, n=50, m=5)
218 >>> # Update with 3 assets (less than maximum of 5)
219 >>> np.random.seed(42)
220 >>> returns = np.random.randn(50, 3)
221 >>> model.update(
222 ... returns=returns,
223 ... lower_assets=np.zeros(3),
224 ... upper_assets=np.ones(3)
225 ... )
226 >>> model.parameter["R"].value[:, :3].shape
227 (50, 3)
229 """
230 ret = kwargs["returns"]
231 num_assets = ret.shape[1]
233 returns_arr = np.zeros((self.n, self.m))
234 returns_arr[:, :num_assets] = ret
235 self.parameter["R"].value = returns_arr
236 self.bounds.update(**kwargs)
238 def solve_minrisk(
239 self,
240 weights: Variable,
241 base: np.ndarray,
242 extra_constraints: list[tuple[np.ndarray, float | None, float | None]],
243 y_var: Variable | None = None,
244 ) -> tuple[float | None, float | None, str]:
245 """Build and solve the Clarabel LP for this model."""
246 n = weights.n
247 T = self.n # noqa: N806
248 k = self.k
249 R = self.parameter["R"].value # noqa: N806
250 lb_w, ub_w = self.bounds.get_bounds()
252 R_n = R[:, :n] # noqa: N806
253 n_vars = n + 1 + T
254 P = sparse.csc_matrix((n_vars, n_vars)) # noqa: N806
255 q = np.zeros(n_vars)
256 q[n] = 1.0
257 q[n + 1 :] = 1.0 / k
259 A_rows: list[np.ndarray] = [] # noqa: N806
260 b_rows: list[np.ndarray] = []
261 cones: list[Any] = []
263 A_cvar = np.zeros((T, n_vars)) # noqa: N806
264 A_cvar[:, :n] = -R_n
265 A_cvar[:, n] = -1.0
266 A_cvar[:, n + 1 :] = -np.eye(T)
267 A_rows.append(A_cvar)
268 b_rows.append(R_n @ base)
269 cones.append(clarabel.NonnegativeConeT(T))
271 A_u = np.zeros((T, n_vars)) # noqa: N806
272 A_u[:, n + 1 :] = -np.eye(T)
273 A_rows.append(A_u)
274 b_rows.append(np.zeros(T))
275 cones.append(clarabel.NonnegativeConeT(T))
277 A_eq = np.zeros((1, n_vars)) # noqa: N806
278 A_eq[0, :n] = 1.0
279 A_rows.append(A_eq)
280 b_rows.append(np.array([1.0]))
281 cones.append(clarabel.ZeroConeT(1))
283 A_lb = np.zeros((n, n_vars)) # noqa: N806
284 A_lb[:, :n] = -np.eye(n)
285 A_rows.append(A_lb)
286 b_rows.append(-lb_w[:n])
287 cones.append(clarabel.NonnegativeConeT(n))
289 A_ub = np.zeros((n, n_vars)) # noqa: N806
290 A_ub[:, :n] = np.eye(n)
291 A_rows.append(A_ub)
292 b_rows.append(ub_w[:n])
293 cones.append(clarabel.NonnegativeConeT(n))
295 for a, lb_val, ub_val in extra_constraints:
296 a = np.asarray(a)
297 if lb_val is not None and ub_val is not None and lb_val == ub_val:
298 A_extra = np.zeros((1, n_vars)) # noqa: N806
299 A_extra[0, :n] = a
300 A_rows.append(A_extra)
301 b_rows.append(np.array([lb_val]))
302 cones.append(clarabel.ZeroConeT(1))
303 else:
304 if lb_val is not None:
305 A_extra = np.zeros((1, n_vars)) # noqa: N806
306 A_extra[0, :n] = -a
307 A_rows.append(A_extra)
308 b_rows.append(np.array([-lb_val]))
309 cones.append(clarabel.NonnegativeConeT(1))
310 if ub_val is not None:
311 A_extra = np.zeros((1, n_vars)) # noqa: N806
312 A_extra[0, :n] = a
313 A_rows.append(A_extra)
314 b_rows.append(np.array([ub_val]))
315 cones.append(clarabel.NonnegativeConeT(1))
317 A = sparse.csc_matrix(np.vstack(A_rows)) # noqa: N806
318 b = np.concatenate(b_rows)
320 settings = clarabel.DefaultSettings()
321 settings.verbose = False
322 sol = clarabel.DefaultSolver(P, q, A, b, cones, settings).solve()
323 status = str(sol.status)
325 if "Solved" in status:
326 weights.value = np.array(sol.x[:n])
327 cvar_val = float(q @ sol.x)
328 return cvar_val, cvar_val, status
329 return None, None, status