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

1"""Fluent builder for assembling a Critical Line Algorithm problem. 

2 

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. 

12 

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

17 

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

28 

29from __future__ import annotations 

30 

31import numpy as np 

32from numpy.typing import NDArray 

33 

34from .cla import CLA 

35from .lasso import Lasso 

36from .operators import QuadraticForm 

37 

38 

39class ProblemBuilder: 

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

41 

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. 

46 

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

53 

54 def __init__(self, mean: NDArray[np.float64], covariance: NDArray[np.float64] | QuadraticForm) -> None: 

55 """Start a builder for an ``n``-asset problem. 

56 

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]] = [] 

69 

70 @property 

71 def _n(self) -> int: 

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

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

74 

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. 

77 

78 Args: 

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

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

81 

82 Returns: 

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

84 

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) 

95 

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

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

98 

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. 

102 

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 

109 

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

112 

113 Args: 

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

115 

116 Returns: 

117 ``self``, for chaining. 

118 """ 

119 return self.bounds(0.0, upper) 

120 

121 def budget(self, total: float = 1.0) -> ProblemBuilder: 

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

123 

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

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

126 

127 Args: 

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

129 

130 Returns: 

131 ``self``, for chaining. 

132 """ 

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

134 

135 def equality(self, a: NDArray[np.float64], b: float | NDArray[np.float64]) -> ProblemBuilder: 

136 """Add one or more equality rows ``A w = b``. 

137 

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. 

142 

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. 

147 

148 Returns: 

149 ``self``, for chaining. 

150 

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 

161 

162 def inequality(self, g: NDArray[np.float64], h: float | NDArray[np.float64]) -> ProblemBuilder: 

163 """Add one or more inequality rows ``G w <= h``. 

164 

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. 

168 

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. 

173 

174 Returns: 

175 ``self``, for chaining. 

176 

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 

187 

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. 

190 

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. 

196 

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) 

206 

207 def trace(self) -> CLA: 

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

209 

210 Returns: 

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

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

213 

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) 

225 

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 ) 

238 

239 

240class LassoBuilder: 

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

242 

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. 

249 

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

260 

261 def __init__(self, x: NDArray[np.float64], y: NDArray[np.float64]) -> None: 

262 """Start a builder for design matrix ``x`` and response ``y``. 

263 

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 

273 

274 def non_negative(self) -> LassoBuilder: 

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

276 

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. 

280 

281 Returns: 

282 ``self``, for chaining. 

283 """ 

284 self._nonneg = True 

285 return self 

286 

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

289 

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. 

295 

296 Returns: 

297 ``self``, for chaining. 

298 

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 

314 

315 def trace(self) -> Lasso: 

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

317 

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)