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

81 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-04 07:53 +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 Annotated, Literal, TypeVar 

11 

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

13 

14from ._config_report import ConfigReport 

15 

16_logger = logging.getLogger(__name__) 

17 

18 

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

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

21class _SentinelType: 

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

23 

24 

25_SENTINEL = _SentinelType() 

26 

27_T = TypeVar("_T") 

28 

29 

30def _coalesce(override: _T | None, current: _T) -> _T: 

31 """Return *override* when it is not ``None``, otherwise *current*. 

32 

33 Helper for `BasanosConfig.replace`: a ``None`` override means 

34 "keep the existing value". Factoring the per-field ``x if x is None 

35 else y`` choice into a named call keeps `replace` flat rather than a 

36 long ternary chain. 

37 """ 

38 return current if override is None else override 

39 

40 

41class CovarianceMode(enum.StrEnum): 

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

43 

44 Attributes: 

45 ewma_shrink: EWMA correlation matrix with linear shrinkage toward the 

46 identity. Controlled by `shrink`. 

47 This is the default mode. 

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

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

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

51 

52 $$ 

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

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

55 + \\hat{D}_t 

56 $$ 

57 

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

59 The system is solved efficiently via the Woodbury identity 

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

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

62 Configured via `SlidingWindowConfig`. 

63 

64 Examples: 

65 >>> CovarianceMode.ewma_shrink 

66 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

67 >>> CovarianceMode.sliding_window 

68 <CovarianceMode.sliding_window: 'sliding_window'> 

69 >>> CovarianceMode("sliding_window") 

70 <CovarianceMode.sliding_window: 'sliding_window'> 

71 """ 

72 

73 ewma_shrink = "ewma_shrink" 

74 sliding_window = "sliding_window" 

75 

76 

77class EwmaShrinkConfig(BaseModel): 

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

79 

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

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

82 

83 .. note:: 

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

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

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

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

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

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

90 

91 Examples: 

92 >>> cfg = EwmaShrinkConfig() 

93 >>> cfg.covariance_mode 

94 <CovarianceMode.ewma_shrink: 'ewma_shrink'> 

95 """ 

96 

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

98 

99 model_config = {"frozen": True} 

100 

101 

102class SlidingWindowConfig(BaseModel): 

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

104 

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

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

107 

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

109 components actually used is 

110 

111 $$ 

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

113 $$ 

114 

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

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

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

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

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

120 explicitly caps computational cost in large universes without changing the 

121 desired factor count used in batch mode. 

122 

123 Args: 

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

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

126 well-posed before truncation. 

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

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

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

130 higher estimation noise. 

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

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

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

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

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

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

137 

138 Examples: 

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

140 >>> cfg.covariance_mode 

141 <CovarianceMode.sliding_window: 'sliding_window'> 

142 >>> cfg.window 

143 60 

144 >>> cfg.n_factors 

145 3 

146 >>> cfg.max_components is None 

147 True 

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

149 >>> cfg2.max_components 

150 3 

151 """ 

152 

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

154 window: int = Field( 

155 ..., 

156 gt=0, 

157 description=( 

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

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

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

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

162 "results or sizing positions." 

163 ), 

164 ) 

165 n_factors: int = Field( 

166 ..., 

167 gt=0, 

168 description=( 

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

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

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

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

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

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

175 "window length is the binding constraint." 

176 ), 

177 ) 

178 max_components: int | None = Field( 

179 default=None, 

180 gt=0, 

181 description=( 

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

183 "When set, the effective component count is " 

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

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

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

187 ), 

188 ) 

189 

190 model_config = {"frozen": True} 

191 

192 @model_validator(mode="after") 

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

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

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

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

197 raise ValueError(msg) 

198 return self 

199 

200 

201CovarianceConfig = Annotated[ 

202 EwmaShrinkConfig | SlidingWindowConfig, 

203 Field(discriminator="covariance_mode"), 

204] 

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

206 

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

208discriminator field: 

209 

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

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

212""" 

213 

214 

215class BasanosConfig(BaseModel): 

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

217 

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

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

220 carefully chosen defaults whose rationale is described below. 

221 

222 Shrinkage methodology 

223 --------------------- 

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

225 the identity: 

226 

227 $$ 

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

