Coverage for src/cvxmarkowitz/risk/cvar/cvar.py: 100%
27 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05:21 +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"""Conditional Value-at-Risk (CVaR) risk model implementation."""
16from __future__ import annotations
18from dataclasses import dataclass
20import cvxpy as cp
21import numpy as np
23from cvxmarkowitz.cvxerror import CvxDataError
24from cvxmarkowitz.model import Model
25from cvxmarkowitz.names import DataNames as D
26from cvxmarkowitz.types import Dimensions, Matrix, Variables
27from cvxmarkowitz.utils.fill import fill_matrix
30@dataclass(frozen=True)
31class CVar(Model):
32 """Conditional value at risk model."""
34 alpha: float = 0.95
35 rows: int = 0
37 def __post_init__(self) -> None:
38 """Initialize CVaR model parameters.
40 Creates the returns matrix parameter with shape `(rows, assets)` and
41 zeros as default value. The `alpha` quantile controls tail size during
42 estimation in `estimate`.
44 Raises:
45 CvxDataError: If `alpha` and `rows` leave fewer than one scenario in
46 the left tail. Checked here rather than in `estimate` because
47 both fields are frozen once construction returns, so the caller
48 is told at the point where the mistake can still be corrected.
49 """
50 if self._tail_size < 1:
51 raise CvxDataError( # noqa: TRY003
52 f"alpha={self.alpha} leaves no scenarios in the left tail of rows={self.rows}. "
53 f"Lower alpha or raise rows so that int(rows * (1 - alpha)) is at least 1."
54 )
56 self.data[D.RETURNS] = cp.Parameter(
57 shape=(self.rows, self.assets),
58 name=D.RETURNS,
59 value=np.zeros((self.rows, self.assets)),
60 )
62 @property
63 def _tail_size(self) -> int:
64 """Return the number of scenarios averaged over the left tail."""
65 return int(self.rows * (1 - self.alpha))
67 def estimate(self, variables: Variables) -> cp.Expression:
68 """Estimate the risk by computing the Cholesky decomposition of self.cov."""
69 # R is a matrix of returns, n is the number of rows in R.
70 # k is the number of returns in the left tail; __post_init__ has already
71 # rejected any (alpha, rows) pair that would make it zero, which would
72 # otherwise reach cvxpy as a bare ValueError and divide by zero here.
73 k = self._tail_size
74 # average value of the k elements in the left tail
75 return -cp.sum_smallest(self.data[D.RETURNS] @ variables[D.WEIGHTS], k=k) / k
77 def dimensions(self, **kwargs: Matrix) -> Dimensions:
78 """Return the number of assets the scenario matrix implies.
80 Its row count is the number of scenarios, which is this model's own
81 business rather than a size shared with the other models, so it is not
82 declared here.
83 """
84 return ((D.WEIGHTS, np.shape(kwargs[D.RETURNS])[1]),)
86 def update(self, **kwargs: Matrix) -> None:
87 """Update the returns matrix used by the CVaR model.
89 Expected keyword arguments:
90 D.RETURNS: Matrix of historical/scenario returns with shape (rows, assets).
91 """
92 self.data[D.RETURNS].value = fill_matrix(rows=self.rows, cols=self.assets, x=kwargs[D.RETURNS])