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

77 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 17:47 +0000

1"""Configuration classes for the Basanos optimizer. 

2 

3Extracted from ``optimizer.py`` to keep each module focused on a single concern. 

4All public names are re-exported from ``optimizer.py`` so existing imports are 

5unaffected. 

6""" 

7 

8import enum 

9import logging 

10from typing import TYPE_CHECKING, Annotated, Literal 

11 

12from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator 

13 

14if TYPE_CHECKING: 

15 from ._config_report import ConfigReport 

16 

17_logger = logging.getLogger(__name__) 

18 

19# Sentinel used in BasanosConfig.replace() to distinguish "not provided" 

20# (keep existing value) from "explicitly set to None" (clear the field). 

21_SENTINEL: object = object() 

22 

23 

24class CovarianceMode(enum.StrEnum): 

25 r"""Covariance estimation mode for the Basanos optimizer. 

26 

27 Attributes: 

28 ewma_shrink: EWMA correlation matrix with linear shrinkage toward the 

29 identity. Controlled by :attr:`BasanosConfig.shrink`. 

30 This is the default mode. 

31 sliding_window: Rolling-window factor model. A fixed block of the 

32 ``W`` most recent volatility-adjusted returns is decomposed via 

33 truncated SVD into ``k`` latent factors, giving the estimator 

34 

35 .. math:: 

36 

37 \\hat{C}_t^{(W,k)} = \\frac{1}{W} 

38 \\mathbf{V}_{k,t}\\mathbf{\\Sigma}_{k,t}^2\\mathbf{V}_{k,t}^\\top 

39 + \\hat{D}_t 

40 

41 where :math:`\\hat{D}_t` is chosen to enforce unit diagonal. 

42 The system is solved efficiently via the Woodbury identity 

43 (Section 4.3 of basanos.pdf) at :math:`O(k^3 + kn)` per step 

44 rather than :math:`O(n^3)`. 

45 Configured via :class:`SlidingWindowConfig`. 

46 

47 Examples: 

48 >>> CovarianceMode.ewma_shrink 

49 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

50 >>> CovarianceMode.sliding_window 

51 <CovarianceMode.sliding_window: 'sliding_window'> 

52 >>> CovarianceMode("sliding_window") 

53 <CovarianceMode.sliding_window: 'sliding_window'> 

54 """ 

55 

56 ewma_shrink = "ewma_shrink" 

57 sliding_window = "sliding_window" 

58 

59 

60class EwmaShrinkConfig(BaseModel): 

61 """Covariance configuration for the ``ewma_shrink`` mode. 

62 

63 This is the default covariance mode. No additional parameters are required 

64 beyond those already present on :class:`BasanosConfig` (``shrink``, ``corr``). 

65 

66 .. note:: 

67 This class is **intentionally minimal**. The only field is the 

68 ``covariance_mode`` discriminator, which is required to make Pydantic's 

69 discriminated-union dispatch work correctly (see :data:`CovarianceConfig`). 

70 Before adding new EWMA-specific fields here, consider whether the field 

71 name clashes with existing :class:`BasanosConfig` top-level fields and 

72 whether it would constitute a breaking change to the public API. 

73 

74 Examples: 

75 >>> cfg = EwmaShrinkConfig() 

76 >>> cfg.covariance_mode 

77 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

78 """ 

79 

80 covariance_mode: Literal[CovarianceMode.ewma_shrink] = CovarianceMode.ewma_shrink 

81 

82 model_config = {"frozen": True} 

83 

84 

85class SlidingWindowConfig(BaseModel): 

