Coverage for src/basanos/math/optimizer.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-11-03 18:03 +0000

1"""Correlation-aware risk position optimizer (Basanos). 

2 

3This module provides utilities to compute correlation-adjusted risk positions 

4from price data and expected-return signals. It relies on volatility-adjusted 

5returns to estimate a dynamic correlation matrix (via EWM), applies shrinkage 

6towards identity, and solves a normalized linear system per timestamp to 

7obtain stable positions. 

8 

9Performance characteristics 

10--------------------------- 

11Let *N* be the number of assets and *T* the number of timestamps. 

12 

13**Computational complexity** 

14 

15+----------------------------------+------------------+--------------------------------------+ 

16| Operation | Complexity | Bottleneck | 

17+==================================+==================+======================================+ 

18| EWM volatility (``ret_adj``, | O(T·N) | Linear in both T and N; negligible | 

19| ``vola``) | | | 

20+----------------------------------+------------------+--------------------------------------+ 

21| EWM correlation (``cor``) | O(T·N²) | ``ewm_covariance`` from | 

22| | | ``cvx.linalg`` over all N² pairs | 

23+----------------------------------+------------------+--------------------------------------+ 

24| Linear solve per timestamp | O(N³) | Cholesky / LU per row in | 

25| (``cash_position``) | * T solves | ``cash_position`` | 

26+----------------------------------+------------------+--------------------------------------+ 

27 

28**Memory usage** (peak, approximate) 

29 

30``ewm_covariance`` from ``cvx.linalg`` processes the input Polars DataFrame 

31and returns a dict of covariance matrices. Peak RAM ≈ **O(T · N²)** bytes. 

32Typical working sizes on a 16 GB machine: 

33 

34+--------+--------------------------+------------------------------------+ 

35| N | T (daily rows) | Peak memory (approx.) | 

36+========+==========================+====================================+ 

37| 50 | 252 (~1 yr) | ~70 MB | 

38+--------+--------------------------+------------------------------------+ 

39| 100 | 252 (~1 yr) | ~280 MB | 

40+--------+--------------------------+------------------------------------+ 

41| 100 | 2 520 (~10 yr) | ~2.8 GB | 

42+--------+--------------------------+------------------------------------+ 

43| 200 | 2 520 (~10 yr) | ~11 GB | 

44+--------+--------------------------+------------------------------------+ 

45| 500 | 2 520 (~10 yr) | ~70 GB ⚠ exceeds typical RAM | 

46+--------+--------------------------+------------------------------------+ 

47 

48**Practical limits (daily data)** 

49 

50* **≤ 150 assets, ≤ 5 years** — well within reach on an 8 GB laptop. 

51* **≤ 250 assets, ≤ 10 years** — requires ~11-12 GB; feasible on a 16 GB 

52 workstation. 

53* **> 500 assets with multi-year history** — peak memory exceeds 16 GB; 

54 reduce the time range or switch to a chunked / streaming approach. 

55* **> 1 000 assets** — the O(N³) per-solve cost alone makes real-time 

56 optimization impractical even with adequate RAM. 

57 

58See ``BENCHMARKS.md`` for measured wall-clock timings across representative 

59dataset sizes. 

60 

61Internal structure 

62------------------ 

63The implementation is split across focused private modules to keep each file 

64readable and independently testable: 

65 

66* `_config` — `BasanosConfig` and all 

67 covariance-mode configuration classes. 

68* `_engine_base` — `_BatchCore`, the validated ``prices`` / ``mu`` / 

69 ``cfg`` dataclass composing `_CoreDataMixin`, `_SolveMixin` and 

70 `_SignalEvaluatorMixin`. 

71 `BasanosEngine` subclasses it; the streaming warmup builds it directly. 

72* `_engine_validation` — free functions that validate the 

73 ``prices`` / ``mu`` / ``cfg`` inputs. 

74* `_engine_core` — the `_CoreDataMixin` providing the 

75 core data-access properties (``assets``, ``ret_adj``, ``vola``, ``cor``, 

76 ``cor_tensor``). 

77* `_engine_solve` — private helpers providing the 

78 ``_iter_matrices`` and ``_iter_solve`` generators (per-timestamp solve 

79 logic). 

80* `_engine_diagnostics` — private helpers providing 

81 matrix-quality diagnostics (condition number, effective rank, solver 

82 residual, signal utilisation). 

83* `_engine_performance` — the `_PerformanceMixin` providing the 

84 portfolio-Sharpe sweep helpers (``sharpe_at_shrink``, 

85 ``sharpe_at_window_factors``, ``naive_sharpe``). 

86* `_engine_ic` — private helpers providing signal 

87 evaluation metrics (IC, Rank IC, ICIR, and summary statistics). 

88* This module — `BasanosEngine`, a single flat class that wires 

89 every method together in clearly delimited sections. 

90""" 

