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

51 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-11 10:51 +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 abstractmethod 

19from dataclasses import dataclass, field 

20 

21import cvxpy as cp 

22 

23from cvxmarkowitz.cvxerror import CvxError 

24from cvxmarkowitz.model import Model 

25from cvxmarkowitz.models.bounds import Bounds 

26from cvxmarkowitz.names import DataNames as D 

27from cvxmarkowitz.names import ModelName as M 

28from cvxmarkowitz.problem import _Problem, deserialize 

29from cvxmarkowitz.risk.factor.factor import FactorModel 

30from cvxmarkowitz.risk.sample.sample import SampleCovariance 

31from cvxmarkowitz.types import Parameter, Variables 

32 

33# Re-exported for backwards compatibility: ``deserialize``/``_Problem`` moved to 

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

35__all__ = ["Builder", "CvxError", "_Problem", "deserialize"] 

36 

37 

38@dataclass(frozen=True) 

39class Builder: 

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

41 

42 Attributes: 

43 assets: Number of asset weights to optimize. 

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

45 otherwise a SampleCovariance risk model is configured. 

46 model: Mapping of model components (e.g., bounds, risk) by name. 

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

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

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

50 """ 

51 

52 assets: int = 0 

53 factors: int | None = None 

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

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

56 variables: Variables = field(default_factory=dict) 

57 parameter: Parameter = field(default_factory=dict) 

58 

59 def __post_init__(self) -> None: 

60 """Initialize default risk model, variables, and bounds. 

61 

62 Selects a factor-based or sample-covariance risk model depending on 

63 `factors`, creates the corresponding variables (weights and, if 

64 applicable, factor weights and their absolute values), and registers 

65 per-asset and/or per-factor bound models. 

66 """ 

67 # pick the correct risk model 

68 if self.factors is not None: 

69 self.model[M.RISK] = FactorModel(assets=self.assets, factors=self.factors) 

70 

71 # add variable for factor weights 

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

73 # add bounds for factor weights 

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

75 # add variable for absolute factor weights 

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

77 

78 else: 

79 self.model[M.RISK] = SampleCovariance(assets=self.assets) 

80 # add variable for absolute weights 

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

82 

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

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

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

86 

87 # add bounds on assets 

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

89 

90 @property 

91 @abstractmethod 

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

93 """Return the objective function.""" 

94 

95 def build(self) -> _Problem: 

96 """Build the cvxpy problem.""" 

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

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

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

100 

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

102 assert problem.is_dpp(), "Problem is not DPP" # noqa: S101 

103 

104 return _Problem(problem=problem, model=self.model) 

105 

106 @property 

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

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

109 return self.variables[D.WEIGHTS] 

110 

111 @property 

112 def risk(self) -> Model: 

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

114 return self.model[M.RISK] 

115 

116 @property 

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

118 """Return the factor-weight variable. 

119 

120 Note: Only present when a factor risk model is used; accessing this 

121 property without factors configured will raise a KeyError. 

122 """ 

123 return self.variables[D.FACTOR_WEIGHTS]