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

78 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 05:58 +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 

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

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

22class _SentinelType: 

23 """Sentinel type for BasanosConfig.replace() to distinguish 'not provided' from None.""" 

24 

25 

26_SENTINEL = _SentinelType() 

27 

28 

29class CovarianceMode(enum.StrEnum): 

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

31 

32 Attributes: 

33 ewma_shrink: EWMA correlation matrix with linear shrinkage toward the 

34 identity. Controlled by `shrink`. 

35 This is the default mode. 

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

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

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

39 

40 $$ 

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

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

43 + \\hat{D}_t 

44 $$ 

45 

46 where $\\hat{D}_t$ is chosen to enforce unit diagonal. 

47 The system is solved efficiently via the Woodbury identity 

48 (Section 4.3 of basanos.pdf) at $O(k^3 + kn)$ per step 

49 rather than $O(n^3)$. 

50 Configured via `SlidingWindowConfig`. 

51 

52 Examples: 

53 >>> CovarianceMode.ewma_shrink 

54 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

55 >>> CovarianceMode.sliding_window 

56 <CovarianceMode.sliding_window: 'sliding_window'> 

57 >>> CovarianceMode("sliding_window") 

58 <CovarianceMode.sliding_window: 'sliding_window'> 

59 """ 

60 

61 ewma_shrink = "ewma_shrink" 

62 sliding_window = "sliding_window" 

63 

64 

65class EwmaShrinkConfig(BaseModel): 

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

67 

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

69 beyond those already present on `BasanosConfig` (``shrink``, ``corr``). 

70 

71 .. note:: 

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

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

74 discriminated-union dispatch work correctly (see `CovarianceConfig`). 

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

76 name clashes with existing `BasanosConfig` top-level fields and 

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

78 

79 Examples: 

80 >>> cfg = EwmaShrinkConfig() 

81 >>> cfg.covariance_mode 

82 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

83 """ 

84 

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

86 

87 model_config = {"frozen": True} 

88 

89 

90class SlidingWindowConfig(BaseModel): 

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

92 

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

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

95 

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

97 components actually used is 

98 

99 $$ 

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

101 $$ 

102 

103 where $k$ = ``n_factors``, $W$ = ``window``, 

104 $n_{\text{valid}}$ is the number of assets with finite prices at that 

