Coverage for src/cvxcla/builder.py: 100%
92 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 06:45 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 06:45 +0000
1"""Fluent builder for assembling a Critical Line Algorithm problem.
3A thin, chainable convenience layer over the explicit :class:`cvxcla.cla.CLA`
4constructor. It exists purely for readability: portfolio practitioners expect to
5say "long-only, fully invested" rather than to remember that the budget is
6encoded as ``a=np.ones((1, n)), b=np.ones(1)``. Every method maps one-to-one onto
7a constructor argument, so the builder adds no modelling power and imposes no
8expression algebra: it accepts the same polyhedral pieces the CLA already
9supports (a quadratic objective, box bounds, linear equalities ``A w = b``, and
10linear inequalities ``G w <= h``) and nothing else. Anything the explicit
11constructor cannot trace, the builder cannot express either.
13The terminal :meth:`ProblemBuilder.trace` builds the ``CLA`` and runs the full
14parametric trace, returning the solved object whose ``frontier`` and
15``turning_points`` describe the entire efficient frontier (not a single optimum,
16which is the distinction from a one-shot convex solver).
18Examples:
19 >>> import numpy as np
20 >>> from cvxcla import CLA
21 >>> rng = np.random.default_rng(0)
22 >>> mean = rng.uniform(0.0, 1.0, 4)
23 >>> covariance = np.eye(4)
24 >>> cla = CLA.problem(mean, covariance).long_only().budget().trace()
25 >>> len(cla) > 0
26 True
27"""
29from __future__ import annotations
31import numpy as np
32from numpy.typing import NDArray
34from .cla import CLA
35from .lasso import Lasso
36from .operators import QuadraticForm
39class ProblemBuilder:
40 """Chainable builder that assembles the polyhedral pieces of a CLA problem.
42 Construct one via :meth:`cvxcla.cla.CLA.problem`, chain the constraint
43 methods (each returns ``self``), and finish with :meth:`trace`. The builder
44 is a convenience over the explicit ``CLA(...)`` constructor and validates
45 shapes with actionable messages as pieces are added.
47 Attributes:
48 mean: Vector of expected returns, fixing the problem dimension ``n``.
49 covariance: The covariance, either a plain ``numpy`` array or a
50 ``QuadraticForm`` backend (e.g. ``FactorCovariance``), passed through
51 to ``CLA`` unchanged so the structured backends keep their advantage.
52 """
54 def __init__(self, mean: NDArray[np.float64], covariance: NDArray[np.float64] | QuadraticForm) -> None:
55 """Start a builder for an ``n``-asset problem.
57 Args:
58 mean: Vector of expected returns of length ``n``.
59 covariance: Covariance matrix or ``QuadraticForm`` backend.
60 """
61 self.mean = np.asarray(mean, dtype=np.float64)
62 self.covariance = covariance
63 self._lower: NDArray[np.float64] | None = None
64 self._upper: NDArray[np.float64] | None = None
65 self._a_blocks: list[NDArray[np.float64]] = []
66 self._b_blocks: list[NDArray[np.float64]] = []
67 self._g_blocks: list[NDArray[np.float64]] = []
68 self._h_blocks: list[NDArray[np.float64]] = []
70 @property
71 def _n(self) -> int:
72 """Number of assets ``n``, fixed by ``mean``."""
73 return int(self.mean.shape[0])
75 def _as_vector(self, value: float | NDArray[np.float64], name: str) -> NDArray[np.float64]:
76 """Broadcast a scalar or length-``n`` array to a length-``n`` vector.
78 Args:
79 value: A scalar (applied to every asset) or a length-``n`` array.
80 name: Argument name, used in the error message.
82 Returns:
83 A fresh length-``n`` float array.
85 Raises:
86 ValueError: If an array is passed whose length is not ``n``.
87 """
88 array = np.asarray(value, dtype=np.float64)
89 if array.ndim == 0:
90 return np.full(self._n, float(array))
91 if array.shape != (self._n,):
92 msg = f"{name} must be a scalar or a length-{self._n} vector, got shape {array.shape}"
93 raise ValueError(msg)
94 return array.astype(np.float64, copy=True)
96 def bounds(self, lower: float | NDArray[np.float64], upper: float | NDArray[np.float64]) -> ProblemBuilder:
97 """Set the box bounds ``lower <= w <= upper``.
99 Args:
100 lower: Lower bound, a scalar (same for every asset) or length-``n`` array.
101 upper: Upper bound, a scalar or length-``n`` array.
103 Returns:
104 ``self``, for chaining.
105 """
106 self._lower = self._as_vector(lower, "lower")
107 self._upper = self._as_vector(upper, "upper")
108 return self
110 def long_only(self, upper: float | NDArray[np.float64] = 1.0) -> ProblemBuilder:
111 """Set long-only box bounds ``0 <= w <= upper`` (``upper`` defaults to ``1``).
113 Args:
114 upper: Upper bound, a scalar or length-``n`` array; defaults to ``1.0``.
116 Returns:
117 ``self``, for chaining.
118 """
119 return self.bounds(0.0, upper)
121 def budget(self, total: float = 1.0) -> ProblemBuilder:
122 """Add the fully-invested budget constraint ``sum(w) = total``.
124 This is the canonical all-ones equality row; ``total=0`` gives a
125 dollar-neutral book. Equivalent to ``equality(np.ones(n), total)``.
127 Args:
128 total: The right-hand side of ``sum(w) = total``; defaults to ``1.0``.
130 Returns:
131 ``self``, for chaining.
132 """
133 return self.equality(np.ones(self._n), total)
135 def equality(self, a: NDArray[np.float64], b: float | NDArray[np.float64]) -> ProblemBuilder:
136 """Add one or more equality rows ``A w = b``.
138 Accepts a single row (a length-``n`` vector with a scalar right-hand side)
139 or a block of rows (an ``(m, n)`` matrix with a length-``m`` right-hand
140 side). Repeated calls accumulate rows, so a budget plus a sector-neutrality
141 block can be added separately.
143 Args:
144 a: A length-``n`` row vector or an ``(m, n)`` matrix.
145 b: The matching right-hand side: a scalar for a single row, or a
146 length-``m`` vector for a block.
148 Returns:
149 ``self``, for chaining.
151 Raises:
152 ValueError: If ``a`` does not have ``n`` columns, or ``b``'s length
153 does not match the number of rows of ``a``.
154 """
155 a_block = np.atleast_2d(np.asarray(a, dtype=np.float64))
156 b_block = np.atleast_1d(np.asarray(b, dtype=np.float64))
157 self._validate_rows(a_block, b_block, "equality", "b")
158 self._a_blocks.append(a_block)
159 self._b_blocks.append(b_block)
160 return self
162 def inequality(self, g: NDArray[np.float64], h: float | NDArray[np.float64]) -> ProblemBuilder:
163 """Add one or more inequality rows ``G w <= h``.
165 Like :meth:`equality` but for ``<=`` rows (e.g. a group- or
166 sector-exposure cap). A ``>=`` row is expressed by negating both ``g`` and
167 ``h``. Repeated calls accumulate rows.
169 Args:
170 g: A length-``n`` row vector or a ``(p, n)`` matrix.
171 h: The matching right-hand side: a scalar for a single row, or a
172 length-``p`` vector for a block.
174 Returns:
175 ``self``, for chaining.
177 Raises:
178 ValueError: If ``g`` does not have ``n`` columns, or ``h``'s length
179 does not match the number of rows of ``g``.
180 """
181 g_block = np.atleast_2d(np.asarray(g, dtype=np.float64))
182 h_block = np.atleast_1d(np.asarray(h, dtype=np.float64))
183 self._validate_rows(g_block, h_block, "inequality", "h")
184 self._g_blocks.append(g_block)
185 self._h_blocks.append(h_block)
186 return self
188 def _validate_rows(self, lhs: NDArray[np.float64], rhs: NDArray[np.float64], method: str, rhs_name: str) -> None:
189 """Check a constraint block has ``n`` columns and a matching right-hand side.
191 Args:
192 lhs: The ``(m, n)`` coefficient block.
193 rhs: The length-``m`` right-hand side.
194 method: The calling method name, used in error messages.
195 rhs_name: The right-hand-side argument name, used in error messages.
197 Raises:
198 ValueError: If the column count is not ``n`` or the lengths disagree.
199 """
200 if lhs.shape[1] != self._n:
201 msg = f"{method}: coefficient matrix must have {self._n} columns, got shape {lhs.shape}"
202 raise ValueError(msg)
203 if rhs.shape[0] != lhs.shape[0]:
204 msg = f"{method}: {rhs_name} must have {lhs.shape[0]} entries to match the rows, got {rhs.shape[0]}"
205 raise ValueError(msg)
207 def trace(self) -> CLA:
208 """Assemble the pieces, build the ``CLA``, and run the full trace.
210 Returns:
211 The solved :class:`cvxcla.cla.CLA`, whose ``frontier`` and
212 ``turning_points`` describe the entire efficient frontier.
214 Raises:
215 ValueError: If no box bounds were set (call :meth:`bounds` or
216 :meth:`long_only`), or no equality constraint was added (call
217 :meth:`budget` or :meth:`equality`).
218 """
219 if self._lower is None or self._upper is None:
220 msg = "set box bounds before tracing: call .long_only() or .bounds(lower, upper)"
221 raise ValueError(msg)
222 if not self._a_blocks:
223 msg = "a CLA problem needs an equality constraint: call .budget() or .equality(A, b)"
224 raise ValueError(msg)
226 g = np.vstack(self._g_blocks) if self._g_blocks else None
227 h = np.concatenate(self._h_blocks) if self._h_blocks else None
228 return CLA(
229 mean=self.mean,
230 covariance=self.covariance,
231 lower_bounds=self._lower,
232 upper_bounds=self._upper,
233 a=np.vstack(self._a_blocks),
234 b=np.concatenate(self._b_blocks),
235 g=g,
236 h=h,
237 )
240class LassoBuilder:
241 """Chainable builder for a LASSO regularisation-path problem.
243 The LASSO counterpart of :class:`ProblemBuilder`. Construct one via
244 :meth:`cvxcla.lasso.Lasso.problem`, optionally add inequality constraints with
245 :meth:`inequality`, and finish with :meth:`trace`, which builds the
246 :class:`cvxcla.lasso.Lasso` and traces the entire regularisation path. Like the
247 CLA builder it adds no modelling power: it accepts the same ``G beta <= h`` rows
248 the ``Lasso`` already supports and nothing else.
250 Examples:
251 >>> import numpy as np
252 >>> from cvxcla import Lasso
253 >>> rng = np.random.default_rng(0)
254 >>> x = rng.standard_normal((30, 5))
255 >>> y = rng.standard_normal(30)
256 >>> lasso = Lasso.problem(x, y).trace()
257 >>> len(lasso.path) > 0
258 True
259 """
261 def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None:
262 """Start a builder for design matrix ``x`` and response ``y``.
264 Args:
265 x: Design matrix of shape ``(m, n)``.
266 y: Response vector of shape ``(m,)``.
267 """
268 self.x = np.asarray(x, dtype=np.float64)
269 self.y = np.asarray(y, dtype=np.float64)
270 self._g_blocks: list[NDArray[np.float64]] = []
271 self._h_blocks: list[NDArray[np.float64]] = []
272 self._nonneg = False
274 def non_negative(self) -> LassoBuilder:
275 """Restrict the coefficients to ``beta >= 0`` (the non-negative LASSO).
277 Under ``beta >= 0`` the l1 penalty collapses to the linear term
278 ``lam * sum(beta)``, so the path is the standard one restricted to positive
279 signs -- structurally the CLA's box-bounded parametric QP.
281 Returns:
282 ``self``, for chaining.
283 """
284 self._nonneg = True
285 return self
287 def inequality(self, g: NDArray[np.float64], h: float | NDArray[np.float64]) -> LassoBuilder:
288 """Add one or more inequality rows ``G beta <= h`` (repeated calls accumulate).
290 Args:
291 g: A length-``n`` row vector or a ``(p, n)`` matrix.
292 h: The matching right-hand side: a scalar for a single row, or a
293 length-``p`` vector. Each entry must be strictly positive (so
294 ``beta = 0`` stays feasible), checked when the path is traced.
296 Returns:
297 ``self``, for chaining.
299 Raises:
300 ValueError: If ``g``'s column count is not ``n`` or ``h``'s length does
301 not match the rows of ``g``.
302 """
303 g_block = np.atleast_2d(np.asarray(g, dtype=np.float64))
304 h_block = np.atleast_1d(np.asarray(h, dtype=np.float64))
305 if self.x.ndim == 2 and g_block.shape[1] != self.x.shape[1]:
306 msg = f"inequality: coefficient matrix must have {self.x.shape[1]} columns, got shape {g_block.shape}"
307 raise ValueError(msg)
308 if h_block.shape[0] != g_block.shape[0]:
309 msg = f"inequality: h must have {g_block.shape[0]} entries to match the rows, got {h_block.shape[0]}"
310 raise ValueError(msg)
311 self._g_blocks.append(g_block)
312 self._h_blocks.append(h_block)
313 return self
315 def trace(self) -> Lasso:
316 """Assemble the pieces, build the ``Lasso``, and trace the full path.
318 Returns:
319 The traced :class:`cvxcla.lasso.Lasso`, whose ``path`` holds the
320 breakpoints of the (constrained) regularisation path.
321 """
322 g = np.vstack(self._g_blocks) if self._g_blocks else None
323 h = np.concatenate(self._h_blocks) if self._h_blocks else None
324 return Lasso(x=self.x, y=self.y, g=g, h=h, nonneg=self._nonneg)