Coverage for src/cvx/core/model.py: 100%

24 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-22 10:34 +0000

1# Copyright (c) 2025 Jebel Quant Research 

2# 

3# Licensed under the MIT License. See the LICENSE file in the project root 

4# for the full license text. 

5"""Abstract parametric model with named numpy-array parameters. 

6 

7This module provides the :class:`Model` abstract base class for any 

8parametric optimization model whose data can be stored as named 

9:class:`~cvx.core.parameter.Parameter` objects and updated independently 

10of the problem structure. 

11 

12Example: 

13 Concrete subclasses implement ``estimate`` and ``update``: 

14 

15 >>> import numpy as np 

16 >>> from cvx.risk.sample import SampleCovariance 

17 >>> model = SampleCovariance(num=3) 

18 >>> model.update( 

19 ... cov=np.eye(3), 

20 ... lower_assets=np.zeros(3), 

21 ... upper_assets=np.ones(3) 

22 ... ) 

23 >>> isinstance(model.estimate(np.ones(3) / 3), float) 

24 True 

25 

26""" 

27 

28from __future__ import annotations 

29 

30from abc import ABC, abstractmethod 

31from collections.abc import Callable 

32from dataclasses import dataclass, field 

33from typing import TYPE_CHECKING, Any 

34 

35import numpy as np 

36 

37from cvx.core.parameter import Parameter 

38 

39if TYPE_CHECKING: 

40 from cvx.core.conic import ConeProgramBuilder 

41 from cvx.core.variable import Variable 

42 

43 

44@dataclass 

45class Model(ABC): 

46 """Abstract base class for parametric optimization models. 

47 

48 A ``Model`` holds a dictionary of named :class:`~cvx.core.parameter.Parameter` 

49 objects (numpy arrays) that can be updated between solver calls without 

50 reconstructing the optimization problem structure. Subclasses implement 

51 :meth:`estimate` to evaluate the model output and :meth:`update` to refresh 

52 the parameter values. 

53 

54 Attributes: 

55 parameter: Dictionary of named :class:`~cvx.core.parameter.Parameter` 

56 objects. Parameters can be updated independently of the problem 

57 structure, making it cheap to solve a sequence of related problems. 

58 

59 Example: 

60 >>> import numpy as np 

61 >>> from cvx.risk.sample import SampleCovariance 

62 >>> model = SampleCovariance(num=2) 

63 >>> model.update( 

64 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]), 

65 ... lower_assets=np.zeros(2), 

66 ... upper_assets=np.ones(2) 

67 ... ) 

68 >>> 'chol' in model.parameter 

69 True 

70 

71 Parameters are :class:`~cvx.core.parameter.Parameter` instances: 

72 

73 >>> from cvx.core.parameter import Parameter 

74 >>> isinstance(model.parameter['chol'], Parameter) 

75 True 

76 

77 """ 

78 

79 parameter: dict[str, Parameter] = field(default_factory=dict) 

80 """Dictionary of named parameters.""" 

81 

82 @abstractmethod 

83 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float: 

84 """Evaluate the model for the given input vector. 

85 

86 Args: 

87 weights: Input vector (e.g. portfolio weights or factor exposures). 

88 **kwargs: Additional keyword arguments for subclass-specific logic. 

89 

90 Returns: 

91 Scalar float result (e.g. risk, cost, or objective contribution). 

92 

93 Example: 

94 >>> import numpy as np 

95 >>> from cvx.risk.sample import SampleCovariance 

96 >>> model = SampleCovariance(num=2) 

97 >>> model.update( 

98 ... cov=np.array([[1.0, 0.0], [0.0, 1.0]]), 

99 ... lower_assets=np.zeros(2), 

100 ... upper_assets=np.ones(2) 

101 ... ) 

102 >>> isinstance(model.estimate(np.array([0.5, 0.5])), float) 

103 True 

104 

105 """ 

106 

107 @abstractmethod 

108 def update(self, **kwargs: Any) -> None: 

109 """Update the parameter values from keyword arguments. 

110 

111 Updating parameters allows the same problem structure to be re-solved 

112 with new data without any symbolic re-compilation. 

113 

114 Args: 

115 **kwargs: New parameter values. The expected keys depend on the 

116 concrete subclass. 

117 

118 Example: 

119 >>> import numpy as np 

120 >>> from cvx.risk.sample import SampleCovariance 

121 >>> model = SampleCovariance(num=3) 

122 >>> model.update( 

123 ... cov=np.eye(3), 

124 ... lower_assets=np.zeros(3), 

125 ... upper_assets=np.ones(3) 

126 ... ) 

127 

128 """ 

129 

130 def _solve_and_unpack( 

131 self, 

132 builder: ConeProgramBuilder, 

133 q: np.ndarray, 

134 weights: Variable, 

135 w_cols: slice, 

136 result: Callable[[Any], tuple[float, float]], 

137 *, 

138 y_var: Variable | None = None, 

139 y_cols: slice | None = None, 

140 ) -> tuple[float | None, float | None, str]: 

141 """Solve an assembled cone program and unpack the primal solution. 

142 

143 This is the shared tail of every concrete :meth:`solve_minrisk`: it runs 

144 the Clarabel solver on the linear objective ``q`` and, on a solved 

145 status, copies the optimal asset weights (and, when both ``y_var`` and 

146 ``y_cols`` are supplied, the factor exposures) back into the caller's 

147 variables. The reported ``(objective, risk)`` pair is derived from the 

148 solution by the model-specific ``result`` callback. If the solver does 

149 not converge, returns ``(None, None, status)`` and leaves the variables 

150 untouched. 

151 

152 Args: 

153 builder: The cone program with all constraints already added. 

154 q: Linear objective coefficients. 

155 weights: Variable that receives the optimal asset weights. 

156 w_cols: Columns of the solution vector holding the asset weights. 

157 result: Maps the solved solution to the ``(objective, risk)`` pair. 

158 y_var: Optional variable that receives the optimal factor exposures. 

159 y_cols: Columns of the solution vector holding the factor exposures. 

160 

161 Returns: 

162 ``(objective, risk, status)`` on a solved status, otherwise 

163 ``(None, None, status)``. 

164 

165 """ 

166 sol, status = builder.solve(q) 

167 if "Solved" not in status: 

168 return None, None, status 

169 weights.value = np.array(sol.x[w_cols]) 

170 if y_var is not None and y_cols is not None: 

171 y_var.value = np.array(sol.x[y_cols]) 

172 objective, risk = result(sol) 

173 return objective, risk, status