Coverage for src/cvxmarkowitz/problem.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 05:46 +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"""The built problem container returned by :meth:`Builder.build`.""" 

15 

16from __future__ import annotations 

17 

18from collections.abc import Generator 

19from dataclasses import dataclass, field 

20from typing import Any 

21 

22import cvxpy as cp 

23import numpy as np 

24 

25from cvxmarkowitz.cvxerror import CvxDataError, CvxSolverError 

26from cvxmarkowitz.model import Model 

27from cvxmarkowitz.names import DataNames as D 

28from cvxmarkowitz.types import Matrix, Parameter, Variables 

29 

30 

31@dataclass(frozen=True) 

32class Problem: 

33 """Frozen container holding a built cvxpy problem and its named models.""" 

34 

35 problem: cp.Problem 

36 model: dict[str, Model] = field(default_factory=dict) 

37 

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

39 """Overwrite the parameter values of every model, **in place**. 

40 

41 This mutates the problem rather than returning a new one. `frozen=True` 

42 on this dataclass only stops attribute rebinding; the `model` mapping and 

43 the cvxpy Parameters it holds stay mutable, and that is deliberate -- 

44 writing new values into the same compiled problem is exactly what lets 

45 cvxpy reuse its cached canonicalization across solves: 

46 

47 problem = MinVar(assets=4).build() # compile once 

48 for data in datasets: 

49 problem.update(**data) # overwrite in place 

50 problem.solve() 

51 

52 Consequently there is only ever one problem. Two `update` calls against 

53 the same object do not yield two independently parametrized problems -- 

54 the second overwrites the first. Call `build()` again for that. 

55 

56 The whole payload is validated against every model before the first 

57 value is written, so a rejected payload leaves the problem exactly as it 

58 was rather than half-overwritten. See `_validate`. 

59 

60 Returns `None` (like `Model.update`) so the in-place semantics are 

61 visible at the call site. 

62 

63 Raises: 

64 CvxDataError: If any model is missing data for one of its parameters, 

65 or if the models disagree about how large the universe is. 

66 

67 Example: 

68 An incomplete payload is rejected before anything is written; a 

69 complete one describing 2 of the 4 compiled assets is accepted: 

70 

71 >>> import numpy as np 

72 >>> from cvxmarkowitz import MinVar 

73 >>> from cvxmarkowitz.names import DataNames as D 

74 >>> problem = MinVar(assets=4).build() 

75 >>> chol = np.linalg.cholesky(np.array([[1.0, 0.5], [0.5, 2.0]])).T 

76 >>> problem.update(**{D.CHOLESKY: chol}) 

77 Traceback (most recent call last): 

78 ... 

79 cvxmarkowitz.cvxerror.CvxDataError: Missing data for vola_uncertainty in model risk 

80 >>> problem.update( 

81 ... **{ 

82 ... D.CHOLESKY: chol, 

83 ... D.LOWER_BOUND_ASSETS: np.zeros(2), 

84 ... D.UPPER_BOUND_ASSETS: np.ones(2), 

85 ... D.VOLA_UNCERTAINTY: np.zeros(2), 

86 ... } 

87 ... ) 

88 """ 

89 self._validate(**kwargs) 

90 

91 for model in self.model.values(): 

92 # It's tempting to operate without the models at this stage. 

93 # However, we would give up a lot of convenience. For example, 

94 # the models can be prepared to deal with data that has not 

95 # exactly the correct shape. 

96 model.update(**kwargs) 

97 

98 def _validate(self, **kwargs: Matrix) -> None: 

99 """Check the payload against every model, writing nothing. 

100 

101 Two passes, both over all models, both raising `CvxDataError`: 

102 

103 1. every keyword each model declares is present, and 

104 2. the models agree on the size of each variable they describe. 

105 

106 The second is not redundant with the shape checks inside the models. 

107 `Model.update` pads a short input up to the compiled size, so a payload 

108 that describes two assets to the risk model and four to the bounds 

109 solves without complaint -- and solves wrongly, because the padded tail 

110 carries no risk while the bounds leave it free, which the solver reads as 

111 two riskless assets. Nothing inside a single model can see that; only 

112 comparing the models can. 

113 

114 Raises: 

115 CvxDataError: On a missing keyword, or on models that disagree about 

116 the size of a variable. 

117 """ 

118 for name, model in self.model.items(): 

119 # `Model.keywords`, not `model.data`: a model may consume a keyword 

120 # that `data` does not back (see `ExpectedReturns.keywords`), and 

121 # checking `data` alone let those through to a bare KeyError. 

122 for key in model.keywords: 

123 if key not in kwargs: 

124 raise CvxDataError(f"Missing data for {key} in model {name}") # noqa: TRY003 

125 

126 claimed: dict[str, tuple[str, int]] = {} 

127 

128 for name, model in self.model.items(): 