86 r"""Covariance configuration for the ``sliding_window`` mode. 

87 

88 Requires both ``window`` (rolling window length) and ``n_factors`` (number 

89 of latent factors for the truncated SVD factor model). 

90 

91 **Effective component count** — at each streaming step the number of SVD 

92 components actually used is 

93 

94 .. math:: 

95 

96 k_{\text{eff}} = \min(k,\; W,\; n_{\text{valid}},\; k_{\text{max}}) 

97 

98 where :math:`k` = ``n_factors``, :math:`W` = ``window``, 

99 :math:`n_{\text{valid}}` is the number of assets with finite prices at that 

100 step, and :math:`k_{\text{max}}` = ``max_components`` (or :math:`+\infty` 

101 when unset). This ensures the truncated SVD remains well-posed even when 

102 assets temporarily drop out of the universe. Setting ``max_components`` 

103 explicitly caps computational cost in large universes without changing the 

104 desired factor count used in batch mode. 

105 

106 Args: 

107 window: Rolling window length :math:`W \\geq 1`. 

108 Rule of thumb: :math:`W \\geq 2n` keeps the sample covariance 

109 well-posed before truncation. 

110 n_factors: Number of latent factors :math:`k \\geq 1`. 

111 :math:`k = 1` recovers the single market-factor model; larger 

112 :math:`k` captures finer correlation structure at the cost of 

113 higher estimation noise. 

114 max_components: Optional hard cap on the number of SVD components used 

115 per streaming step. When set, the effective component count is 

116 :math:`\\min(k_{\\text{eff}},\\, \\texttt{max\\_components})`. 

117 Useful for large universes where only a few factors dominate and 

118 you want to limit SVD cost below ``n_factors``. Must be 

119 :math:`\\geq 1` when provided. Defaults to ``None`` (no extra cap). 

120 

121 Examples: 

122 >>> cfg = SlidingWindowConfig(window=60, n_factors=3) 

123 >>> cfg.covariance_mode 

124 <CovarianceMode.sliding_window: 'sliding_window'> 

125 >>> cfg.window 

126 60 

127 >>> cfg.n_factors 

128 3 

129 >>> cfg.max_components is None 

130 True 

131 >>> cfg2 = SlidingWindowConfig(window=60, n_factors=10, max_components=3) 

132 >>> cfg2.max_components 

133 3 

134 """ 

135 

136 covariance_mode: Literal[CovarianceMode.sliding_window] = CovarianceMode.sliding_window 

137 window: int = Field( 

138 ..., 

139 gt=0, 

140 description=( 

141 "Sliding window length W (number of most recent observations). " 

142 "Rule of thumb: W >= 2 * n_assets to keep the sample covariance well-posed. " 

143 "Note: the first W-1 rows of output will have zero/empty positions while the " 

144 "sliding window fills up (warm-up period). Account for this when interpreting " 

145 "results or sizing positions." 

146 ), 

147 ) 

148 n_factors: int = Field( 

149 ..., 

150 gt=0, 

151 description=( 

152 "Number of latent factors k for the sliding window factor model. " 

153 "k=1 recovers the single market-factor model; larger k captures finer correlation " 

154 "structure at the cost of higher estimation noise. " 

155 "At each streaming step the actual number of components used is " 

156 "min(n_factors, window, n_valid_assets[, max_components]), so the effective " 

157 "rank may be lower than n_factors when the number of valid assets or the " 

158 "window length is the binding constraint." 

159 ), 

160 ) 

161 max_components: int | None = Field( 

162 default=None, 

163 gt=0, 

164 description=( 

165 "Optional hard cap on the number of SVD components used per streaming step. " 

166 "When set, the effective component count is " 

167 "min(n_factors, window, n_valid_assets, max_components). " 

168 "Useful for large universes where only a few factors dominate and you want to " 

169 "limit SVD cost below n_factors. Must be >= 1 when provided. Defaults to None." 

170 ), 

171 ) 

172 

173 model_config = {"frozen": True} 

174 

175 @model_validator(mode="after") 

176 def _validate_max_components(self) -> "SlidingWindowConfig": 

177 """Validate that max_components does not exceed n_factors.""" 

178 if self.max_components is not None and self.max_components > self.n_factors: 

179 msg = f"max_components ({self.max_components}) must not exceed n_factors ({self.n_factors})" 

