Coverage for src/cvxmarkowitz/problem.py: 100%
58 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"""The built problem container and its (de)serialization round-trip."""
16from __future__ import annotations
18import pickle # nosec B403
19from collections.abc import Generator
20from dataclasses import dataclass, field
21from os import PathLike
22from typing import Any
24import cvxpy as cp
25import numpy as np
27from cvxmarkowitz.cvxerror import CvxError
28from cvxmarkowitz.model import Model
29from cvxmarkowitz.names import DataNames as D
30from cvxmarkowitz.types import File, Matrix, Parameter, Variables
33def deserialize(
34 problem_file: str | bytes | PathLike[str] | PathLike[bytes] | int,
35 *,
36 trusted: bool = False,
37) -> Any:
38 """Load a previously serialized Markowitz problem from disk.
40 .. warning::
42 This uses :func:`pickle.load`, which executes arbitrary code while
43 unpickling. Only ever call this on files you produced yourself with
44 :meth:`_Problem.serialize`. Never deserialize a file received from an
45 untrusted or unauthenticated source — doing so is equivalent to
46 running that source's code on your machine.
48 To make that trust boundary explicit, deserialization is opt-in: you must
49 pass ``trusted=True`` to confirm the file is one you produced yourself.
50 Calling without it raises :class:`~cvxmarkowitz.cvxerror.CvxError` rather
51 than silently unpickling.
53 Args:
54 problem_file: Path to the pickle file created by `_Problem.serialize`.
55 trusted: Must be set to ``True`` to confirm the file originates from a
56 trusted source. Defaults to ``False``, which refuses to load.
58 Returns:
59 The deserialized `_Problem` instance.
61 Raises:
62 CvxError: If ``trusted`` is not explicitly set to ``True``.
63 """
64 if not trusted:
65 raise CvxError( # noqa: TRY003
66 "Refusing to deserialize: pickle.load executes arbitrary code. "
67 "Pass trusted=True only for a file you produced yourself with "
68 "_Problem.serialize()."
69 )
70 # nosec B301 / noqa: S301: pickle is the intended format for round-tripping a
71 # built problem. The trust boundary is guarded by the trusted flag above; the
72 # input is assumed to be a self-produced serialize() file.
73 with open(problem_file, "rb") as infile:
74 return pickle.load(infile) # nosec B301 # noqa: S301
77@dataclass(frozen=True)
78class _Problem:
79 """Frozen container holding a built cvxpy problem and its named models."""
81 problem: cp.Problem
82 model: dict[str, Model] = field(default_factory=dict)
84 def update(self, **kwargs: Matrix) -> _Problem:
85 """Update the problem."""
86 for name, model in self.model.items():
87 for key in model.data:
88 if key not in kwargs:
89 raise CvxError(f"Missing data for {key} in model {name}") # noqa: TRY003
91 # It's tempting to operate without the models at this stage.
92 # However, we would give up a lot of convenience. For example,
93 # the models can be prepared to deal with data that has not
94 # exactly the correct shape.
95 model.update(**kwargs)
97 return self
99 def solve(self, solver: str = cp.CLARABEL, **kwargs: Any) -> float:
100 """Solve the problem."""
101 value = self.problem.solve(solver=solver, **kwargs)
103 if self.problem.status is not cp.OPTIMAL:
104 raise CvxError(f"Problem status is {self.problem.status}") # noqa: TRY003
106 return float(value)
108 @property
109 def value(self) -> float:
110 """Return the current objective value of the solved problem."""
111 return float(self.problem.value)
113 def is_dpp(self) -> bool:
114 """Return True if the problem satisfies disciplined parameterized programming."""
115 return bool(self.problem.is_dpp())
117 @property
118 def data(self) -> Generator[tuple[tuple[str, str], cp.Parameter]]:
119 """Yield ``((model_name, param_key), parameter)`` pairs for all models."""
120 for name, model in self.model.items():
121 for key, value in model.data.items():
122 yield (name, key), value
124 @property
125 def parameter(self) -> Parameter:
126 """Return a mapping of parameter names to cvxpy Parameter objects."""
127 return dict(self.problem.param_dict.items())
129 @property
130 def variables(self) -> Variables:
131 """Return a mapping of variable names to cvxpy Variable objects."""
132 return dict(self.problem.var_dict.items())
134 @property
135 def weights(self) -> Matrix:
136 """Return the optimal asset weights as a numpy array."""
137 return np.array(self.variables[D.WEIGHTS].value)
139 @property
140 def factor_weights(self) -> Matrix:
141 """Return the optimal factor weights as a numpy array."""
142 return np.array(self.variables[D.FACTOR_WEIGHTS].value)
144 def serialize(self, problem_file: File) -> None:
145 """Pickle this problem to disk for later reuse with `deserialize`."""
146 with open(problem_file, "wb") as outfile:
147 pickle.dump(self, outfile)