129 for variable, size in model.dimensions(**kwargs): 

130 first_name, first_size = claimed.setdefault(variable, (name, size)) 

131 

132 if size != first_size: 

133 raise CvxDataError( # noqa: TRY003 

134 f"Inconsistent size for {variable}: model {first_name} was given " 

135 f"{first_size}, model {name} was given {size}" 

136 ) 

137 

138 def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float: 

139 """Solve the problem. 

140 

141 Example: 

142 >>> import numpy as np 

143 >>> from cvxmarkowitz import MinVar 

144 >>> from cvxmarkowitz.names import DataNames as D 

145 >>> problem = MinVar(assets=4).build() 

146 >>> problem.update( 

147 ... **{ 

148 ... D.CHOLESKY: np.linalg.cholesky(np.array([[1.0, 0.5], [0.5, 2.0]])).T, 

149 ... D.LOWER_BOUND_ASSETS: np.zeros(2), 

150 ... D.UPPER_BOUND_ASSETS: np.ones(2), 

151 ... D.VOLA_UNCERTAINTY: np.zeros(2), 

152 ... } 

153 ... ) 

154 >>> round(problem.solve(), 4) 

155 0.9354 

156 >>> np.round(problem.weights, 3).tolist() 

157 [0.75, 0.25, 0.0, 0.0] 

158 """ 

159 value = self.problem.solve(solver=solver, **kwargs) 

160 

161 if self.problem.status is not cp.OPTIMAL: 

162 raise CvxSolverError(f"Problem status is {self.problem.status}") # noqa: TRY003 

163 

164 return float(value) 

165 

166 def get_problem_data( 

167 self, 

168 solver: str = cp.CLARABEL, 

169 gp: bool = False, 

170 enforce_dpp: bool = False, 

171 ignore_dpp: bool = False, 

172 verbose: bool = False, 

173 canon_backend: str | None = None, 

174 solver_opts: dict[str, Any] | None = None, 

175 ) -> Any: 

176 """Return the low-level data the solver would be handed for this problem. 

177 

178 This forwards to :meth:`cvxpy.Problem.get_problem_data`, exposing the 

179 compiled form of the problem without solving it. Useful for inspecting 

180 the canonicalization or for driving a solver directly. 

181 

182 Args: 

183 solver: The target solver to compile for. 

184 gp: Whether to parse the problem as a disciplined geometric program. 

185 enforce_dpp: Raise if the problem is not DPP-compliant. 

186 ignore_dpp: Treat the problem as non-DPP even if it is compliant. 

187 verbose: Print compilation progress. 

188 canon_backend: Canonicalization backend to use, or None for the default. 

189 solver_opts: Extra options forwarded to the solver. 

190 

191 Returns: 

192 The ``(data, chain, inverse_data)`` triple produced by cvxpy. 

193 """ 

194 return self.problem.get_problem_data( 

195 solver, 

196 gp=gp, 

197 enforce_dpp=enforce_dpp, 

198 ignore_dpp=ignore_dpp, 

199 verbose=verbose, 

200 canon_backend=canon_backend, 

201 solver_opts=solver_opts, 

202 ) 

203 

204 @property 

205 def value(self) -> float: 

206 """Return the current objective value of the solved problem.""" 

207 return float(self.problem.value) 

208 

209 def is_dpp(self) -> bool: 

210 """Return True if the problem satisfies disciplined parameterized programming.""" 

211 return bool(self.problem.is_dpp()) 

212 

213 @property 

214 def data(self) -> Generator[tuple[tuple[str, str], cp.Parameter]]: 

215 """Yield ``((model_name, param_key), parameter)`` pairs for all models.""" 

216 for name, model in self.model.items(): 

217 for key, value in model.data.items(): 

218 yield (name, key), value 

219 

220 @property 

221 def parameter(self) -> Parameter: 

222 """Return a mapping of parameter names to cvxpy Parameter objects.""" 

223 return dict(self.problem.param_dict.items()) 

224 

225 @property 

226 def variables(self) -> Variables: 

227 """Return a mapping of variable names to cvxpy Variable objects.""" 

228 return dict(self.problem.var_dict.items()) 

229 

230 @property 

231 def weights(self) -> Matrix: 

232 """Return the optimal asset weights as a numpy array.""" 

233 return np.array(self.variables[D.WEIGHTS].value) 

234 

235 @property 

236 def factor_weights(self) -> Matrix: 

237 """Return the optimal factor weights as a numpy array. 

238 

239 Raises: 

240 CvxDataError: If the problem was built without a factor risk model, 

241 in which case there is no factor-weight variable. 

242 """ 

243 try: 

244 return np.array(self.variables[D.FACTOR_WEIGHTS].value) 

245 except KeyError as err: 

246 raise CvxDataError( # noqa: TRY003 

247 "No factor weights: this problem was built without 'factors'." 

248 ) from err