Coverage for src/cvxmarkowitz/model.py: 100%
21 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"""Abstract cp model."""
16from __future__ import annotations
18from abc import ABC, abstractmethod
19from dataclasses import dataclass, field
21import cvxpy as cp
23from cvxmarkowitz.types import Constraints, Dimensions, Matrix, Parameter, Variables
26@dataclass(frozen=True)
27class Model(ABC):
28 """Abstract base for every component a `Builder` assembles.
30 Risk models are only part of it: `Bounds`, `ExpectedReturns`, `TradingCosts`
31 and `HoldingCosts` derive from this too. A component owns the cvxpy
32 Parameters it is built from and contributes an objective term (`estimate`),
33 constraints (`constraints`), or -- as `Bounds` does -- only the latter.
35 Attributes:
36 assets: Number of entries the component's parameters are sized for.
37 parameter: cvxpy Parameters the component holds that `data` does not
38 back. Some are set once at construction (`TradingCosts` fixes the
39 cost exponent this way); others are written by `update` from a
40 keyword, which is the case that requires overriding `keywords`.
41 data: cvxpy Parameters `update` fills from its keyword arguments, and
42 the default source of `keywords`.
43 """
45 assets: int
46 parameter: Parameter = field(default_factory=dict)
47 data: Parameter = field(default_factory=dict)
49 @property
50 def keywords(self) -> tuple[str, ...]:
51 """Return the keyword names this model's `update` consumes.
53 `Problem.update` checks these against the keywords it was handed before
54 any value is written, so that a missing one is reported as a
55 `CvxDataError` rather than escaping as whatever the model's own
56 `kwargs[...]` lookup happens to raise.
58 The default is the keys of `data`, which is where a model registers the
59 cvxpy Parameters it fills from keyword arguments. **Override it whenever
60 `update` reads a keyword that `data` does not back** -- otherwise the
61 check cannot see that keyword and a caller who omits it gets a bare
62 `KeyError`, which is outside the `CvxError` tree the package promises.
63 `ExpectedReturns` is the one such model today.
65 Returns a tuple rather than a set so the key named in the error message
66 follows the insertion order of `data` instead of set iteration order.
67 """
68 return tuple(self.data)
70 @abstractmethod
71 def estimate(self, variables: Variables) -> cp.Expression:
72 """Return this component's objective contribution, given the variables.
74 What the expression means is the component's own business: a risk model
75 returns a risk measure (`FactorModel` and `SampleCovariance` a norm, not
76 a variance; `CVar` a conditional value-at-risk), `ExpectedReturns` a
77 robust expected return, the cost models a cost. A component that
78 contributes no objective term at all raises `NotImplementedError` here
79 -- see `Bounds`, which is pure constraints.
80 """
82 @abstractmethod
83 def dimensions(self, **kwargs: Matrix) -> Dimensions:
84 """Return the size every input in `kwargs` implies for a problem variable.
86 One `(variable name, size)` pair per input this model consumes, the
87 variable named by `DataNames` -- `WEIGHTS` for anything sized by the
88 asset universe, `FACTOR_WEIGHTS` for anything sized by the factors.
89 Several pairs may name the same variable; that is the point.
91 `Problem.update` collects these across every model and rejects a payload
92 whose claims disagree. It has to, because `update` pads short inputs up
93 to the compiled size (see `cvxmarkowitz.utils.fill`): a payload that
94 hands the risk model two assets and the bounds four does not fail on its
95 own -- it leaves the padded tail both zero-risk and unbounded, and the
96 solver puts the whole portfolio there. Each model already checks its own
97 inputs against each other; this is what checks them across models.
99 Abstract, rather than a default a model may quietly not override, for
100 the reason `keywords` documents: that shape of contract has already been
101 got wrong once here. A model with nothing to declare returns `()`, but it
102 forfeits the cross-check for every keyword it consumes.
103 """
105 @abstractmethod
106 def update(self, **kwargs: Matrix) -> None:
107 """Write fresh values into this component's parameters, in place.
109 Each implementation documents the keywords it consumes; `keywords` is
110 what `Problem.update` checks those against before any value is written.
111 """
113 def constraints(self, variables: Variables) -> Constraints: # noqa: ARG002 # base default ignores `variables`; name kept to match overrides (LSP)
114 """Return this component's named constraints; none by default."""
115 return {}