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

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

60 assets: int = 0 

61 factors: int | None = None 

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

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

64 variables: Variables = field(default_factory=dict) 

65 parameter: Parameter = field(default_factory=dict) 

66 

67 def __post_init__(self) -> None: 

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

69 

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

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

72 bound models. 

73 

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

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

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

77 are used with a builder: 

78 

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

80 

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

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

83 otherwise. 

84 """ 

85 if self.factors is not None: 

86 # add variable for factor weights 

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

88 # add bounds for factor weights 

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

90 # add variable for absolute factor weights 

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

92 

93 else: 

94 # add variable for absolute weights 

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

96 

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

98 if M.RISK not in self.model: 

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

100 

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

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

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

104 

105 # add bounds on assets 

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

107 

108 @property 

109 @abstractmethod 

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

111 """Return the objective function.""" 

112 

113 def build(self) -> Problem: 

114 """Build the cvxpy problem. 

115 

116 Raises: 

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

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

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

120 the whole caching story rests on. 

121 """ 

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

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

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

125 

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

127 

128 if not problem.is_dpp(): 

129 raise CvxBuildError( # noqa: TRY003 

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

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

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

133 ) 

134 

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

136 

137 @property 

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

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

140 return self.variables[D.WEIGHTS] 

141 

142 @property 

143 def risk(self) -> Model: 

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

145 return self.model[M.RISK] 

146 

147 @property 

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

149 """Return the factor-weight variable. 

150 

151 Raises: 

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

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

154 """ 

155 try: 

156 return self.variables[D.FACTOR_WEIGHTS] 

157 except KeyError as err: 

158 raise CvxDataError( # noqa: TRY003 

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

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

161 ) from err