91 

92import dataclasses 

93 

94import numpy as np 

95import polars as pl 

96from jquantstats import Portfolio 

97 

98from ._config import ( 

99 BasanosConfig, 

100 CovarianceConfig, 

101 CovarianceMode, 

102 EwmaShrinkConfig, 

103 SlidingWindowConfig, 

104) 

105from ._config_report import ConfigReport 

106from ._engine_base import _BatchCore 

107from ._engine_diagnostics import _DiagnosticsMixin 

108from ._engine_performance import _PerformanceMixin 

109 

110# --------------------------------------------------------------------------- 

111# Re-export config symbols so ``from basanos.math.optimizer import …`` keeps 

112# working for existing callers. 

113# --------------------------------------------------------------------------- 

114__all__ = [ 

115 "BasanosConfig", 

116 "BasanosEngine", 

117 "CovarianceConfig", 

118 "CovarianceMode", 

119 "EwmaShrinkConfig", 

120 "SlidingWindowConfig", 

121] 

122 

123 

124@dataclasses.dataclass(frozen=True) 

125class BasanosEngine(_BatchCore, _DiagnosticsMixin, _PerformanceMixin): 

126 """Engine to compute correlation matrices and optimize risk positions. 

127 

128 Encapsulates price data and configuration to build EWM-based 

129 correlations, apply shrinkage, and solve for normalized positions. 

130 

131 Public methods are organised into clearly delimited sections (some 

132 inherited from the private mixin classes): 

133 

134 * **Core data access** — `assets`, `ret_adj`, `vola`, `cor`, `cor_tensor` 

135 * **Solve / position logic** — `cash_position`, `position_status`, 

136 `risk_position`, `position_leverage`, `warmup_state` 

137 * **Portfolio and performance** — `portfolio`, `naive_sharpe`, 

138 `sharpe_at_shrink`, `sharpe_at_window_factors` 

139 * **Matrix diagnostics** — `condition_number`, `effective_rank`, 

140 `solver_residual`, `signal_utilisation` 

141 * **Signal evaluation** — `ic(h)`, `rank_ic(h)`, `ic_mean(h)`, `ic_std(h)`, 

142 `icir(h)`, `rank_ic_mean(h)`, `rank_ic_std(h)` (``h`` defaults to 1) 

143 * **Reporting** — `config_report` 

144 

145 Data-flow diagram 

146 ----------------- 

147 

148 .. code-block:: text 

149 

150 prices (pl.DataFrame) 

151 │ 

152 ├─ vol_adj ──► ret_adj (volatility-adjusted log returns) 

153 │ │ 

154 │ ├─ ewm_covariance ──► cor / cor_tensor 

155 │ │ │ 

156 │ │ └─ shrink2id / FactorModel 

157 │ │ │ 

158 │ vola covariance matrix 

159 │ │ │ 

160 └── mu ──────────┴── _iter_solve ──────────┘ 

161 │ 

162 cash_position 

163 │ 

164 ┌────────┴────────┐ 

165 portfolio diagnostics 

166 (Portfolio) (condition_number, 

167 effective_rank, 

168 solver_residual, 

169 signal_utilisation, 

170 ic, rank_ic, …) 

171 

172 Attributes: 

173 prices: Polars DataFrame of price levels per asset over time. Must 

174 contain a ``'date'`` column and at least one numeric asset column 

175 with strictly positive values that are not monotonically 

176 non-decreasing or non-increasing (i.e. they must vary in sign). 

177 mu: Polars DataFrame of expected-return signals aligned with *prices*. 

178 Must share the same shape and column names as *prices*. 

179 cfg: Immutable `BasanosConfig` controlling EWMA half-lives, 

180 clipping, shrinkage intensity, and AUM. 

181 

182 Examples: 

183 Build an engine with two synthetic assets over 30 days and inspect the 

184 optimized positions and diagnostic properties. 

185 

186 >>> import numpy as np 

187 >>> import polars as pl 

188 >>> from basanos.math import BasanosConfig, BasanosEngine 

189 >>> dates = list(range(30)) 

190 >>> rng = np.random.default_rng(42) 

191 >>> prices = pl.DataFrame({ 

192 ... "date": dates, 

193 ... "A": np.cumprod(1 + rng.normal(0.001, 0.02, 30)) * 100.0, 

194 ... "B": np.cumprod(1 + rng.normal(0.001, 0.02, 30)) * 150.0, 

195 ... }) 

196 >>> mu = pl.DataFrame({ 

197 ... "date": dates, 

198 ... "A": rng.normal(0.0, 0.5, 30), 

199 ... "B": rng.normal(0.0, 0.5, 30), 

200 ... }) 

201 >>> cfg = BasanosConfig(vola=5, corr=10, clip=2.0, shrink=0.5, aum=1_000_000) 

202 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg) 

203 >>> engine.assets 

204 ['A', 'B'] 

205 >>> engine.cash_position.shape 

206 (30, 3) 

207 >>> engine.position_leverage.columns 

208 ['date', 'leverage'] 

209 """ 