180 raise ValueError(msg) 

181 return self 

182 

183 

184CovarianceConfig = Annotated[ 

185 EwmaShrinkConfig | SlidingWindowConfig, 

186 Field(discriminator="covariance_mode"), 

187] 

188"""Discriminated union of covariance-mode configurations. 

189 

190Pydantic selects the correct sub-config based on the ``covariance_mode`` 

191discriminator field: 

192 

193* :class:`EwmaShrinkConfig` when ``covariance_mode="ewma_shrink"`` 

194* :class:`SlidingWindowConfig` when ``covariance_mode="sliding_window"`` 

195""" 

196 

197 

198class BasanosConfig(BaseModel): 

199 r"""Configuration for correlation-aware position optimization. 

200 

201 The required parameters (``vola``, ``corr``, ``clip``, ``shrink``, ``aum``) 

202 must be supplied by the caller. The optional parameters carry 

203 carefully chosen defaults whose rationale is described below. 

204 

205 Shrinkage methodology 

206 --------------------- 

207 ``shrink`` controls linear shrinkage of the EWMA correlation matrix toward 

208 the identity: 

209 

210 .. math:: 

211 

212 C_{\\text{shrunk}} = \\lambda \\cdot C_{\\text{EWMA}} + (1 - \\lambda) \\cdot I_n 

213 

214 where :math:`\\lambda` = ``shrink`` and :math:`I_n` is the identity. 

215 Shrinkage regularises the matrix when assets are few relative to the 

216 lookback (high concentration ratio :math:`n / T`), reducing the impact of 

217 extreme sample eigenvalues and improving the condition number of the matrix 

218 passed to the linear solver. 

219 

220 **When to prefer strong shrinkage (low** ``shrink`` **/ high** ``1-shrink``\\ **):** 

221 

222 * Fewer than ~30 assets with a ``corr`` lookback shorter than 100 days. 

223 * High-volatility or crisis regimes where correlations spike and the sample 

224 matrix is less representative of the true structure. 

225 * Portfolios where estimation noise is more costly than correlation bias 

226 (e.g., when the signal-to-noise ratio of ``mu`` is low). 

227 

228 **When to prefer light shrinkage (high** ``shrink``\\ **):** 

229 

230 * Many assets with a long lookback (low concentration ratio). 

231 * The EWMA correlation structure carries genuine diversification information 

232 that you want the solver to exploit. 

233 * Out-of-sample testing shows that position stability is not a concern. 

234 

235 **Practical starting points (daily return data):** 

236 

237 Here *n* = number of assets and *T* = ``cfg.corr`` (EWMA lookback). 

238 

239 +-----------------------+-------------------+--------------------------------+ 

240 | n (assets) / T (corr) | Suggested shrink | Notes | 

241 +=======================+===================+================================+ 

242 | n > 20, T < 40 | 0.3 - 0.5 | Near-singular matrix likely; | 

243 | | | strong regularisation needed. | 

244 +-----------------------+-------------------+--------------------------------+ 

245 | n ~ 10, T ~ 60 | 0.5 - 0.7 | Balanced regime. | 

246 +-----------------------+-------------------+--------------------------------+ 

247 | n < 10, T > 100 | 0.7 - 0.9 | Well-conditioned sample; | 

248 | | | light shrinkage for stability. | 

249 +-----------------------+-------------------+--------------------------------+ 

250 

251 See :func:`~basanos.math._signal.shrink2id` for the full theoretical 

252 background and academic references (Ledoit & Wolf, 2004; Chen et al., 2010). 

253 

254 Default rationale 

255 ----------------- 

256 ``denom_tol = 1e-12`` 

257 Positions are zeroed when the normalisation denominator 

258 ``inv_a_norm(μ, Σ)`` falls at or below this threshold. The 

259 value 1e-12 provides ample headroom above float64 machine 

260 epsilon (~2.2e-16) while remaining negligible relative to any 

261 economically meaningful signal magnitude. 

262 

263 ``position_scale = 1e6`` 

264 The dimensionless risk position is multiplied by this factor 

265 before being passed to :class:`~jquantstats.Portfolio`. 

266 A value of 1e6 means positions are expressed in units of one 

267 million of the base currency, a conventional denomination for 

268 institutional-scale portfolios where AUM is measured in hundreds 

269 of millions. 

270 

271 ``min_corr_denom = 1e-14`` 

272 The EWMA correlation denominator ``sqrt(var_x * var_y)`` is 

273 compared against this threshold; when at or below it the 

274 correlation is set to NaN rather than dividing by a near-zero 

275 value. The default 1e-14 is safely above float64 underflow 

276 while remaining negligible for any realistic return series. 

277 Advanced users may tighten this guard (larger value) when 

278 working with very-low-variance synthetic data. 

279 

280 ``max_nan_fraction = 0.9`` 

281 :class:`~basanos.exceptions.ExcessiveNullsError` is raised 

282 during construction when the null fraction in any asset price 

283 column **strictly exceeds** this threshold. The default 0.9 

284 permits up to 90 % missing prices (e.g., illiquid or recently 

285 listed assets in a long history) while rejecting columns that 

286 are almost entirely null and would contribute no useful 

287 information. Callers who want a stricter gate can lower this 

288 value; callers running on sparse data can raise it toward 1.0. 

289 

290 Sliding-window mode 

291 ------------------- 

292 When ``covariance_config`` is a :class:`SlidingWindowConfig`, the EWMA 

293 correlation estimator is replaced by a rolling-window factor model 

294 (Section 4.4 of basanos.pdf). At each timestamp *t* the 

295 :math:`W \\times n` submatrix of the :math:`W` most recent 

296 volatility-adjusted returns is decomposed via truncated SVD to extract 

297 :math:`k` latent factors. The resulting correlation estimate is 

298 

299 .. math:: 

300 

301 \\hat{C}_t^{(W,k)} 

302 = \\frac{1}{W}\\mathbf{V}_{k,t}\\mathbf{\\Sigma}_{k,t}^2 

303 \\mathbf{V}_{k,t}^\\top + \\hat{D}_t 

304 

305 where :math:`\\hat{D}_t` enforces unit diagonal. The linear system 

306 :math:`\\hat{C}_t^{(W,k)}\\mathbf{x}_t = \\boldsymbol{\\mu}_t` is solved 

307 via the Woodbury identity (:func:`~basanos.math._factor_model.FactorModel.solve`) 

308 at cost :math:`O(k^3 + kn)` per step rather than :math:`O(n^3)`. 

309 

310 ``covariance_config`` 

311 Pass a :class:`SlidingWindowConfig` instance to enable this mode. 

312 The required sub-parameters are: 

313 

314 ``window`` 

315 Rolling window length :math:`W \\geq 1`. Rule of thumb: :math:`W 

316 \\geq 2n` keeps the sample covariance well-posed before truncation. 

317 

318 ``n_factors`` 

319 Number of latent factors :math:`k \\geq 1`. :math:`k = 1` 

320 recovers the single market-factor model; larger :math:`k` captures 

321 finer correlation structure at the cost of higher estimation noise. 

322 

323 Examples: 

324 >>> cfg = BasanosConfig(vola=32, corr=64, clip=3.0, shrink=0.5, aum=1e8) 

325 >>> cfg.vola 

326 32 

327 >>> cfg.corr 

328 64 

329 >>> sw_cfg = BasanosConfig( 

330 ... vola=16, corr=32, clip=3.0, shrink=0.5, aum=1e6, 

331 ... covariance_config=SlidingWindowConfig(window=60, n_factors=3), 

332 ... ) 

333 >>> sw_cfg.covariance_mode 

334 <CovarianceMode.sliding_window: 'sliding_window'> 

335 """ 

