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
« 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.
7This module provides the :class:`ConeProgramBuilder`, a small utility that
8accumulates the constraint blocks and cones of a conic program
10 minimize q' x
11 subject to A x + s = b, s in K
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.
18Example:
19 Minimize x subject to x >= 1:
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
32"""
34from __future__ import annotations
36from typing import Any
38import clarabel
39import numpy as np
40from scipy import sparse
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]
47class ConeProgramBuilder:
48 """Accumulate constraint blocks for a Clarabel conic program and solve it.
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.
54 Attributes:
55 n_vars: Total number of decision variables in the program.
57 """
59 def __init__(self, n_vars: int) -> None:
60 """Create an empty builder for a program with ``n_vars`` variables.
62 Args:
63 n_vars: Total number of decision variables.
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] = []
71 def block(self, rows: int) -> np.ndarray:
72 """Return a zero matrix with ``rows`` rows and one column per variable.
74 Args:
75 rows: Number of constraint rows in the block.
77 Returns:
78 A zero matrix of shape ``(rows, n_vars)`` ready to be filled in.
80 """
81 return np.zeros((rows, self.n_vars))
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``.
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.
93 """
94 self._a_blocks.append(sparse.csr_matrix(a_block))
95 self._b_blocks.append(b_block)
96 self._cones.append(cone)
98 def add_sum_constraint(self, cols: slice, total: float = 1.0) -> None:
99 """Constrain the variables selected by ``cols`` to sum to ``total``.
101 Args:
102 cols: Column slice selecting the variables.
103 total: Required sum of the selected variables.
105 """
106 a = self.block(1)
107 a[0, cols] = 1.0
108 self.add(a, np.array([total]), clarabel.ZeroConeT(1))
110 def add_variable_bounds(self, cols: slice, lower: np.ndarray, upper: np.ndarray) -> None:
111 """Add elementwise bounds ``lower <= x[cols] <= upper``.
113 The identity blocks are built directly in sparse form, so the cost is
114 O(m) rather than O(m * n_vars).
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.
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))
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``.
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.
138 """
139 row = self.block(1)
140 row[0, cols] = a
141 self.add(row, np.array([rhs]), cone)
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``.
146 Each present bound becomes a ``NonnegativeConeT`` row; ``None`` sides
147 are skipped.
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.
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))
161 def add_linear_constraints(self, constraints: list[LinearConstraint], cols: slice) -> None:
162 """Add user-supplied linear constraints ``lb <= a @ x[cols] <= ub``.
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).
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)
178 def solve(self, q: np.ndarray) -> tuple[Any, str]:
179 """Assemble the accumulated blocks, solve with Clarabel, and return the result.
181 Args:
182 q: Linear objective vector of length ``n_vars``.
184 Returns:
185 Tuple ``(solution, status)`` where ``solution`` is the Clarabel
186 solution object and ``status`` its status as a string.
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)
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)