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
« 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."""
16from __future__ import annotations
18from abc import ABC, abstractmethod
19from dataclasses import dataclass, field
21import cvxpy as cp
23from cvxmarkowitz.cvxerror import CvxBuildError, CvxDataError, CvxError
24from cvxmarkowitz.model import Model
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
37# Re-exported for backwards compatibility: ``Problem`` moved to
38# cvxmarkowitz.problem, ``CvxError`` lives in cvxmarkowitz.cvxerror.
39__all__ = ["Builder", "CvxError", "Problem"]
42@dataclass(frozen=True)
43class Builder(ABC):
44 """Assemble variables, models, and constraints for Markowitz problems.
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.
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:
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 """
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)
79 def __post_init__(self) -> None:
80 """Initialize the risk model, variables, and bounds.
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.
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:
91 MinVar(assets=10, model={M.RISK: CVar(assets=10, rows=100)})
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)
105 else:
106 # add variable for absolute weights
107 self.variables[D._ABS] = cp.Variable(self.assets, name=D._ABS, nonneg=True)
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)
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)
117 # add bounds on assets
118 self.model[M.BOUND_ASSETS] = Bounds(assets=self.assets, name="assets", acting_on=D.WEIGHTS)
120 @property
121 @abstractmethod
122 def objective(self) -> cp.Minimize | cp.Maximize:
123 """Return the objective function."""
125 def build(self) -> Problem:
126 """Build the cvxpy problem.
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
138 problem = cp.Problem(self.objective, list(self.constraints.values()))
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 )
147 return Problem(problem=problem, model=self.model)
149 @property
150 def weights(self) -> cp.Variable:
151 """Return the asset-weight decision variable (`weights`)."""
152 return self.variables[D.WEIGHTS]
154 @property
155 def risk(self) -> Model:
156 """Return the configured risk model held under `model[M.RISK]`."""
157 return self.model[M.RISK]
159 @property
160 def factor_weights(self) -> cp.Variable:
161 """Return the factor-weight variable.
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