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
« 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."""
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.
58 """
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)
67 def __post_init__(self) -> None:
68 """Initialize the risk model, variables, and bounds.
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.
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:
79 MinVar(assets=10, model={M.RISK: CVar(assets=10, rows=100)})
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)
93 else:
94 # add variable for absolute weights
95 self.variables[D._ABS] = cp.Variable(self.assets, name=D._ABS, nonneg=True)
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)
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)
105 # add bounds on assets
106 self.model[M.BOUND_ASSETS] = Bounds(assets=self.assets, name="assets", acting_on=D.WEIGHTS)
108 @property
109 @abstractmethod
110 def objective(self) -> cp.Minimize | cp.Maximize:
111 """Return the objective function."""
113 def build(self) -> Problem:
114 """Build the cvxpy problem.
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
126 problem = cp.Problem(self.objective, list(self.constraints.values()))
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 )
135 return Problem(problem=problem, model=self.model)
137 @property
138 def weights(self) -> cp.Variable:
139 """Return the asset-weight decision variable (`weights`)."""
140 return self.variables[D.WEIGHTS]
142 @property
143 def risk(self) -> Model:
144 """Return the configured risk model held under `model[M.RISK]`."""
145 return self.model[M.RISK]
147 @property
148 def factor_weights(self) -> cp.Variable:
149 """Return the factor-weight variable.
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