Coverage for src / cvx / markowitz / builder.py: 100%
98 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 13:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 13:49 +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
18import pickle
19from abc import abstractmethod
20from collections.abc import Generator
21from dataclasses import dataclass, field
22from os import PathLike
23from typing import Any
25import cvxpy as cp
26import numpy as np
28from cvx.markowitz.cvxerror import CvxError
29from cvx.markowitz.model import Model
30from cvx.markowitz.models.bounds import Bounds
31from cvx.markowitz.names import DataNames as D
32from cvx.markowitz.names import ModelName as M
33from cvx.markowitz.risk.factor.factor import FactorModel
34from cvx.markowitz.risk.sample.sample import SampleCovariance
35from cvx.markowitz.types import File, Matrix, Parameter, Variables
38def deserialize(
39 problem_file: str | bytes | PathLike[str] | PathLike[bytes] | int,
40) -> Any:
41 """Load a previously serialized Markowitz problem from disk.
43 Args:
44 problem_file: Path to the pickle file created by `_Problem.serialize`.
46 Returns:
47 The deserialized `_Problem` instance.
48 """
49 with open(problem_file, "rb") as infile:
50 return pickle.load(infile)
53@dataclass(frozen=True)
54class _Problem:
55 problem: cp.Problem
56 model: dict[str, Model] = field(default_factory=dict)
58 def update(self, **kwargs: Matrix) -> _Problem:
59 """Update the problem."""
60 for name, model in self.model.items():
61 for key in model.data.keys():
62 if key not in kwargs:
63 raise CvxError(f"Missing data for {key} in model {name}")
65 # It's tempting to operate without the models at this stage.
66 # However, we would give up a lot of convenience. For example,
67 # the models can be prepared to deal with data that has not
68 # exactly the correct shape.
69 model.update(**kwargs)
71 return self
73 def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float:
74 """Solve the problem."""
75 value = self.problem.solve(solver=solver, **kwargs)
77 if self.problem.status is not cp.OPTIMAL:
78 raise CvxError(f"Problem status is {self.problem.status}")
80 return float(value)
82 @property
83 def value(self) -> float:
84 return float(self.problem.value)
86 def is_dpp(self) -> bool:
87 return bool(self.problem.is_dpp())
89 @property
90 def data(self) -> Generator[tuple[tuple[str, str], Matrix]]:
91 for name, model in self.model.items():
92 for key, value in model.data.items():
93 yield (name, key), value
95 @property
96 def parameter(self) -> Parameter:
97 return dict(self.problem.param_dict.items())
99 @property
100 def variables(self) -> Variables:
101 return dict(self.problem.var_dict.items())
103 @property
104 def weights(self) -> Matrix:
105 return np.array(self.variables[D.WEIGHTS].value)
107 @property
108 def factor_weights(self) -> Matrix:
109 return np.array(self.variables[D.FACTOR_WEIGHTS].value)
111 def serialize(self, problem_file: File) -> None:
112 with open(problem_file, "wb") as outfile:
113 pickle.dump(self, outfile)
116@dataclass(frozen=True)
117class Builder:
118 """Assemble variables, models, and constraints for Markowitz problems.
120 Attributes:
121 assets: Number of asset weights to optimize.
122 factors: Optional number of factors; if provided, a FactorModel is used,
123 otherwise a SampleCovariance risk model is configured.
124 model: Mapping of model components (e.g., bounds, risk) by name.
125 constraints: Mapping of named cvxpy constraints added during build.
126 variables: Mapping of problem variables (weights, factor weights, etc.).
127 parameter: Mapping of cvxpy Parameters used by the builder/models.
128 """
130 assets: int = 0
131 factors: int | None = None
132 model: dict[str, Model] = field(default_factory=dict)
133 constraints: dict[str, cp.Constraint] = field(default_factory=dict)
134 variables: Variables = field(default_factory=dict)
135 parameter: Parameter = field(default_factory=dict)
137 def __post_init__(self) -> None:
138 """Initialize default risk model, variables, and bounds.
140 Selects a factor-based or sample-covariance risk model depending on
141 `factors`, creates the corresponding variables (weights and, if
142 applicable, factor weights and their absolute values), and registers
143 per-asset and/or per-factor bound models.
144 """
145 # pick the correct risk model
146 if self.factors is not None:
147 self.model[M.RISK] = FactorModel(assets=self.assets, factors=self.factors)
149 # add variable for factor weights
150 self.variables[D.FACTOR_WEIGHTS] = cp.Variable(self.factors, name=D.FACTOR_WEIGHTS)
151 # add bounds for factor weights
152 self.model[M.BOUND_FACTORS] = Bounds(assets=self.factors, name="factors", acting_on=D.FACTOR_WEIGHTS)
153 # add variable for absolute factor weights
154 self.variables[D._ABS] = cp.Variable(self.factors, name=D._ABS, nonneg=True)
156 else:
157 self.model[M.RISK] = SampleCovariance(assets=self.assets)
158 # add variable for absolute weights
159 self.variables[D._ABS] = cp.Variable(self.assets, name=D._ABS, nonneg=True)
161 # Note that for the SampleCovariance model the factor_weights are None.
162 # They are only included for the harmony of the interfaces for both models.
163 self.variables[D.WEIGHTS] = cp.Variable(self.assets, name=D.WEIGHTS)
165 # add bounds on assets
166 self.model[M.BOUND_ASSETS] = Bounds(assets=self.assets, name="assets", acting_on=D.WEIGHTS)
168 @property
169 @abstractmethod
170 def objective(self) -> cp.Expression:
171 """Return the objective function."""
173 def build(self) -> _Problem:
174 """Build the cvxpy problem."""
175 for name_model, model in self.model.items():
176 for name_constraint, constraint in model.constraints(self.variables).items():
177 self.constraints[f"{name_model}_{name_constraint}"] = constraint
179 problem = cp.Problem(self.objective, list(self.constraints.values()))
180 assert problem.is_dpp(), "Problem is not DPP"
182 return _Problem(problem=problem, model=self.model)
184 @property
185 def weights(self) -> cp.Variable:
186 """Return the asset-weight decision variable (`weights`)."""
187 return self.variables[D.WEIGHTS]
189 @property
190 def risk(self) -> Model:
191 """Return the configured risk model held under `model[M.RISK]`."""
192 return self.model[M.RISK]
194 @property
195 def factor_weights(self) -> cp.Variable:
196 """Return the factor-weight variable.
198 Note: Only present when a factor risk model is used; accessing this
199 property without factors configured will raise a KeyError.
200 """
201 return self.variables[D.FACTOR_WEIGHTS]