336 

337 vola: int = Field(..., gt=0, description="EWMA lookback for volatility normalization.") 

338 corr: int = Field(..., gt=0, description="EWMA lookback for correlation estimation.") 

339 clip: float = Field(..., gt=0.0, description="Clipping threshold for volatility adjustment.") 

340 shrink: float = Field( 

341 ..., 

342 ge=0.0, 

343 le=1.0, 

344 description=( 

345 "Retention weight λ for linear shrinkage of the EWMA correlation matrix toward " 

346 "the identity: C_shrunk = λ·C_ewma + (1-λ)·I. " 

347 "λ=1.0 uses the raw EWMA matrix (no shrinkage); λ=0.0 replaces it entirely " 

348 "with the identity (maximum shrinkage, positions are treated as uncorrelated). " 

349 "Values in [0.3, 0.8] are typical for daily financial return data. " 

350 "Lower values improve numerical stability when assets are many relative to the " 

351 "lookback (high concentration ratio n/T). See shrink2id() for full guidance. " 

352 "Only used when covariance_mode='ewma_shrink'." 

353 ), 

354 ) 

355 aum: float = Field(..., gt=0.0, description="Assets under management for portfolio scaling.") 

356 denom_tol: float = Field( 

357 default=1e-12, 

358 gt=0.0, 

359 description=( 

360 "Minimum normalisation denominator; positions are zeroed at or below this value. " 

361 "The default 1e-12 is well above float64 machine epsilon (~2.2e-16) while " 

362 "remaining negligible for any economically meaningful signal." 

363 ), 

364 ) 