210 

211 # ------------------------------------------------------------------ 

212 # Fields and validation — inherited from _BatchCore 

213 # ------------------------------------------------------------------ 

214 # (prices, mu, cfg, __post_init__ -> _validate_inputs) 

215 # Defined in _engine_base.py, which BasanosStream also builds on. 

216 

217 # ------------------------------------------------------------------ 

218 # Core data-access properties — inherited from _CoreDataMixin 

219 # ------------------------------------------------------------------ 

220 # (assets, ret_adj, vola, cor, cor_tensor) 

221 # Implementations live in _engine_core.py. 

222 

223 # ------------------------------------------------------------------ 

224 # Internal solve helpers — inherited from _SolveMixin 

225 # ------------------------------------------------------------------ 

226 # (_compute_mask, _check_signal, _scale_to_cash, _row_early_check, 

227 # _denom_guard_yield, _compute_position, _replay_positions, 

228 # _iter_matrices, _iter_solve, warmup_state) 

229 # Implementations live in _engine_solve.py; patch targets remain in that 

230 # module's namespace, e.g. ``patch("basanos.math._engine_solve.solve")``. 

231 

232 # ------------------------------------------------------------------ 

233 # Position properties 

234 # ------------------------------------------------------------------ 

235 

236 @property 

237 def cash_position(self) -> pl.DataFrame: 

238 r"""Optimize correlation-aware risk positions for each timestamp. 

239 

240 Supports two covariance modes controlled by ``cfg.covariance_config``: 

241 

242 * `EwmaShrinkConfig` (default): Computes EWMA correlations, applies 

243 linear shrinkage toward the identity, and solves a normalised linear 

244 system $C\,x = \mu$ per timestamp via Cholesky / LU. 

245 

246 * `SlidingWindowConfig`: At each timestamp uses the 

247 ``cfg.covariance_config.window`` most recent vol-adjusted returns to fit a 

248 rank-``cfg.covariance_config.n_factors`` factor model via truncated SVD and 

249 solves the system via the Woodbury identity at $O(k^3 + kn)$ rather 

250 than $O(n^3)$ per step. 

251 

252 Non-finite or ill-posed cases yield zero positions for safety. 

253 

254 Returns: 

255 pl.DataFrame: DataFrame with columns ['date'] + asset names containing 

256 the per-timestamp cash positions (risk divided by EWMA volatility). 

257 

258 Performance: 

259 For ``ewma_shrink``: dominant cost is ``self.cor`` (O(T·N²) time, 

260 O(T·N²) memory). The per-timestamp 

261 linear solve adds O(N³) per row. 

262 

263 For ``sliding_window``: O(T·W·N·k) for sliding SVDs plus 

264 O(T·(k³ + kN)) for Woodbury solves. Memory is O(W·N) per step, 

265 independent of T. 

266 """ 