105 step, and $k_{\text{max}}$ = ``max_components`` (or $+\infty$ 

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

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

108 explicitly caps computational cost in large universes without changing the 

109 desired factor count used in batch mode. 

110 

111 Args: 

112 window: Rolling window length $W \\geq 1$. 

113 Rule of thumb: $W \\geq 2n$ keeps the sample covariance 

114 well-posed before truncation. 

115 n_factors: Number of latent factors $k \\geq 1$. 

116 $k = 1$ recovers the single market-factor model; larger 

117 $k$ captures finer correlation structure at the cost of 

118 higher estimation noise. 

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

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

121 $\\min(k_{\\text{eff}},\\, \\texttt{max\\_components})$. 

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

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

124 $\\geq 1$ when provided. Defaults to ``None`` (no extra cap). 

125 

126 Examples: 

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

128 >>> cfg.covariance_mode 

129 <CovarianceMode.sliding_window: 'sliding_window'> 

130 >>> cfg.window 

131 60 

132 >>> cfg.n_factors 

133 3 

134 >>> cfg.max_components is None 

135 True 

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

137 >>> cfg2.max_components 

138 3 

139 """ 

140 

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

142 window: int = Field( 

143 ..., 

144 gt=0, 

145 description=( 

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

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

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

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

150 "results or sizing positions." 

151 ), 

152 ) 

153 n_factors: int = Field( 

154 ..., 

155 gt=0, 

156 description=( 

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

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

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

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

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

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

163 "window length is the binding constraint." 

164 ), 

165 ) 

166 max_components: int | None = Field( 

167 default=None, 

168 gt=0, 

169 description=( 

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

171 "When set, the effective component count is " 

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

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

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

175 ), 

176 ) 

177 

178 model_config = {"frozen": True} 

179 

180 @model_validator(mode="after") 

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

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

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

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

185 raise ValueError(msg) 

186 return self 

187 

188 

189CovarianceConfig = Annotated[ 

190 EwmaShrinkConfig | SlidingWindowConfig, 

191 Field(discriminator="covariance_mode"), 

192] 

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

194 

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

196discriminator field: 

197 

198* `EwmaShrinkConfig` when ``covariance_mode="ewma_shrink"`` 

199* `SlidingWindowConfig` when ``covariance_mode="sliding_window"`` 

200""" 

201 

202 

203class BasanosConfig(BaseModel): 

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

205 

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

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

208 carefully chosen defaults whose rationale is described below. 

209 

210 Shrinkage methodology 

211 --------------------- 

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

213 the identity: 

214 

215 $$ 

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

217 $$ 

218 

219 where $\\lambda$ = ``shrink`` and $I_n$ is the identity. 

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

221 lookback (high concentration ratio $n / T$), reducing the impact of 

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

223 passed to the linear solver. 

224 

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

226 

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

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

229 matrix is less representative of the true structure. 

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

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

232 

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

234 

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

236 * The EWMA correlation structure carries genuine diversification information 

237 that you want the solver to exploit. 

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

239 

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

241 

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

243 

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

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

246 +=======================+===================+================================+ 

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

248 | | | strong regularisation needed. | 

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

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

251 +-----------------------+-------------------+--------------------------------+ 

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

253 | | | light shrinkage for stability. | 

254 +-----------------------+-------------------+--------------------------------+ 

255 

256 See `shrink2id` for the full theoretical 

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

258 

259 Default rationale 

260 ----------------- 

261 ``denom_tol = 1e-12`` 

262 Positions are zeroed when the normalisation denominator 

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

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

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

266 economically meaningful signal magnitude. 

267 

268 ``position_scale = 1e6`` 

269 The dimensionless risk position is multiplied by this factor 

270 before being passed to `Portfolio`. 

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

272 million of the base currency, a conventional denomination for 

273 institutional-scale portfolios where AUM is measured in hundreds 

274 of millions. 

275 

276 ``min_corr_denom = 1e-14`` 

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

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

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

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

281 while remaining negligible for any realistic return series. 

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

283 working with very-low-variance synthetic data. 

284 

285 ``max_nan_fraction = 0.9`` 

286 `ExcessiveNullsError` is raised 

287 during construction when the null fraction in any asset price 

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

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

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

291 are almost entirely null and would contribute no useful 

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

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

294 

295 Sliding-window mode 

296 ------------------- 

297 When ``covariance_config`` is a `SlidingWindowConfig`, the EWMA 

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

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

300 $W \\times n$ submatrix of the $W$ most recent 

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

302 $k$ latent factors. The resulting correlation estimate is 

303 

304 $$ 

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

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

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

308 $$ 

309 

310 where $\\hat{D}_t$ enforces unit diagonal. The linear system 

311 $\\hat{C}_t^{(W,k)}\\mathbf{x}_t = \\boldsymbol{\\mu}_t$ is solved 

312 via the Woodbury identity (`solve`) 

313 at cost $O(k^3 + kn)$ per step rather than $O(n^3)$. 

314 

315 ``covariance_config`` 

316 Pass a `SlidingWindowConfig` instance to enable this mode. 

317 The required sub-parameters are: 

318 

319 ``window`` 

320 Rolling window length $W \\geq 1$. Rule of thumb: $W 

321 \\geq 2n$ keeps the sample covariance well-posed before truncation. 

322 

323 ``n_factors`` 

324 Number of latent factors $k \\geq 1$. $k = 1$ 

325 recovers the single market-factor model; larger $k$ captures 

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

327 

328 Examples: 

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

330 >>> cfg.vola 

331 32 

332 >>> cfg.corr 

333 64 

334 >>> sw_cfg = BasanosConfig( 

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

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

337 ... ) 

338 >>> sw_cfg.covariance_mode 

339 <CovarianceMode.sliding_window: 'sliding_window'> 

340 """ 

341 

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

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

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

345 shrink: float = Field( 

346 ..., 

347 ge=0.0, 

348 le=1.0, 

349 description=( 

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

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

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

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

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

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

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

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

358 ), 

359 ) 

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

361 denom_tol: float = Field( 

362 default=1e-12, 

363 gt=0.0, 

364 description=( 

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

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

367 "remaining negligible for any economically meaningful signal." 

368 ), 

369 ) 

370 position_scale: float = Field( 

371 default=1e6, 

372 gt=0.0, 

373 description=( 

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

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

376 "conventional denomination for institutional portfolios." 

377 ), 

378 ) 

379 min_corr_denom: float = Field( 

380 default=1e-14, 

381 gt=0.0, 

382 description=( 

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

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

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

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

387 "any realistic return variance." 

388 ), 

389 ) 

390 max_nan_fraction: float = Field( 

391 default=0.9, 

392 gt=0.0, 

393 lt=1.0, 

394 description=( 

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

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

397 "strictly exceeds this threshold. " 

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

399 "that are almost entirely null." 

400 ), 

401 ) 

402 covariance_config: CovarianceConfig = Field( 

403 default_factory=EwmaShrinkConfig, 

404 description=( 

405 "Covariance estimation configuration. " 

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

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

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

409 ), 

410 ) 

411 cost_per_unit: float = Field( 

412 default=0.0, 

413 ge=0.0, 

414 description=( 

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

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

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

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

419 ), 

420 ) 

421 max_turnover: float | None = Field( 

422 default=None, 

423 gt=0.0, 

424 description=( 

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

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

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

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

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

430 ), 

431 ) 

432 

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

434 

435 @model_validator(mode="before") 

436 @classmethod 

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

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

439 

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

441 ``window`` as top-level keyword arguments to `BasanosConfig`. 

442 Those fields were replaced by the nested discriminated union 

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

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

445 

446 Examples: 

447 >>> BasanosConfig( 

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

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

450 ... ) # doctest: +IGNORE_EXCEPTION_DETAIL 

451 Traceback (most recent call last): 

452 ... 

453 TypeError: ... 

454 """ 

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

456 found = legacy_keys & data.keys() 

457 if found: 

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

459 msg = ( 

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

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

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

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

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

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

466 " from basanos.math import SlidingWindowConfig\n" 

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

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

469 ) 

470 raise TypeError(msg) 

471 return data 

472 

473 def replace( 

474 self, 

475 *, 

476 vola: int | None = None, 

477 corr: int | None = None, 

478 clip: float | None = None, 

479 shrink: float | None = None, 

480 aum: float | None = None, 

481 denom_tol: float | None = None, 

482 position_scale: float | None = None, 

483 min_corr_denom: float | None = None, 

484 max_nan_fraction: float | None = None, 

485 covariance_config: "CovarianceConfig | None" = None, 

486 cost_per_unit: float | None = None, 

487 max_turnover: float | None | _SentinelType = _SENTINEL, 

488 ) -> "BasanosConfig": 

489 """Return a new `BasanosConfig` with selected fields replaced. 

490 

491 Unlike `model_copy`, this method uses explicit constructor kwarg 

492 forwarding so that any new required field added to 

493 `BasanosConfig` surfaces immediately as a type or lint error at 

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

495 

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

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

498 

499 Args: 

500 vola: EWMA lookback for volatility normalisation. 

501 corr: EWMA lookback for correlation estimation. 

502 clip: Clipping threshold for volatility adjustment. 

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

504 aum: Assets under management for portfolio scaling. 

505 denom_tol: Minimum normalisation denominator. 

506 position_scale: Multiplicative scaling factor for cash positions. 

507 min_corr_denom: Guard threshold for the EWMA correlation denominator. 

508 max_nan_fraction: Maximum tolerated null fraction per price column. 

509 covariance_config: Covariance estimation configuration. 

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

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

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

513 

514 Returns: 

515 A new `BasanosConfig` with the specified fields replaced and 

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

517 

518 Examples: 

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

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

521 >>> cfg2.shrink 

522 0.8 

523 >>> cfg2.vola == cfg.vola 

524 True 

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

526 >>> cfg3.cost_per_unit 

527 0.001 

528 >>> cfg3.max_turnover 

529 100000.0 

530 """ 

531 new_max_turnover: float | None = self.max_turnover if isinstance(max_turnover, _SentinelType) else max_turnover 

532 return BasanosConfig( 

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

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

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

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

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

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

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

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

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

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

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

544 max_turnover=new_max_turnover, 

545 ) 

546 

547 @property 

548 def covariance_mode(self) -> CovarianceMode: 

549 """Covariance mode derived from `covariance_config`.""" 

550 return self.covariance_config.covariance_mode 

551 

552 @property 

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

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

555 if isinstance(self.covariance_config, SlidingWindowConfig): 

556 return self.covariance_config.window 

557 return None 

558 

559 @property 

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

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

562 if isinstance(self.covariance_config, SlidingWindowConfig): 

563 return self.covariance_config.n_factors 

564 return None 

565 

566 @property 

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

568 """Return a `ConfigReport` facade for this config. 

569 

570 Generates a self-contained HTML report summarising all configuration 

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

572 Ledoit-Wolf shrinkage. 

573 

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

575 `config_report` instead, which requires price and 

576 signal data. 

577 

578 Returns: 

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

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

581 

582 Examples: 

583 >>> from basanos.math import BasanosConfig 

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

585 >>> report = cfg.report 

586 >>> html = report.to_html() 

587 >>> "Parameters" in html 

588 True 

589 """ 

590 from ._config_report import ConfigReport 

591 

592 return ConfigReport(config=self) 

593 

594 @field_validator("corr") 

595 @classmethod 

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

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

598 

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

600 """ 

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

602 if vola is not None and v < vola: 

603 raise ValueError 

604 return v