365 position_scale: float = Field( 

366 default=1e6, 

367 gt=0.0, 

368 description=( 

369 "Multiplicative scaling factor applied to dimensionless risk positions to obtain " 

370 "cash positions in base-currency units. Defaults to 1e6 (one million), a " 

371 "conventional denomination for institutional portfolios." 

372 ), 

373 ) 

374 min_corr_denom: float = Field( 

375 default=1e-14, 

376 gt=0.0, 

377 description=( 

378 "Guard threshold for the EWMA correlation denominator sqrt(var_x * var_y). " 

379 "When the denominator is at or below this value the correlation is set to NaN " 

380 "instead of dividing by a near-zero number. " 

381 "The default 1e-14 is safely above float64 underflow while being negligible for " 

382 "any realistic return variance." 

383 ), 

384 ) 

385 max_nan_fraction: float = Field( 

386 default=0.9, 

387 gt=0.0, 

388 lt=1.0, 

389 description=( 

390 "Maximum tolerated fraction of null values in any asset price column. " 

391 "ExcessiveNullsError is raised during construction when the null fraction " 

392 "strictly exceeds this threshold. " 

393 "The default 0.9 allows up to 90 % missing prices while rejecting columns " 

394 "that are almost entirely null." 

395 ), 

396 ) 

397 covariance_config: CovarianceConfig = Field( 

398 default_factory=EwmaShrinkConfig, 

399 description=( 

400 "Covariance estimation configuration. " 

401 "Pass EwmaShrinkConfig() (default) for EWMA correlation with linear shrinkage " 

402 "toward the identity, or SlidingWindowConfig(window=W, n_factors=k) for a " 

403 "rolling-window factor model. See Section 4.4 of basanos.pdf." 

404 ), 

405 ) 

406 cost_per_unit: float = Field( 

407 default=0.0, 

408 ge=0.0, 

409 description=( 

410 "One-way trading cost per unit of position change. " 

411 "At each period, the cost deduction is sum(|x_t - x_{t-1}|) * cost_per_unit " 

412 "where x_t is the cash position vector. Defaults to 0.0 (no cost). " 

413 "The resulting net-of-cost NAV is exposed via Portfolio.net_cost_nav." 

414 ), 

415 ) 

416 max_turnover: float | None = Field( 

417 default=None, 

418 gt=0.0, 

419 description=( 

420 "Optional turnover budget per period in cash-position units. " 

421 "When set, the L1 norm of position changes sum(|x_t - x_{t-1}|) is capped " 

422 "at this value at every solve step by proportionally scaling the position " 

423 "delta toward the previous position. Must be strictly positive when provided. " 

424 "Defaults to None (no turnover constraint)." 

425 ), 

426 ) 