229 $$ 

230 

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

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

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

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

235 passed to the linear solver. 

236 

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

238 

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

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

241 matrix is less representative of the true structure. 

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

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

244 

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

246 

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

248 * The EWMA correlation structure carries genuine diversification information 

249 that you want the solver to exploit. 

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

251 

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

253 

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

255 

256 +-----------------------+-------------------+--------------------------------+ 

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

258 +=======================+===================+================================+ 

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

260 | | | strong regularisation needed. | 

261 +-----------------------+-------------------+--------------------------------+ 

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

263 +-----------------------+-------------------+--------------------------------+ 

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

265 | | | light shrinkage for stability. | 

266 +-----------------------+-------------------+--------------------------------+ 

267 

268 See `shrink2id` for the full theoretical 

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

270 

271 Default rationale 

272 ----------------- 

273 ``denom_tol = 1e-12`` 

274 Positions are zeroed when the normalisation denominator 

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

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

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

278 economically meaningful signal magnitude. 

279 

280 ``position_scale = 1e6`` 

281 The dimensionless risk position is multiplied by this factor 

282 before being passed to `Portfolio`. 

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

284 million of the base currency, a conventional denomination for 

285 institutional-scale portfolios where AUM is measured in hundreds 

286 of millions. 

287 

288 ``min_corr_denom = 1e-14`` 

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

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

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

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

293 while remaining negligible for any realistic return series. 

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

295 working with very-low-variance synthetic data. 

296 

297 ``max_nan_fraction = 0.9`` 

298 `ExcessiveNullsError` is raised 

299 during construction when the null fraction in any asset price 

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

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

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

303 are almost entirely null and would contribute no useful 

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

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

306 

307 Sliding-window mode 

308 ------------------- 

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

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

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

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

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

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

315 

316 $$ 

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

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

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

320 $$ 

321 

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

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

324 via the Woodbury identity (`solve`) 

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

326 

327 ``covariance_config`` 

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

329 The required sub-parameters are: 

330 

331 ``window`` 

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

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

334 

335 ``n_factors`` 

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

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

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

339 

340 Examples: 

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

342 >>> cfg.vola 

343 32 

344 >>> cfg.corr 

345 64 

346 >>> sw_cfg = BasanosConfig( 

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

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

349 ... ) 

350 >>> sw_cfg.covariance_mode 

351 <CovarianceMode.sliding_window: 'sliding_window'> 

352 """ 

353 

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

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

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

357 shrink: float = Field( 

358 ..., 

359 ge=0.0, 

360 le=1.0, 

361 description=( 

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

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

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

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

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

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

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

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

370 ), 

371 ) 

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

373 denom_tol: float = Field( 

374 default=1e-12, 

375 gt=0.0, 

376 description=( 

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

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

379 "remaining negligible for any economically meaningful signal." 

380 ), 

381 ) 

382 position_scale: float = Field( 

383 default=1e6, 

384 gt=0.0, 

385 description=( 

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

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

388 "conventional denomination for institutional portfolios." 

389 ), 

390 ) 

391 min_corr_denom: float = Field( 

392 default=1e-14, 

393 gt=0.0, 

394 description=( 

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

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

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

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

399 "any realistic return variance." 

400 ), 

401 ) 

402 max_nan_fraction: float = Field( 

403 default=0.9, 

404 gt=0.0, 

405 lt=1.0, 

406 description=( 

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

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

409 "strictly exceeds this threshold. " 

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

411 "that are almost entirely null." 

412 ), 

413 ) 

414 covariance_config: CovarianceConfig = Field( 

415 default_factory=EwmaShrinkConfig, 

416 description=( 

417 "Covariance estimation configuration. " 

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

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

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

421 ), 

422 ) 

423 cost_per_unit: float = Field( 

424 default=0.0, 

425 ge=0.0, 

426 description=( 

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

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

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

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

431 ), 

432 ) 

433 max_turnover: float | None = Field( 

434 default=None, 

435 gt=0.0, 

436 description=( 

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

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

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

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

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

442 ), 

443 ) 

444 

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

446 

447 @model_validator(mode="before") 

448 @classmethod 

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

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

451 

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

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

454 Those fields were replaced by the nested discriminated union 

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

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

457 

458 Examples: 

459 >>> BasanosConfig( 

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

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

462 ... ) # doctest: +IGNORE_EXCEPTION_DETAIL 

463 Traceback (most recent call last): 

464 ... 

465 TypeError: ... 

466 """ 

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