267 assets = self.assets 

268 

269 # Compute risk positions row-by-row using _replay_positions. 

270 prices_num = self.prices.select(assets).to_numpy() 

271 

272 risk_pos_np = np.full_like(prices_num, fill_value=np.nan, dtype=float) 

273 cash_pos_np = np.full_like(prices_num, fill_value=np.nan, dtype=float) 

274 vola_np = self.vola.select(assets).to_numpy() 

275 

276 self._replay_positions(risk_pos_np, cash_pos_np, vola_np) 

277 

278 # Build Polars DataFrame for cash positions (numeric columns only) 

279 cash_position = self.prices.with_columns( 

280 [(pl.lit(cash_pos_np[:, i]).alias(asset)) for i, asset in enumerate(assets)] 

281 ) 

282 

283 return cash_position 

284 

285 @property 

286 def position_status(self) -> pl.DataFrame: 

287 """Per-timestamp reason code explaining each `cash_position` row. 

288 

289 Labels every row with exactly one of four `SolveStatus` 

290 codes (which compare equal to their string equivalents): 

291 

292 * ``'warmup'``: Insufficient history for the sliding-window 

293 covariance mode (``i + 1 < cfg.covariance_config.window``). 

294 Positions are ``NaN`` for all assets at this timestamp. 

295 * ``'zero_signal'``: The expected-return vector ``mu`` was 

296 all-zeros (or all-NaN) at this timestamp; the optimizer 

297 short-circuited and returned zero positions without solving. 

298 * ``'degenerate'``: The normalisation denominator was non-finite 

299 or below ``cfg.denom_tol``, the Cholesky / Woodbury solve 

300 failed, or no asset had a finite price; positions were zeroed 

301 for safety. 

302 * ``'valid'``: The linear system was solved successfully and 

303 positions are non-trivially non-zero. 

304 

305 The codes map one-to-one onto the three NaN / zero cases 

306 described in the issue and allow downstream consumers (backtests, 

307 risk monitors) to distinguish data gaps from signal silence from 

308 numerical ill-conditioning without re-inspecting ``mu`` or the 

309 engine configuration. 

310 

311 Returns: 

312 pl.DataFrame: Two-column DataFrame ``{'date': ..., 'status': ...}`` 

313 with one row per timestamp. The ``status`` column has 

314 ``Polars`` dtype ``String``. 

315 """ 

316 statuses = [status for _i, _t, _mask, _pos, status in self._iter_solve()] 

317 return pl.DataFrame({"date": self.prices["date"], "status": pl.Series(statuses, dtype=pl.String)}) 

318 

319 @property 

320 def risk_position(self) -> pl.DataFrame: 

321 """Risk positions (before EWMA-volatility scaling) at each timestamp. 

322 

323 Derives the un-volatility-scaled position by multiplying the cash 

324 position by the per-asset EWMA volatility. Equivalently, this is 

325 the quantity solved by the correlation-adjusted linear system before 

326 dividing by ``vola``. 

327 

328 Relationship to other properties:: 

329 

330 cash_position = risk_position / vola 

331 risk_position = cash_position * vola 

332 

333 Returns: 

334 pl.DataFrame: DataFrame with columns ``['date'] + assets`` where 

335 each value is ``cash_position_i * vola_i`` at the given timestamp. 

336 """ 

337 assets = self.assets 

338 cp_np = self.cash_position.select(assets).to_numpy() 

339 vola_np = self.vola.select(assets).to_numpy() 

340 with np.errstate(invalid="ignore"): 

341 risk_pos = cp_np * vola_np 

342 return self.prices.with_columns([pl.lit(risk_pos[:, i]).alias(asset) for i, asset in enumerate(assets)]) 

343 

344 @property 

345 def position_leverage(self) -> pl.DataFrame: 

346 """L1 norm of cash positions (gross leverage) at each timestamp. 

347 

348 Sums the absolute values of all asset cash positions at each row. 

349 NaN positions are treated as zero (they contribute nothing to gross 

350 leverage). 

351 

352 Returns: 

353 pl.DataFrame: Two-column DataFrame ``{'date': ..., 'leverage': ...}`` 

354 where ``leverage`` is the L1 norm of the cash-position vector. 

355 """ 