427 

428 model_config = {"frozen": True, "extra": "forbid"} 

429 

430 @model_validator(mode="before") 

431 @classmethod 

432 def _reject_legacy_flat_kwargs(cls, data: dict[str, object]) -> dict[str, object]: 

433 """Raise an informative TypeError when the pre-v0.4 flat kwargs are used. 

434 

435 Before v0.4 callers passed ``covariance_mode``, ``n_factors``, and 

436 ``window`` as top-level keyword arguments to :class:`BasanosConfig`. 

437 Those fields were replaced by the nested discriminated union 

438 ``covariance_config``. Without this validator Pydantic raises a 

439 generic ``extra_forbidden`` error that gives no migration guidance. 

440 

441 Examples: 

442 >>> BasanosConfig( 

443 ... vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6, 

444 ... covariance_mode="sliding_window", window=30, n_factors=2, 

445 ... ) # doctest: +IGNORE_EXCEPTION_DETAIL 

446 Traceback (most recent call last): 

447 ... 

448 TypeError: ... 

449 """ 

450 legacy_keys = {"covariance_mode", "n_factors", "window"} 

451 found = legacy_keys & data.keys() 

452 if found: 

453 found_str = ", ".join(f"'{k}'" for k in sorted(found)) 

454 msg = ( 

455 f"BasanosConfig received legacy keyword argument(s): {found_str}. " 

456 "These flat fields were removed in v0.4. " 

457 "Migrate to the nested covariance_config API:\n\n" 

458 " # Before (v0.3 and earlier):\n" 

459 " BasanosConfig(..., covariance_mode='sliding_window', window=30, n_factors=2)\n\n" 

460 " # After (v0.4+):\n" 

461 " from basanos.math import SlidingWindowConfig\n" 

462 " BasanosConfig(..., covariance_config=SlidingWindowConfig(window=30, n_factors=2))\n\n" 

463 "For the default EWMA-shrink mode no covariance_config argument is needed." 

464 ) 

465 raise TypeError(msg) 

466 return data 

467 

468 def replace( 

469 self, 

470 *, 

471 vola: int | None = None, 

472 corr: int | None = None, 

473 clip: float | None = None, 

474 shrink: float | None = None, 

475 aum: float | None = None, 

476 denom_tol: float | None = None, 

477 position_scale: float | None = None, 

478 min_corr_denom: float | None = None, 

479 max_nan_fraction: float | None = None, 

480 covariance_config: "CovarianceConfig | None" = None, 

481 cost_per_unit: float | None = None, 

482 max_turnover: float | None = _SENTINEL, # type: ignore[assignment] 

483 ) -> "BasanosConfig": 

484 """Return a new :class:`BasanosConfig` with selected fields replaced. 

485 

486 Unlike :meth:`model_copy`, this method uses explicit constructor kwarg 

487 forwarding so that any new required field added to 

488 :class:`BasanosConfig` surfaces immediately as a type or lint error at 

489 the call site, rather than silently failing at runtime. 

490 

491 All parameters default to ``None``, meaning *keep the existing value*. 

492 Pass a non-``None`` value for every field you want to change. 

493 

494 Args: 

495 vola: EWMA lookback for volatility normalisation. 

496 corr: EWMA lookback for correlation estimation. 

497 clip: Clipping threshold for volatility adjustment. 

498 shrink: Retention weight λ ∈ [0, 1] for linear shrinkage. 

499 aum: Assets under management for portfolio scaling. 

500 denom_tol: Minimum normalisation denominator. 

501 position_scale: Multiplicative scaling factor for cash positions. 

502 min_corr_denom: Guard threshold for the EWMA correlation denominator. 

503 max_nan_fraction: Maximum tolerated null fraction per price column. 

504 covariance_config: Covariance estimation configuration. 

505 cost_per_unit: One-way trading cost per unit of position change. 

506 max_turnover: Optional turnover budget per period in cash-position 

507 units. Pass ``None`` explicitly to clear an existing budget. 

508 

509 Returns: 

510 A new :class:`BasanosConfig` with the specified fields replaced and 

511 all other fields copied from ``self``. 

512 

513 Examples: 

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

515 >>> cfg2 = cfg.replace(shrink=0.8) 

516 >>> cfg2.shrink 

517 0.8 

518 >>> cfg2.vola == cfg.vola 

519 True 

520 >>> cfg3 = cfg.replace(cost_per_unit=0.001, max_turnover=1e5) 

521 >>> cfg3.cost_per_unit 

522 0.001 

523 >>> cfg3.max_turnover 

524 100000.0 

525 """ 

