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

1"""Conditional Value at Risk (CVaR) risk model implementation. 

2 

3This module provides the CVar class, which implements the Conditional Value at Risk 

4(also known as Expected Shortfall) risk measure for portfolio optimization. 

5 

6CVaR measures the expected loss in the tail of the portfolio's return distribution, 

7making it a popular choice for risk-averse portfolio optimization. 

8 

9Example: 

10 Create a CVaR model and compute the tail risk: 

11 

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 

30 

31""" 

32 

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 

38 

39from dataclasses import dataclass 

40from typing import Any 

41 

42import clarabel 

43import numpy as np 

44from scipy import sparse 

45 

46from cvx.core import Bounds, ConeProgramBuilder, Model, Parameter, Variable 

47 

48 

49@dataclass 

50class CVar(Model): 

51 """Conditional Value at Risk (CVaR) risk model. 

52 

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. 

56 

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

60 

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. 

66 

67 Example: 

68 Basic CVaR model setup: 

69 

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

91 

92 Mathematical verification of CVaR calculation: 

93 

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 

113 

114 Different alpha values affect the tail focus: 

115 

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 

123 

124 """ 

125 

126 alpha: float = 0.95 

127 """Confidence level for CVaR (e.g., 0.95 for 95% CVaR).""" 

128 

129 n: int = 0 

130 """Number of historical return observations (scenarios).""" 

131 

132 m: int = 0 

133 """Maximum number of assets in the portfolio.""" 

134 

135 def __post_init__(self) -> None: 

136 """Initialize the parameters after the class is instantiated. 

137 

138 Calculates the number of samples in the tail (k) based on alpha, 

139 creates the returns parameter matrix, and initializes the bounds. 

140 

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

145 

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. 

154 

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) 

164 

165 An exact fraction is not truncated by floating-point error: 

166 

167 >>> CVar(alpha=0.9, n=10, m=3).k 

168 1 

169 

170 A configured alpha/n combination with an empty tail is rejected: 

171 

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 

176 

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

184 

185 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float: 

186 """Estimate the Conditional Value at Risk (CVaR) for the given weights. 

187 

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. 

191 

192 Args: 

193 weights: Numpy array representing portfolio weights. 

194 **kwargs: Additional keyword arguments (not used). 

195 

196 Returns: 

197 Float representing the CVaR (expected tail loss). 

198 

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 

214 

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

220 

221 def update(self, **kwargs: Any) -> None: 

222 """Update the returns data and bounds parameters. 

223 

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. 

227 

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. 

233 

234 Raises: 

235 ValueError: If ``returns`` is missing, has the wrong number of 

236 scenarios, or more columns than the model capacity ``m``. 

237 

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) 

252 

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] 

268 

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) 

273 

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. 

282 

283 Raises: 

284 ValueError: If the weights dimension exceeds the model capacity ``m``. 

285 

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

295 

296 R_n = R[:, :n] # noqa: N806 

297 

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) 

304 

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

313 

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

320 

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) 

324 

325 q = np.zeros(builder.n_vars) 

326 q[gamma_col] = 1.0 

327 q[u_cols] = 1.0 / k 

328 

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 

333 

334 return self._solve_and_unpack(builder, q, weights, w_cols, result)