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

1"""Chainable builders that assemble the polyhedral pieces of a traced problem. 

2 

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. 

10 

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. 

14 

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""" 

22 

23from __future__ import annotations 

24 

25from collections.abc import Callable 

26from typing import Generic, TypeVar 

27 

28import numpy as np 

29from numpy.typing import NDArray 

30 

31from .operators import QuadraticForm 

32 

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") 

37 

38 

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. 

43 

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. 

46 

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. 

50 

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)) 

55 

56 

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. 

65 

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. 

74 

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) 

84 

85 

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. 

90 

91 ``None`` is the solvers' own encoding of "no rows of this kind", so an unused 

92 builder method costs nothing downstream. 

93 

94 Args: 

95 lhs_blocks: The accumulated ``(m_i, n)`` coefficient blocks. 

96 rhs_blocks: The accumulated length-``m_i`` right-hand sides. 

97 

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) 

104 

105 

106class ProblemBuilder(Generic[SolverT]): 

107 """Chainable builder that assembles the polyhedral pieces of a CLA problem. 

108 

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. 

119 

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). 

125 

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. 

131 

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 """ 

142 

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. 

150 

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 

167 

168 @property 

169 def _n(self) -> int: 

170 """Number of assets ``n``, fixed by ``mean``.""" 

171 return int(self.mean.shape[0]) 

172 

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. 

175 

176 Args: 

177 value: A scalar (applied to every asset) or a length-``n`` array. 

178 name: Argument name, used in the error message. 

179 

180 Returns: 

181 A fresh length-``n`` float array. 

182 

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) 

193 

194 def bounds(self, lower: float | NDArray[np.float64], upper: float | NDArray[np.float64]) -> ProblemBuilder[SolverT]: 

195 """Set the box bounds ``lower <= w <= upper``. 

196 

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. 

200 

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 

207 

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``). 

210 

211 Args: 

212 upper: Upper bound, a scalar or length-``n`` array; defaults to ``1.0``. 

213 

214 Returns: 

215 ``self``, for chaining. 

216 """ 

217 return self.bounds(0.0, upper) 

218 

219 def budget(self, total: float = 1.0) -> ProblemBuilder[SolverT]: 

220 """Add the fully-invested budget constraint ``sum(w) = total``. 

221 

222 This is the canonical all-ones equality row; ``total=0`` gives a 

223 dollar-neutral book. Equivalent to ``equality(np.ones(n), total)``. 

224 

225 Args: 

226 total: The right-hand side of ``sum(w) = total``; defaults to ``1.0``. 

227 

228 Returns: 

229 ``self``, for chaining. 

230 """ 

231 return self.equality(np.ones(self._n), total) 

232 

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``. 

235 

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. 

240 

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. 

245 

246 Returns: 

247 ``self``, for chaining. 

248 

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 

258 

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``. 

261 

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. 

265 

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. 

270 

271 Returns: 

272 ``self``, for chaining. 

273 

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 

283 

284 def leverage(self, limit: float) -> ProblemBuilder[SolverT]: 

285 """Cap the gross exposure: ``||w||_1 = sum(|w_i|) <= limit``. 

286 

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. 

291 

292 Args: 

293 limit: The gross-exposure cap ``c``, a positive number. 

294 

295 Returns: 

296 ``self``, for chaining. 

297 """ 

298 self._leverage = float(limit) 

299 return self 

300 

301 def trace(self) -> SolverT: 

302 """Assemble the pieces, build the ``CLA``, and run the full trace. 

303 

304 Returns: 

305 The solved :class:`cvxcla.cla.CLA`, whose ``frontier`` and 

306 ``turning_points`` describe the entire efficient frontier. 

307 

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) 

317 

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 ) 

330 

331 def _resolved_bounds(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]: 

332 """Return the box bounds, raising if they were never set. 

333 

334 Returns: 

335 The ``(lower, upper)`` box-bound vectors. 

336 

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 

345 

346 

347class LassoBuilder(Generic[SolverT]): 

348 """Chainable builder for a LASSO regularisation-path problem. 

349 

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. 

357 

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 """ 

368 

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``. 

376 

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 

390 

391 def non_negative(self) -> LassoBuilder[SolverT]: 

392 """Restrict the coefficients to ``beta >= 0`` (the non-negative LASSO). 

393 

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. 

397 

398 Returns: 

399 ``self``, for chaining. 

400 """ 

401 self._nonneg = True 

402 return self 

403 

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). 

406 

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. 

412 

413 Returns: 

414 ``self``, for chaining. 

415 

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 

428 

429 def equality(self, a: NDArray[np.float64]) -> LassoBuilder[SolverT]: 

430 """Add one or more homogeneous equality rows ``A beta = 0`` (calls accumulate). 

431 

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`. 

436 

437 Args: 

438 a: A length-``n`` row vector or an ``(m, n)`` matrix. 

439 

440 Returns: 

441 ``self``, for chaining. 

442 

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 

451 

452 def trace(self) -> SolverT: 

453 """Assemble the pieces, build the ``Lasso``, and trace the full path. 

454 

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)