526 new_max_turnover: float | None = self.max_turnover if max_turnover is _SENTINEL else max_turnover 

527 return BasanosConfig( 

528 vola=self.vola if vola is None else vola, 

529 corr=self.corr if corr is None else corr, 

530 clip=self.clip if clip is None else clip, 

531 shrink=self.shrink if shrink is None else shrink, 

532 aum=self.aum if aum is None else aum, 

533 denom_tol=self.denom_tol if denom_tol is None else denom_tol, 

534 position_scale=self.position_scale if position_scale is None else position_scale, 

535 min_corr_denom=self.min_corr_denom if min_corr_denom is None else min_corr_denom, 

536 max_nan_fraction=self.max_nan_fraction if max_nan_fraction is None else max_nan_fraction, 

537 covariance_config=self.covariance_config if covariance_config is None else covariance_config, 

538 cost_per_unit=self.cost_per_unit if cost_per_unit is None else cost_per_unit, 

539 max_turnover=new_max_turnover, 

540 ) 

541 

542 @property 

543 def covariance_mode(self) -> CovarianceMode: 

544 """Covariance mode derived from :attr:`covariance_config`.""" 

545 return self.covariance_config.covariance_mode 

546 

547 @property 

548 def window(self) -> int | None: 

549 """Sliding window length, or ``None`` when not in ``sliding_window`` mode.""" 

550 if isinstance(self.covariance_config, SlidingWindowConfig): 

551 return self.covariance_config.window 

552 return None 

553 

554 @property 

555 def n_factors(self) -> int | None: 

556 """Number of latent factors, or ``None`` when not in ``sliding_window`` mode.""" 

557 if isinstance(self.covariance_config, SlidingWindowConfig): 

558 return self.covariance_config.n_factors 

559 return None 

560 

561 @property 

562 def report(self) -> "ConfigReport": 

563 """Return a :class:`~basanos.math._config_report.ConfigReport` facade for this config. 

564 

565 Generates a self-contained HTML report summarising all configuration 

566 parameters, a shrinkage-guidance table, and a theory section on 

567 Ledoit-Wolf shrinkage. 

568 

569 To also include a lambda-sweep chart (Sharpe vs λ), use 

570 :attr:`BasanosEngine.config_report` instead, which requires price and 

571 signal data. 

572 

573 Returns: 

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

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

576 

577 Examples: 

578 >>> from basanos.math import BasanosConfig 

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

580 >>> report = cfg.report 

581 >>> html = report.to_html() 

582 >>> "Parameters" in html 

583 True 

584 """ 

585 from ._config_report import ConfigReport 

586 

587 return ConfigReport(config=self) 

588 

589 @field_validator("corr") 

590 @classmethod 

591 def corr_greater_than_vola(cls, v: int, info: ValidationInfo) -> int: 

592 """Optionally enforce corr ≥ vola for stability. 

593 

594 Pydantic v2 passes ValidationInfo; use info.data to access other fields. 

595 """ 

596 vola = info.data.get("vola") if hasattr(info, "data") else None 

597 if vola is not None and v < vola: 

598 raise ValueError 

599 return v