Coverage for src/cvxcla/_builders.py: 100%
107 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 05:24 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 05:24 +0000
1"""Chainable builders that assemble the polyhedral pieces of a traced problem.
3This module is a **leaf**: it imports numpy and :mod:`cvxcla.operators` and
4nothing else from the package. In particular it never imports :mod:`cvxcla.cla`
5or :mod:`cvxcla.lasso`, not even under ``TYPE_CHECKING``. The solver a builder
6finally constructs is *injected* -- :meth:`cvxcla.cla.CLA.problem` passes ``CLA``
7and :meth:`cvxcla.lasso.Lasso.problem` passes ``Lasso`` -- and each builder is
8generic in that type, so ``.trace()`` still returns the precise solver class
9without this module ever naming it.
11That injection is what keeps the internal import graph acyclic while letting the
12builders live apart from the solvers they build. The dependency runs one way,
13``cla``/``lasso`` -> ``_builders``, and :mod:`tests.test_import_graph` pins it.
15Neither builder adds modelling power. Each accepts exactly the polyhedral pieces
16its solver already supports -- box bounds, linear equalities ``A w = b``, linear
17inequalities ``G w <= h`` and, for the CLA, a gross-exposure cap ``||w||_1 <= c``
18(for the LASSO, only homogeneous equalities ``A beta = 0``) -- and maps them
19one-to-one onto constructor arguments.
20Anything the explicit constructor cannot trace, the builder cannot express either.
21"""
23from __future__ import annotations
25from collections.abc import Callable
26from typing import Generic, TypeVar
28import numpy as np
29from numpy.typing import NDArray
31from .operators import QuadraticForm
33#: The solver a builder constructs. Bound at the call site by ``CLA.problem`` /
34#: ``Lasso.problem``, so ``trace()`` returns the concrete class rather than
35#: ``Any`` -- without this module importing either of them.
36SolverT = TypeVar("SolverT")
39def _as_block(
40 lhs: NDArray[np.float64], rhs: float | NDArray[np.float64]
41) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
42 """Normalise a constraint row or block to ``(m, n)`` and ``(m,)`` float arrays.
44 A single row may be given as a length-``n`` vector with a scalar right-hand
45 side; both are promoted so the accumulation logic sees only blocks.
47 Args:
48 lhs: A length-``n`` row vector or an ``(m, n)`` coefficient matrix.
49 rhs: A scalar or a length-``m`` right-hand side.
51 Returns:
52 The ``(lhs, rhs)`` pair as ``(m, n)`` and ``(m,)`` float arrays.
53 """
54 return np.atleast_2d(np.asarray(lhs, dtype=np.float64)), np.atleast_1d(np.asarray(rhs, dtype=np.float64))
57def _validate_block(
58 lhs: NDArray[np.float64],
59 rhs: NDArray[np.float64],
60 n: int | None,
61 method: str,
62 rhs_name: str,
63) -> None:
64 """Check a constraint block has ``n`` columns and a matching right-hand side.
66 Args:
67 lhs: The ``(m, n)`` coefficient block.
68 rhs: The length-``m`` right-hand side.
69 n: The expected column count, or ``None`` to skip the column check (the
70 LASSO builder cannot know ``n`` until the design matrix is 2-D, and
71 defers that diagnosis to :class:`cvxcla.lasso.Lasso`).
72 method: The calling method name, used in error messages.
73 rhs_name: The right-hand-side argument name, used in error messages.
75 Raises:
76 ValueError: If the column count is not ``n`` or the lengths disagree.
77 """
78 if n is not None and lhs.shape[1] != n:
79 msg = f"{method}: coefficient matrix must have {n} columns, got shape {lhs.shape}"
80 raise ValueError(msg)
81 if rhs.shape[0] != lhs.shape[0]:
82 msg = f"{method}: {rhs_name} must have {lhs.shape[0]} entries to match the rows, got {rhs.shape[0]}"
83 raise ValueError(msg)
86def _stack(
87 lhs_blocks: list[NDArray[np.float64]], rhs_blocks: list[NDArray[np.float64]]
88) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None]:
89 """Stack accumulated constraint blocks, or ``(None, None)`` when none were added.
91 ``None`` is the solvers' own encoding of "no rows of this kind", so an unused
92 builder method costs nothing downstream.
94 Args:
95 lhs_blocks: The accumulated ``(m_i, n)`` coefficient blocks.
96 rhs_blocks: The accumulated length-``m_i`` right-hand sides.
98 Returns:
99 The vertically stacked ``(lhs, rhs)``, or ``(None, None)`` if empty.
100 """
101 if not lhs_blocks:
102 return None, None
103 return np.vstack(lhs_blocks), np.concatenate(rhs_blocks)
106class ProblemBuilder(Generic[SolverT]):
107 """Chainable builder that assembles the polyhedral pieces of a CLA problem.
109 A thin, chainable convenience layer over the explicit
110 :class:`cvxcla.cla.CLA` constructor. It exists purely for readability:
111 portfolio practitioners expect to say "long-only, fully invested" rather than
112 to remember that the budget is encoded as ``a=np.ones((1, n)), b=np.ones(1)``.
113 Every method maps one-to-one onto a constructor argument, so the builder adds
114 no modelling power and imposes no expression algebra: it accepts the same
115 polyhedral pieces the CLA already supports (a quadratic objective, box bounds,
116 linear equalities ``A w = b``, linear inequalities ``G w <= h``, and a
117 gross-exposure cap ``||w||_1 <= c``) and nothing else. Anything the explicit constructor cannot trace, the builder
118 cannot express either.
120 Construct one via :meth:`cvxcla.cla.CLA.problem`, chain the constraint methods
121 (each returns ``self``), and finish with :meth:`trace`, which builds the
122 ``CLA`` and runs the full parametric trace, returning the solved object whose
123 ``frontier`` and ``turning_points`` describe the entire efficient frontier
124 (not a single optimum, which is the distinction from a one-shot convex solver).
126 Attributes:
127 mean: Vector of expected returns, fixing the problem dimension ``n``.
128 covariance: The covariance, either a plain ``numpy`` array or a
129 ``QuadraticForm`` backend (e.g. ``FactorCovariance``), passed through
130 to ``CLA`` unchanged so the structured backends keep their advantage.
132 Examples:
133 >>> import numpy as np
134 >>> from cvxcla import CLA
135 >>> rng = np.random.default_rng(0)
136 >>> mean = rng.uniform(0.0, 1.0, 4)
137 >>> covariance = np.eye(4)
138 >>> cla = CLA.problem(mean, covariance).long_only().budget().trace()
139 >>> len(cla) > 0
140 True
141 """
143 def __init__(
144 self,
145 mean: NDArray[np.float64],
146 covariance: NDArray[np.float64] | QuadraticForm,
147 solver: Callable[..., SolverT],
148 ) -> None:
149 """Start a builder for an ``n``-asset problem.
151 Args:
152 mean: Vector of expected returns of length ``n``.
153 covariance: Covariance matrix or ``QuadraticForm`` backend.
154 solver: The class :meth:`trace` constructs, injected by
155 :meth:`cvxcla.cla.CLA.problem` so this module never imports it.
156 """
157 self.mean = np.asarray(mean, dtype=np.float64)
158 self.covariance = covariance
159 self._solver = solver
160 self._lower: NDArray[np.float64] | None = None
161 self._upper: NDArray[np.float64] | None = None
162 self._a_blocks: list[NDArray[np.float64]] = []
163 self._b_blocks: list[NDArray[np.float64]] = []
164 self._g_blocks: list[NDArray[np.float64]] = []
165 self._h_blocks: list[NDArray[np.float64]] = []
166 self._leverage: float | None = None
168 @property
169 def _n(self) -> int:
170 """Number of assets ``n``, fixed by ``mean``."""
171 return int(self.mean.shape[0])
173 def _as_vector(self, value: float | NDArray[np.float64], name: str) -> NDArray[np.float64]:
174 """Broadcast a scalar or length-``n`` array to a length-``n`` vector.
176 Args:
177 value: A scalar (applied to every asset) or a length-``n`` array.
178 name: Argument name, used in the error message.
180 Returns:
181 A fresh length-``n`` float array.
183 Raises:
184 ValueError: If an array is passed whose length is not ``n``.
185 """
186 array = np.asarray(value, dtype=np.float64)
187 if array.ndim == 0:
188 return np.full(self._n, float(array))
189 if array.shape != (self._n,):
190 msg = f"{name} must be a scalar or a length-{self._n} vector, got shape {array.shape}"
191 raise ValueError(msg)
192 return array.astype(np.float64, copy=True)
194 def bounds(self, lower: float | NDArray[np.float64], upper: float | NDArray[np.float64]) -> ProblemBuilder[SolverT]:
195 """Set the box bounds ``lower <= w <= upper``.
197 Args:
198 lower: Lower bound, a scalar (same for every asset) or length-``n`` array.
199 upper: Upper bound, a scalar or length-``n`` array.
201 Returns:
202 ``self``, for chaining.
203 """
204 self._lower = self._as_vector(lower, "lower")
205 self._upper = self._as_vector(upper, "upper")
206 return self
208 def long_only(self, upper: float | NDArray[np.float64] = 1.0) -> ProblemBuilder[SolverT]:
209 """Set long-only box bounds ``0 <= w <= upper`` (``upper`` defaults to ``1``).
211 Args:
212 upper: Upper bound, a scalar or length-``n`` array; defaults to ``1.0``.
214 Returns:
215 ``self``, for chaining.
216 """
217 return self.bounds(0.0, upper)
219 def budget(self, total: float = 1.0) -> ProblemBuilder[SolverT]:
220 """Add the fully-invested budget constraint ``sum(w) = total``.
222 This is the canonical all-ones equality row; ``total=0`` gives a
223 dollar-neutral book. Equivalent to ``equality(np.ones(n), total)``.
225 Args:
226 total: The right-hand side of ``sum(w) = total``; defaults to ``1.0``.
228 Returns:
229 ``self``, for chaining.
230 """
231 return self.equality(np.ones(self._n), total)
233 def equality(self, a: NDArray[np.float64], b: float | NDArray[np.float64]) -> ProblemBuilder[SolverT]:
234 """Add one or more equality rows ``A w = b``.
236 Accepts a single row (a length-``n`` vector with a scalar right-hand side)
237 or a block of rows (an ``(m, n)`` matrix with a length-``m`` right-hand
238 side). Repeated calls accumulate rows, so a budget plus a sector-neutrality
239 block can be added separately.
241 Args:
242 a: A length-``n`` row vector or an ``(m, n)`` matrix.
243 b: The matching right-hand side: a scalar for a single row, or a
244 length-``m`` vector for a block.
246 Returns:
247 ``self``, for chaining.
249 Raises:
250 ValueError: If ``a`` does not have ``n`` columns, or ``b``'s length
251 does not match the number of rows of ``a``.
252 """
253 a_block, b_block = _as_block(a, b)
254 _validate_block(a_block, b_block, self._n, "equality", "b")
255 self._a_blocks.append(a_block)
256 self._b_blocks.append(b_block)
257 return self
259 def inequality(self, g: NDArray[np.float64], h: float | NDArray[np.float64]) -> ProblemBuilder[SolverT]:
260 """Add one or more inequality rows ``G w <= h``.
262 Like :meth:`equality` but for ``<=`` rows (e.g. a group- or
263 sector-exposure cap). A ``>=`` row is expressed by negating both ``g`` and
264 ``h``. Repeated calls accumulate rows.
266 Args:
267 g: A length-``n`` row vector or a ``(p, n)`` matrix.
268 h: The matching right-hand side: a scalar for a single row, or a
269 length-``p`` vector for a block.
271 Returns:
272 ``self``, for chaining.
274 Raises:
275 ValueError: If ``g`` does not have ``n`` columns, or ``h``'s length
276 does not match the number of rows of ``g``.
277 """
278 g_block, h_block = _as_block(g, h)
279 _validate_block(g_block, h_block, self._n, "inequality", "h")
280 self._g_blocks.append(g_block)
281 self._h_blocks.append(h_block)
282 return self
284 def leverage(self, limit: float) -> ProblemBuilder[SolverT]:
285 """Cap the gross exposure: ``||w||_1 = sum(|w_i|) <= limit``.
287 With a budget ``sum(w) = 1`` a limit of ``1.3`` is a 130/30 book; with
288 ``sum(w) = 0`` it caps the combined long and short notional. Only assets
289 whose bounds allow a short position are affected, so the cap is redundant
290 for a long-only fully-invested book. A second call replaces the first.
292 Args:
293 limit: The gross-exposure cap ``c``, a positive number.
295 Returns:
296 ``self``, for chaining.
297 """
298 self._leverage = float(limit)
299 return self
301 def trace(self) -> SolverT:
302 """Assemble the pieces, build the ``CLA``, and run the full trace.
304 Returns:
305 The solved :class:`cvxcla.cla.CLA`, whose ``frontier`` and
306 ``turning_points`` describe the entire efficient frontier.
308 Raises:
309 ValueError: If no box bounds were set (call :meth:`bounds` or
310 :meth:`long_only`), or no equality constraint was added (call
311 :meth:`budget` or :meth:`equality`).
312 """
313 lower, upper = self._resolved_bounds()
314 if not self._a_blocks:
315 msg = "a CLA problem needs an equality constraint: call .budget() or .equality(A, b)"
316 raise ValueError(msg)
318 g, h = _stack(self._g_blocks, self._h_blocks)
319 return self._solver(
320 mean=self.mean,
321 covariance=self.covariance,
322 lower_bounds=lower,
323 upper_bounds=upper,
324 a=np.vstack(self._a_blocks),
325 b=np.concatenate(self._b_blocks),
326 g=g,
327 h=h,
328 leverage=self._leverage,
329 )
331 def _resolved_bounds(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
332 """Return the box bounds, raising if they were never set.
334 Returns:
335 The ``(lower, upper)`` box-bound vectors.
337 Raises:
338 ValueError: If no box bounds were set (call :meth:`bounds` or
339 :meth:`long_only`).
340 """
341 if self._lower is None or self._upper is None:
342 msg = "set box bounds before tracing: call .long_only() or .bounds(lower, upper)"
343 raise ValueError(msg)
344 return self._lower, self._upper
347class LassoBuilder(Generic[SolverT]):
348 """Chainable builder for a LASSO regularisation-path problem.
350 The LASSO counterpart of :class:`ProblemBuilder`. Construct one via
351 :meth:`cvxcla.lasso.Lasso.problem`, optionally add inequality constraints with
352 :meth:`inequality` or homogeneous equality constraints with :meth:`equality`,
353 and finish with :meth:`trace`, which builds the :class:`cvxcla.lasso.Lasso` and
354 traces the entire regularisation path. Like the CLA builder it adds no modelling
355 power: it accepts the same ``G beta <= h`` and ``A beta = 0`` rows the ``Lasso``
356 already supports and nothing else.
358 Examples:
359 >>> import numpy as np
360 >>> from cvxcla import Lasso
361 >>> rng = np.random.default_rng(0)
362 >>> x = rng.standard_normal((30, 5))
363 >>> y = rng.standard_normal(30)
364 >>> lasso = Lasso.problem(x, y).trace()
365 >>> len(lasso.path) > 0
366 True
367 """
369 def __init__(
370 self,
371 x: NDArray[np.float64],
372 y: NDArray[np.float64],
373 solver: Callable[..., SolverT],
374 ) -> None:
375 """Start a builder for design matrix ``x`` and response ``y``.
377 Args:
378 x: Design matrix of shape ``(m, n)``.
379 y: Response vector of shape ``(m,)``.
380 solver: The class :meth:`trace` constructs, injected by
381 :meth:`cvxcla.lasso.Lasso.problem` so this module never imports it.
382 """
383 self.x = np.asarray(x, dtype=np.float64)
384 self.y = np.asarray(y, dtype=np.float64)
385 self._solver = solver
386 self._g_blocks: list[NDArray[np.float64]] = []
387 self._h_blocks: list[NDArray[np.float64]] = []
388 self._a_blocks: list[NDArray[np.float64]] = []
389 self._nonneg = False
391 def non_negative(self) -> LassoBuilder[SolverT]:
392 """Restrict the coefficients to ``beta >= 0`` (the non-negative LASSO).
394 Under ``beta >= 0`` the l1 penalty collapses to the linear term
395 ``lam * sum(beta)``, so the path is the standard one restricted to positive
396 signs -- structurally the CLA's box-bounded parametric QP.
398 Returns:
399 ``self``, for chaining.
400 """
401 self._nonneg = True
402 return self
404 def inequality(self, g: NDArray[np.float64], h: float | NDArray[np.float64]) -> LassoBuilder[SolverT]:
405 """Add one or more inequality rows ``G beta <= h`` (repeated calls accumulate).
407 Args:
408 g: A length-``n`` row vector or a ``(p, n)`` matrix.
409 h: The matching right-hand side: a scalar for a single row, or a
410 length-``p`` vector. Each entry must be strictly positive (so
411 ``beta = 0`` stays feasible), checked when the path is traced.
413 Returns:
414 ``self``, for chaining.
416 Raises:
417 ValueError: If ``g``'s column count is not ``n`` or ``h``'s length does
418 not match the rows of ``g``.
419 """
420 g_block, h_block = _as_block(g, h)
421 # A 1-D design has no column count to check against; ``Lasso`` rejects it
422 # with its own (better) diagnosis when the path is traced.
423 n = int(self.x.shape[1]) if self.x.ndim == 2 else None
424 _validate_block(g_block, h_block, n, "inequality", "h")
425 self._g_blocks.append(g_block)
426 self._h_blocks.append(h_block)
427 return self
429 def equality(self, a: NDArray[np.float64]) -> LassoBuilder[SolverT]:
430 """Add one or more homogeneous equality rows ``A beta = 0`` (calls accumulate).
432 A row of ones is the sum-to-zero constraint. Only a zero right-hand side is
433 supported: the path is traced through the leverage CLA, whose rescaling
434 needs homogeneous rows. Equality rows cannot be combined with
435 :meth:`inequality`.
437 Args:
438 a: A length-``n`` row vector or an ``(m, n)`` matrix.
440 Returns:
441 ``self``, for chaining.
443 Raises:
444 ValueError: If ``a``'s column count is not ``n``.
445 """
446 a_block, b_block = _as_block(a, np.zeros(np.atleast_2d(a).shape[0]))
447 n = int(self.x.shape[1]) if self.x.ndim == 2 else None
448 _validate_block(a_block, b_block, n, "equality", "b")
449 self._a_blocks.append(a_block)
450 return self
452 def trace(self) -> SolverT:
453 """Assemble the pieces, build the ``Lasso``, and trace the full path.
455 Returns:
456 The traced :class:`cvxcla.lasso.Lasso`, whose ``path`` holds the
457 breakpoints of the (constrained) regularisation path.
458 """
459 g, h = _stack(self._g_blocks, self._h_blocks)
460 a = np.vstack(self._a_blocks) if self._a_blocks else None
461 return self._solver(x=self.x, y=self.y, g=g, h=h, a=a, nonneg=self._nonneg)