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

54 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"""Core builder classes to assemble and solve Markowitz problems.""" 

15 

16from __future__ import annotations 

17 

18from abc import ABC, abstractmethod 

19from dataclasses import dataclass, field 

20 

21import cvxpy as cp 

22 

23from cvxmarkowitz.cvxerror import CvxBuildError, CvxDataError, CvxError 

24from cvxmarkowitz.model import Model 

25 

26# `Bounds` is imported concretely on purpose: it is not a default being chosen 

27# among alternatives but part of what a Builder unconditionally is, so putting 

28# it behind a selector would be indirection with nothing to select. The risk 

29# model *is* a choice, and `default_risk_model` owns it -- see cvxmarkowitz.risk. 

30from cvxmarkowitz.models.bounds import Bounds 

31from cvxmarkowitz.names import DataNames as D 

32from cvxmarkowitz.names import ModelName as M 

33from cvxmarkowitz.problem import Problem 

34from cvxmarkowitz.risk import default_risk_model 

35from cvxmarkowitz.types import Parameter, Variables 

36 

37# Re-exported for backwards compatibility: ``Problem`` moved to 

38# cvxmarkowitz.problem, ``CvxError`` lives in cvxmarkowitz.cvxerror. 

39__all__ = ["Builder", "CvxError", "Problem"] 

40 

41 

42@dataclass(frozen=True) 

43class Builder(ABC): 

44 """Assemble variables, models, and constraints for Markowitz problems. 

45 

46 Attributes: 

47 assets: Number of asset weights to optimize. 

48 factors: Optional number of factors; if provided, a FactorModel is used, 

49 otherwise a SampleCovariance risk model is configured. Ignored for the 

50 choice of risk model when one is injected via `model`, but still 

51 controls which variables and bounds are created. 

52 model: Mapping of model components (e.g., bounds, risk) by name. Pass an 

53 entry under `ModelName.RISK` to supply your own risk model instead of 

54 the `factors`-based default -- see `__post_init__`. 

55 constraints: Mapping of named cvxpy constraints added during build. 

56 variables: Mapping of problem variables (weights, factor weights, etc.). 

57 parameter: Mapping of cvxpy Parameters used by the builder/models. 

58 

59 Example: 

60 `Builder` is abstract; a concrete subclass such as `MinVar` supplies the 

61 objective. `build` compiles the problem once, and it is DPP-compliant: 

62 

63 >>> from cvxmarkowitz import MinVar, Problem 

64 >>> builder = MinVar(assets=4) 

65 >>> builder.weights.shape 

66 (4,) 

67 >>> problem = builder.build() 

68 >>> isinstance(problem, Problem), problem.is_dpp() 

69 (True, True) 

70 """ 

71 

72 assets: int = 0 

73 factors: int | None = None 

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

75 constraints: dict[str, cp.Constraint] = field(default_factory=dict) 

76 variables: Variables = field(default_factory=dict) 

77 parameter: Parameter = field(default_factory=dict) 

78 

79 def __post_init__(self) -> None: 

80 """Initialize the risk model, variables, and bounds. 

81 

82 Creates the variables (weights and, if `factors` is given, factor weights 

83 and their absolute values) and registers the per-asset and/or per-factor 

84 bound models. 

85 

86 The risk model is only defaulted when the caller did not supply one. 

87 Passing `model={ModelName.RISK: my_model}` to the constructor keeps that 

88 model, which is how risk models outside the two defaults -- `CVar`, say -- 

89 are used with a builder: 

90 

91 MinVar(assets=10, model={M.RISK: CVar(assets=10, rows=100)}) 

92 

93 With no entry under `ModelName.RISK`, `cvxmarkowitz.risk.default_risk_model` 

94 picks one: a `FactorModel` when `factors` is set, a `SampleCovariance` 

95 otherwise. 

96 """ 

97 if self.factors is not None: 

98 # add variable for factor weights 

99 self.variables[D.FACTOR_WEIGHTS] = cp.Variable(self.factors, name=D.FACTOR_WEIGHTS) 

100 # add bounds for factor weights 

101 self.model[M.BOUND_FACTORS] = Bounds(assets=self.factors, name="factors", acting_on=D.FACTOR_WEIGHTS) 

102 # add variable for absolute factor weights 

103 self.variables[D._ABS] = cp.Variable(self.factors, name=D._ABS, nonneg=True) 

104 

105 else: 

106 # add variable for absolute weights 

107 self.variables[D._ABS] = cp.Variable(self.assets, name=D._ABS, nonneg=True) 

108 

109 # pick the default risk model, unless the caller injected one 

110 if M.RISK not in self.model: 

111 self.model[M.RISK] = default_risk_model(assets=self.assets, factors=self.factors) 

112 

113 # Note that for the SampleCovariance model the factor_weights are None. 

114 # They are only included for the harmony of the interfaces for both models. 

115 self.variables[D.WEIGHTS] = cp.Variable(self.assets, name=D.WEIGHTS) 

116 

117 # add bounds on assets 

118 self.model[M.BOUND_ASSETS] = Bounds(assets=self.assets, name="assets", acting_on=D.WEIGHTS) 

119 

120 @property 

121 @abstractmethod 

122 def objective(self) -> cp.Minimize | cp.Maximize: 

123 """Return the objective function.""" 

124 

125 def build(self) -> Problem: 

126 """Build the cvxpy problem. 

127 

128 Raises: 

129 CvxBuildError: If the assembled problem is not DPP-compliant. This is 

130 checked with a raise rather than an `assert` on purpose: `assert` 

131 is stripped under `python -O`, and DPP compliance is the invariant 

132 the whole caching story rests on. 

133 """ 

134 for name_model, model in self.model.items(): 

135 for name_constraint, constraint in model.constraints(self.variables).items(): 

136 self.constraints[f"{name_model}_{name_constraint}"] = constraint 

137 

138 problem = cp.Problem(self.objective, list(self.constraints.values())) 

139 

140 if not problem.is_dpp(): 

141 raise CvxBuildError( # noqa: TRY003 

142 "The assembled problem is not DPP-compliant, so cvxpy cannot cache " 

143 "its canonicalization. Check the objective and the constraints for " 

144 "expressions that are not affine in the parameters." 

145 ) 

146 

147 return Problem(problem=problem, model=self.model) 

148 

149 @property 

150 def weights(self) -> cp.Variable: 

151 """Return the asset-weight decision variable (`weights`).""" 

152 return self.variables[D.WEIGHTS] 

153 

154 @property 

155 def risk(self) -> Model: 

156 """Return the configured risk model held under `model[M.RISK]`.""" 

157 return self.model[M.RISK] 

158 

159 @property 

160 def factor_weights(self) -> cp.Variable: 

161 """Return the factor-weight variable. 

162 

163 Raises: 

164 CvxDataError: If the builder was constructed without `factors`, in 

165 which case there is no factor-weight variable to return. 

166 """ 

167 try: 

168 return self.variables[D.FACTOR_WEIGHTS] 

169 except KeyError as err: 

170 raise CvxDataError( # noqa: TRY003 

171 "No factor weights: this builder was constructed without 'factors'. " 

172 "Pass factors=<number of factors> to use a factor risk model." 

173 ) from err