356 assets = self.assets 

357 cp_np = self.cash_position.select(assets).to_numpy() 

358 leverage = np.nansum(np.abs(cp_np), axis=1) 

359 return pl.DataFrame({"date": self.prices["date"], "leverage": pl.Series(leverage, dtype=pl.Float64)}) 

360 

361 # ------------------------------------------------------------------ 

362 # Portfolio and performance 

363 # ------------------------------------------------------------------ 

364 

365 @property 

366 def portfolio(self) -> Portfolio: 

367 """Construct a Portfolio from the optimized cash positions. 

368 

369 Converts the computed cash positions into a Portfolio using the 

370 configured AUM. The ``cost_per_unit`` from `cfg` is forwarded 

371 so that `net_cost_nav` and 

372 `position_delta_costs` work out 

373 of the box without any further configuration. 

374 

375 Returns: 

376 Portfolio: Instance built from cash positions with AUM scaling. 

377 """ 

378 cp = self.cash_position 

379 assets = [c for c in cp.columns if c != "date" and cp[c].dtype.is_numeric()] 

380 scaled = cp.with_columns(pl.col(a) * self.cfg.position_scale for a in assets) 

381 return Portfolio.from_cash_position(self.prices, scaled, aum=self.cfg.aum, cost_per_unit=self.cfg.cost_per_unit) 

382 

383 # ------------------------------------------------------------------ 

384 # Performance sweeps — inherited from _PerformanceMixin 

385 # ------------------------------------------------------------------ 

386 # (sharpe_at_shrink, sharpe_at_window_factors, naive_sharpe) 

387 # Implementations live in _engine_performance.py. 

388 

389 # ------------------------------------------------------------------ 

390 # Reporting 

391 # ------------------------------------------------------------------ 

392 

393 @property 

394 def config_report(self) -> "ConfigReport": 

395 """Return a `ConfigReport` facade for this engine. 

396 

397 Returns a `ConfigReport` that 

398 includes the full **lambda-sweep chart** — an interactive plot of the 

399 annualised Sharpe ratio as `shrink` (λ) is swept 

400 across [0, 1] — in addition to the parameter table, shrinkage-guidance 

401 table, and theory section available from 

402 `report`. 

403 

404 Returns: 

405 basanos.math._config_report.ConfigReport: Report facade with 

406 ``to_html()`` and ``save()`` methods. 

407 

408 Examples: 

409 >>> import numpy as np 

410 >>> import polars as pl 

411 >>> from basanos.math.optimizer import BasanosConfig, BasanosEngine 

412 >>> dates = pl.Series("date", list(range(200))) 

413 >>> rng = np.random.default_rng(0) 

414 >>> prices = pl.DataFrame({"date": dates, "A": rng.lognormal(size=200), "B": rng.lognormal(size=200)}) 

415 >>> mu = pl.DataFrame({"date": dates, "A": rng.normal(size=200), "B": rng.normal(size=200)}) 

416 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6) 

417 >>> engine = BasanosEngine(prices=prices, mu=mu, cfg=cfg) 

418 >>> report = engine.config_report 

419 >>> html = report.to_html() 

420 >>> "Lambda" in html 

421 True 

422 """ 

423 return ConfigReport(config=self.cfg, engine=self) 

424 

425 # ------------------------------------------------------------------ 

426 # Matrix diagnostics — inherited from _DiagnosticsMixin 

427 # ------------------------------------------------------------------ 

428 # (condition_number, effective_rank, solver_residual, signal_utilisation) 

429 # Implementations live in _engine_diagnostics.py; patch targets remain in 

430 # that module's namespace, e.g. 

431 # ``patch("basanos.math._engine_diagnostics.solve")``. 

432 

433 # ------------------------------------------------------------------ 

434 # Signal evaluation — inherited from _SignalEvaluatorMixin 

435 # ------------------------------------------------------------------ 

436 # (_ic_series, ic, rank_ic, ic_mean, ic_std, icir, 

437 # rank_ic_mean, rank_ic_std) 

438 # Implementations live in _engine_ic.py.