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
« 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."""
16from __future__ import annotations
18from abc import abstractmethod
19from dataclasses import dataclass, field
21import cvxpy as cp
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
33# Re-exported for backwards compatibility: ``deserialize``/``_Problem`` moved to
34# cvxmarkowitz.problem, ``CvxError`` lives in cvxmarkowitz.cvxerror.
35__all__ = ["Builder", "CvxError", "_Problem", "deserialize"]
38@dataclass(frozen=True)
39class Builder:
40 """Assemble variables, models, and constraints for Markowitz problems.
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 """
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)
59 def __post_init__(self) -> None:
60 """Initialize default risk model, variables, and bounds.
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)
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)
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)
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)
87 # add bounds on assets
88 self.model[M.BOUND_ASSETS] = Bounds(assets=self.assets, name="assets", acting_on=D.WEIGHTS)
90 @property
91 @abstractmethod
92 def objective(self) -> cp.Minimize | cp.Maximize:
93 """Return the objective function."""
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
101 problem = cp.Problem(self.objective, list(self.constraints.values()))
102 assert problem.is_dpp(), "Problem is not DPP" # noqa: S101
104 return _Problem(problem=problem, model=self.model)
106 @property
107 def weights(self) -> cp.Variable:
108 """Return the asset-weight decision variable (`weights`)."""
109 return self.variables[D.WEIGHTS]
111 @property
112 def risk(self) -> Model:
113 """Return the configured risk model held under `model[M.RISK]`."""
114 return self.model[M.RISK]
116 @property
117 def factor_weights(self) -> cp.Variable:
118 """Return the factor-weight variable.
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]