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

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.""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19 

20import cvxpy as cp 

21import numpy as np 

22 

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 

28 

29 

30@dataclass(frozen=True) 

31class CVar(Model): 

32 """Conditional value at risk model.""" 

33 

34 alpha: float = 0.95 

35 rows: int = 0 

36 

37 def __post_init__(self) -> None: 

38 """Initialize CVaR model parameters. 

39 

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`. 

43 

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 ) 

55 

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 ) 

61 

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)) 

66 

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 

76 

77 def dimensions(self, **kwargs: Matrix) -> Dimensions: 

78 """Return the number of assets the scenario matrix implies. 

79 

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]),) 

85 

86 def update(self, **kwargs: Matrix) -> None: 

87 """Update the returns matrix used by the CVaR model. 

88 

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])