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

52 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"""Shared helper for building and solving Clarabel conic programs. 

6 

7This module provides the :class:`ConeProgramBuilder`, a small utility that 

8accumulates the constraint blocks and cones of a conic program 

9 

10 minimize q' x 

11 subject to A x + s = b, s in K 

12 

13and solves it with Clarabel. The risk models use it to express their 

14minimum-risk problems without repeating the boilerplate of stacking 

15constraint blocks, handling user-supplied linear constraints, and 

16invoking the solver. 

17 

18Example: 

19 Minimize x subject to x >= 1: 

20 

21 >>> import clarabel 

22 >>> import numpy as np 

23 >>> from cvx.core import ConeProgramBuilder 

24 >>> builder = ConeProgramBuilder(n_vars=1) 

25 >>> builder.add(np.array([[-1.0]]), np.array([-1.0]), clarabel.NonnegativeConeT(1)) 

26 >>> solution, status = builder.solve(q=np.array([1.0])) 

27 >>> status 

28 'Solved' 

29 >>> bool(np.isclose(solution.x[0], 1.0)) 

30 True 

31 

32""" 

33 

34from __future__ import annotations 

35 

36from typing import Any 

37 

38import clarabel 

39import numpy as np 

40from scipy import sparse 

41 

42# User-supplied linear constraint: (a, lb, ub) meaning lb <= a @ w <= ub. 

43# Use None for one-sided bounds; lb == ub yields an equality constraint. 

44LinearConstraint = tuple[np.ndarray, float | None, float | None] 

45 

46 

47class ConeProgramBuilder: 

48 """Accumulate constraint blocks for a Clarabel conic program and solve it. 

49 

50 Constraints are added as blocks of the stacked system ``A x + s = b`` with 

51 slack ``s`` in the corresponding cone. The builder assembles the blocks 

52 into sparse matrices and runs the Clarabel solver. 

53 

54 Attributes: 

55 n_vars: Total number of decision variables in the program. 

56 

57 """ 

58 

59 def __init__(self, n_vars: int) -> None: 

60 """Create an empty builder for a program with ``n_vars`` variables. 

61 

62 Args: 

63 n_vars: Total number of decision variables. 

64 

65 """ 

66 self.n_vars = n_vars 

67 self._a_blocks: list[sparse.csr_matrix] = [] 

68 self._b_blocks: list[np.ndarray] = [] 

69 self._cones: list[Any] = [] 

70 

71 def block(self, rows: int) -> np.ndarray: 

72 """Return a zero matrix with ``rows`` rows and one column per variable. 

73 

74 Args: 

75 rows: Number of constraint rows in the block. 

76 

77 Returns: 

78 A zero matrix of shape ``(rows, n_vars)`` ready to be filled in. 

79 

80 """ 

81 return np.zeros((rows, self.n_vars)) 

82 

83 def add(self, a_block: np.ndarray | sparse.spmatrix, b_block: np.ndarray, cone: Any) -> None: 

84 """Append the constraint block ``a_block @ x + s = b_block`` with ``s`` in ``cone``. 

85 

86 Args: 

87 a_block: Constraint matrix block of shape ``(rows, n_vars)``, 

88 either dense or scipy sparse. Blocks are stored in sparse 

89 form so large structured constraints stay memory-efficient. 

90 b_block: Right-hand side vector of length ``rows``. 

91 cone: Clarabel cone for the slack of this block. 

92 

93 """ 

94 self._a_blocks.append(sparse.csr_matrix(a_block)) 

95 self._b_blocks.append(b_block) 

96 self._cones.append(cone) 

97 

98 def add_sum_constraint(self, cols: slice, total: float = 1.0) -> None: 

99 """Constrain the variables selected by ``cols`` to sum to ``total``. 

100 

101 Args: 

102 cols: Column slice selecting the variables. 

103 total: Required sum of the selected variables. 

104 

105 """ 

106 a = self.block(1) 

107 a[0, cols] = 1.0 

108 self.add(a, np.array([total]), clarabel.ZeroConeT(1)) 

109 

