Coverage for src/cvx/risk/cvar/cvar.py: 100%
73 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"""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 (c) 2025 Jebel Quant Research
34#
35# Licensed under the MIT License. See the LICENSE file in the project root
36# for the full license text.
37from __future__ import annotations
39from dataclasses import dataclass
40from typing import Any
42import clarabel
43import numpy as np
44from scipy import sparse
46from cvx.core import Bounds, ConeProgramBuilder, Model, Parameter, Variable
49@dataclass
50class CVar(Model):
51 """Conditional Value at Risk (CVaR) risk model.
53 CVaR, also known as Expected Shortfall, measures the expected loss in the
54 worst (1-alpha) fraction of scenarios. For example, with alpha=0.95, CVaR
55 is the average of the worst 5% of returns.
57 This implementation uses historical returns to estimate CVaR, which is
58 computed as the negative average of the k smallest portfolio returns,
59 where k = n * (1 - alpha).
61 Attributes:
62 alpha: Confidence level, typically 0.95 or 0.99. Higher alpha means
63 focusing on more extreme tail events.
64 n: Number of historical return observations (scenarios).
65 m: Maximum number of assets in the portfolio.
67 Example:
68 Basic CVaR model setup:
70 >>> import numpy as np
71 >>> from cvx.risk.cvar import CVar
72 >>> from cvx.risk.portfolio import minrisk_problem
73 >>> from cvx.core.variable import Variable
74 >>> # Create model for 95% CVaR with 50 scenarios and 3 assets
75 >>> model = CVar(alpha=0.95, n=50, m=3)
76 >>> # Number of tail samples: k = 50 * (1 - 0.95) = 2.5 -> 2
77 >>> model.k
78 2
79 >>> # Generate sample returns
80 >>> np.random.seed(42)
81 >>> returns = np.random.randn(50, 3)
82 >>> model.update(
83 ... returns=returns,
84 ... lower_assets=np.zeros(3),
85 ... upper_assets=np.ones(3)
86 ... )
87 >>> # Create and solve optimization
88 >>> weights = Variable(3)
89 >>> problem = minrisk_problem(model, weights)
90 >>> problem.solve()
92 Mathematical verification of CVaR calculation:
94 >>> model = CVar(alpha=0.95, n=20, m=2)
95 >>> # Simple returns: asset 1 always returns 0.05, asset 2 returns vary
96 >>> returns = np.zeros((20, 2))
97 >>> returns[:, 0] = 0.05 # Asset 1 constant return
98 >>> returns[:, 1] = np.linspace(-0.20, 0.18, 20) # Asset 2 varying
99 >>> model.update(
100 ... returns=returns,
101 ... lower_assets=np.zeros(2),
102 ... upper_assets=np.ones(2)
103 ... )
104 >>> # k = 20 * (1 - 0.95) = 1, so we take the single worst return
105 >>> model.k
106 1
107 >>> # For 100% in asset 2, worst return is -0.20
108 >>> w = np.array([0.0, 1.0])
109 >>> cvar = model.estimate(w)
110 >>> expected_cvar = 0.20 # negative of worst return
111 >>> bool(np.isclose(cvar, expected_cvar, rtol=1e-6))
112 True
114 Different alpha values affect the tail focus:
116 >>> # Higher alpha = focus on more extreme events
117 >>> model_95 = CVar(alpha=0.95, n=100, m=2)
118 >>> model_95.k # Only 5 worst scenarios
119 5
120 >>> model_75 = CVar(alpha=0.75, n=100, m=2)
121 >>> model_75.k # 25 worst scenarios
122 25
124 """
126 alpha: float = 0.95
127 """Confidence level for CVaR (e.g., 0.95 for 95% CVaR)."""
129 n: int = 0
130 """Number of historical return observations (scenarios)."""
132 m: int = 0
133 """Maximum number of assets in the portfolio."""
135 def __post_init__(self) -> None:
136 """Initialize the parameters after the class is instantiated.
138 Calculates the number of samples in the tail (k) based on alpha,
139 creates the returns parameter matrix, and initializes the bounds.
141 The tail size is ``k = floor(n * (1 - alpha))``, computed with a small
142 rounding tolerance so that exact fractions such as ``n=10, alpha=0.9``
143 yield ``k=1`` rather than ``0`` (``1 - 0.9`` is slightly below ``0.1``
144 in floating point, which would otherwise truncate down to ``0``).
146 Raises:
147 ValueError: If a configured model (``n > 0``) yields an empty tail
148 (``k == 0``) — e.g. ``alpha=0.99`` with ``n=50``. An empty tail
149 makes the risk undefined (``estimate`` returns ``nan`` and the
150 solver objective divides by zero), so the degenerate model is
151 rejected up front. Increase ``n`` or lower ``alpha``. The
152 all-defaults placeholder ``CVar()`` (``n == 0``) is left
153 constructible, mirroring the other risk models.
155 Example:
156 >>> from cvx.risk.cvar import CVar
157 >>> model = CVar(alpha=0.95, n=100, m=5)
158 >>> # k is the number of samples in the tail
159 >>> model.k
160 5
161 >>> # Returns parameter is created
162 >>> model.parameter["R"].shape
163 (100, 5)
165 An exact fraction is not truncated by floating-point error:
167 >>> CVar(alpha=0.9, n=10, m=3).k
168 1
170 A configured alpha/n combination with an empty tail is rejected:
172 >>> CVar(alpha=0.99, n=50, m=3)
173 Traceback (most recent call last):
174 ...
175 ValueError: alpha=0.99 with n=50 yields an empty CVaR tail (k=0); increase n or lower alpha
177 """
178 self.k = int(np.floor(round(self.n * (1 - self.alpha), 9)))
179 if self.n > 0 and self.k < 1:
180 msg = f"alpha={self.alpha} with n={self.n} yields an empty CVaR tail (k=0); increase n or lower alpha"
181 raise ValueError(msg)
182 self.parameter["R"] = Parameter(shape=(self.n, self.m), name="returns")
183 self.bounds = Bounds(m=self.m, name="assets")
185 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float:
186 """Estimate the Conditional Value at Risk (CVaR) for the given weights.
188 Computes the negative average of the k smallest returns in the portfolio,
189 where k is determined by the alpha parameter. This represents the expected
190 loss in the worst (1-alpha) fraction of scenarios.
192 Args:
193 weights: Numpy array representing portfolio weights.
194 **kwargs: Additional keyword arguments (not used).
196 Returns:
197 Float representing the CVaR (expected tail loss).
199 Example:
200 >>> import numpy as np
201 >>> from cvx.risk.cvar import CVar
202 >>> model = CVar(alpha=0.95, n=100, m=3)
203 >>> np.random.seed(42)
204 >>> returns = np.random.randn(100, 3)
205 >>> model.update(
206 ... returns=returns,
207 ... lower_assets=np.zeros(3),
208 ... upper_assets=np.ones(3)
209 ... )
210 >>> w = np.array([1/3, 1/3, 1/3])
211 >>> cvar = model.estimate(w)
212 >>> isinstance(cvar, float)
213 True
215 """
216 portfolio_returns = self.parameter["R"].value @ np.asarray(weights)
217 sorted_returns = np.sort(portfolio_returns)
218 # Take the k smallest (worst) returns and average them
219 return float(-np.mean(sorted_returns[: self.k]))
221 def update(self, **kwargs: Any) -> None:
222 """Update the returns data and bounds parameters.
224 Updates the returns matrix and asset bounds. The returns matrix can
225 have fewer columns than m (maximum assets), in which case only the
226 first columns are updated.
228 Args:
229 **kwargs: Keyword arguments containing:
230 - returns: Matrix of returns with shape (n, num_assets).
231 - lower_assets: Array of lower bounds for asset weights.
232 - upper_assets: Array of upper bounds for asset weights.
234 Raises:
235 ValueError: If ``returns`` is missing, has the wrong number of
236 scenarios, or more columns than the model capacity ``m``.
238 Example:
239 >>> import numpy as np
240 >>> from cvx.risk.cvar import CVar
241 >>> model = CVar(alpha=0.95, n=50, m=5)
242 >>> # Update with 3 assets (less than maximum of 5)
243 >>> np.random.seed(42)
244 >>> returns = np.random.randn(50, 3)
245 >>> model.update(
246 ... returns=returns,
247 ... lower_assets=np.zeros(3),
248 ... upper_assets=np.ones(3)
249 ... )
250 >>> model.parameter["R"].value[:, :3].shape
251 (50, 3)
253 """
254 if "returns" not in kwargs:
255 msg = "update() requires a 'returns' argument"
256 raise ValueError(msg)
257 returns = np.asarray(kwargs["returns"])
258 if returns.ndim != 2:
259 msg = f"returns must be a 2d matrix of shape (n, num_assets), got shape {returns.shape}"
260 raise ValueError(msg)
261 if returns.shape[0] != self.n:
262 msg = f"returns has {returns.shape[0]} scenarios but the model expects n={self.n}"
263 raise ValueError(msg)
264 if returns.shape[1] > self.m:
265 msg = f"Too many assets: returns has {returns.shape[1]} columns but the model capacity is m={self.m}"
266 raise ValueError(msg)
267 num_assets = returns.shape[1]
269 padded_returns = np.zeros((self.n, self.m))
270 padded_returns[:, :num_assets] = returns
271 self.parameter["R"].value = padded_returns
272 self.bounds.update(**kwargs)
274 def solve_minrisk(
275 self,
276 weights: Variable,
277 base: np.ndarray,
278 extra_constraints: list[tuple[np.ndarray, float | None, float | None]],
279 y_var: Variable | None = None, # noqa: ARG002 -- shared solve_minrisk interface; only factor models use y_var
280 ) -> tuple[float | None, float | None, str]:
281 """Build and solve the Clarabel LP for this model.
283 Raises:
284 ValueError: If the weights dimension exceeds the model capacity ``m``.
286 """
287 n = weights.n
288 if n > self.m:
289 msg = f"weights has dimension {n} but the model capacity is m={self.m}"
290 raise ValueError(msg)
291 T = self.n # noqa: N806
292 k = self.k
293 R = self.parameter["R"].value # noqa: N806
294 lb_w, ub_w = self.bounds.get_bounds()
296 R_n = R[:, :n] # noqa: N806
298 # Variables: x = [w, gamma, u] (Rockafellar-Uryasev formulation)
299 # with gamma the VaR level and u the scenario excess losses.
300 w_cols = slice(0, n)
301 gamma_col = n
302 u_cols = slice(n + 1, n + 1 + T)
303 builder = ConeProgramBuilder(n_vars=n + 1 + T)
305 # u >= -R @ (w - base) - gamma (scenario losses beyond VaR)
306 # Built directly in sparse form: dense (T x n_vars) blocks would be
307 # O(T^2) memory for what is mostly an identity over the u variables.
308 a_cvar = sparse.hstack(
309 [sparse.csr_matrix(-R_n), sparse.csr_matrix(-np.ones((T, 1))), -sparse.identity(T, format="csr")],
310 format="csr",
311 )
312 builder.add(a_cvar, R_n @ base, clarabel.NonnegativeConeT(T))
314 # u >= 0
315 a_u = sparse.hstack(
316 [sparse.csr_matrix((T, n + 1)), -sparse.identity(T, format="csr")],
317 format="csr",
318 )
319 builder.add(a_u, np.zeros(T), clarabel.NonnegativeConeT(T))
321 builder.add_sum_constraint(w_cols)
322 builder.add_variable_bounds(w_cols, lb_w[:n], ub_w[:n])
323 builder.add_linear_constraints(extra_constraints, w_cols)
325 q = np.zeros(builder.n_vars)
326 q[gamma_col] = 1.0
327 q[u_cols] = 1.0 / k
329 def result(sol: Any) -> tuple[float, float]:
330 """Return the CVaR objective value as both the risk and the reported minimum."""
331 cvar_val = float(q @ sol.x)
332 return cvar_val, cvar_val
334 return self._solve_and_unpack(builder, q, weights, w_cols, result)