468 found = legacy_keys & data.keys() 

469 if found: 

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

471 msg = ( 

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

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

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

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

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

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

478 " from basanos.math import SlidingWindowConfig\n" 

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

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

481 ) 

482 raise TypeError(msg) 

483 return data 

484 

485 def replace( 

486 self, 

487 *, 

488 vola: int | None = None, 

489 corr: int | None = None, 

490 clip: float | None = None, 

491 shrink: float | None = None, 

492 aum: float | None = None, 

493 denom_tol: float | None = None, 

494 position_scale: float | None = None, 

495 min_corr_denom: float | None = None, 

496 max_nan_fraction: float | None = None, 

497 covariance_config: "CovarianceConfig | None" = None, 

498 cost_per_unit: float | None = None, 

499 max_turnover: float | _SentinelType | None = _SENTINEL, 

500 ) -> "BasanosConfig": 

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

502 

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

504 forwarding so that any new required field added to 

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

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

507 

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

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

510 

511 Args: 

512 vola: EWMA lookback for volatility normalisation. 

513 corr: EWMA lookback for correlation estimation. 

514 clip: Clipping threshold for volatility adjustment. 

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

516 aum: Assets under management for portfolio scaling. 

517 denom_tol: Minimum normalisation denominator. 

518 position_scale: Multiplicative scaling factor for cash positions. 

519 min_corr_denom: Guard threshold for the EWMA correlation denominator. 

520 max_nan_fraction: Maximum tolerated null fraction per price column. 

521 covariance_config: Covariance estimation configuration. 

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

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

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

525 

526 Returns: 

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

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

529 

530 Examples: 

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

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

533 >>> cfg2.shrink 

534 0.8 

535 >>> cfg2.vola == cfg.vola 

536 True 

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

538 >>> cfg3.cost_per_unit 

539 0.001 

540 >>> cfg3.max_turnover 

541 100000.0 

542 """ 

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

544 return BasanosConfig( 

545 vola=_coalesce(vola, self.vola), 

546 corr=_coalesce(corr, self.corr), 

547 clip=_coalesce(clip, self.clip), 

548 shrink=_coalesce(shrink, self.shrink), 

549 aum=_coalesce(aum, self.aum), 

550 denom_tol=_coalesce(denom_tol, self.denom_tol), 

551 position_scale=_coalesce(position_scale, self.position_scale), 

552 min_corr_denom=_coalesce(min_corr_denom, self.min_corr_denom), 

553 max_nan_fraction=_coalesce(max_nan_fraction, self.max_nan_fraction), 

554 covariance_config=_coalesce(covariance_config, self.covariance_config), 

555 cost_per_unit=_coalesce(cost_per_unit, self.cost_per_unit), 

556 max_turnover=new_max_turnover, 

557 ) 

558 

559 @property 

560 def covariance_mode(self) -> CovarianceMode: 

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

562 return self.covariance_config.covariance_mode 

563 

564 @property 

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

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

567 if isinstance(self.covariance_config, SlidingWindowConfig): 

568 return self.covariance_config.window 

569 return None 

570 

571 @property 

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

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

574 if isinstance(self.covariance_config, SlidingWindowConfig): 

575 return self.covariance_config.n_factors 

576 return None 

577 

578 @property 

579 def report(self) -> ConfigReport: 

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

581 

582 Generates a self-contained HTML report summarising all configuration 

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

584 Ledoit-Wolf shrinkage. 

585 

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

587 `config_report` instead, which requires price and 

588 signal data. 

589 

590 Returns: 

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

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

593 

594 Examples: 

595 >>> from basanos.math import BasanosConfig 

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

597 >>> report = cfg.report 

598 >>> html = report.to_html() 

599 >>> "Parameters" in html 

600 True 

601 """ 

602 return ConfigReport(config=self) 

603 

604 @field_validator("corr") 

605 @classmethod 

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

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

608 

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

610 """ 

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

612 if vola is not None and v < vola: 

613 raise ValueError 

614 return v