110 def add_variable_bounds(self, cols: slice, lower: np.ndarray, upper: np.ndarray) -> None: 

111 """Add elementwise bounds ``lower <= x[cols] <= upper``. 

112 

113 The identity blocks are built directly in sparse form, so the cost is 

114 O(m) rather than O(m * n_vars). 

115 

116 Args: 

117 cols: Column slice selecting the variables to bound. 

118 lower: Lower bound for each selected variable. 

119 upper: Upper bound for each selected variable. 

120 

121 """ 

122 m = len(lower) 

123 eye = sparse.identity(m, format="csr") 

124 left = sparse.csr_matrix((m, cols.start)) 

125 right = sparse.csr_matrix((m, self.n_vars - cols.stop)) 

126 self.add(sparse.hstack([left, -eye, right]), -lower, clarabel.NonnegativeConeT(m)) 

127 self.add(sparse.hstack([left, eye, right]), upper, clarabel.NonnegativeConeT(m)) 

128 

129 def _add_scalar_row(self, a: np.ndarray, cols: slice, rhs: float, cone: Any) -> None: 

130 """Add a single-row constraint ``a @ x[cols] + s = rhs`` with ``s`` in ``cone``. 

131 

132 Args: 

133 a: Coefficient vector for the selected columns. 

134 cols: Column slice the coefficients refer to. 

135 rhs: Scalar right-hand side. 

136 cone: Clarabel cone for the row's slack. 

137 

138 """ 

139 row = self.block(1) 

140 row[0, cols] = a 

141 self.add(row, np.array([rhs]), cone) 

142 

143 def _add_inequalities(self, a: np.ndarray, cols: slice, lower: float | None, upper: float | None) -> None: 

144 """Add the one-sided rows for ``lower <= a @ x[cols] <= ub``. 

145 

146 Each present bound becomes a ``NonnegativeConeT`` row; ``None`` sides 

147 are skipped. 

148 

149 Args: 

150 a: Coefficient vector for the selected columns. 

151 cols: Column slice the coefficients refer to. 

152 lower: Lower bound, or ``None`` to leave the expression unbounded below. 

153 upper: Upper bound, or ``None`` to leave the expression unbounded above. 

154 

155 """ 

156 if lower is not None: 

157 self._add_scalar_row(-a, cols, -lower, clarabel.NonnegativeConeT(1)) 

158 if upper is not None: 

159 self._add_scalar_row(a, cols, upper, clarabel.NonnegativeConeT(1)) 

160 

161 def add_linear_constraints(self, constraints: list[LinearConstraint], cols: slice) -> None: 

162 """Add user-supplied linear constraints ``lb <= a @ x[cols] <= ub``. 

163 

164 Args: 

165 constraints: List of ``(a, lb, ub)`` tuples. Use ``None`` to drop 

166 one side; ``lb == ub`` produces an equality constraint. 

167 cols: Column slice the coefficient vectors refer to (typically the 

168 portfolio weights). 

169 

170 """ 

171 for coeffs, lower, upper in constraints: 

172 a = np.asarray(coeffs) 

173 if lower is not None and upper is not None and lower == upper: 

174 self._add_scalar_row(a, cols, lower, clarabel.ZeroConeT(1)) 

175 else: 

176 self._add_inequalities(a, cols, lower, upper) 

177 

178 def solve(self, q: np.ndarray) -> tuple[Any, str]: 

179 """Assemble the accumulated blocks, solve with Clarabel, and return the result. 

180 

181 Args: 

182 q: Linear objective vector of length ``n_vars``. 

183 

184 Returns: 

185 Tuple ``(solution, status)`` where ``solution`` is the Clarabel 

186 solution object and ``status`` its status as a string. 

187 

188 """ 

189 p = sparse.csc_matrix((self.n_vars, self.n_vars)) 

190 a = sparse.vstack(self._a_blocks, format="csc") 

191 b = np.concatenate(self._b_blocks) 

192 

193 settings = clarabel.DefaultSettings() 

194 settings.verbose = False 

195 solution = clarabel.DefaultSolver(p, q, a, b, self._cones, settings).solve() 

196 return solution, str(solution.status)