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
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +0000
1"""Configuration classes for the Basanos optimizer.
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"""
8import enum
9import logging
10from typing import Annotated, Literal, TypeVar
12from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
14from ._config_report import ConfigReport
16_logger = logging.getLogger(__name__)
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."""
25_SENTINEL = _SentinelType()
27_T = TypeVar("_T")
30def _coalesce(override: _T | None, current: _T) -> _T:
31 """Return *override* when it is not ``None``, otherwise *current*.
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
41class CovarianceMode(enum.StrEnum):
42 r"""Covariance estimation mode for the Basanos optimizer.
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
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 $$
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`.
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 """
73 ewma_shrink = "ewma_shrink"
74 sliding_window = "sliding_window"
77class EwmaShrinkConfig(BaseModel):
78 """Covariance configuration for the ``ewma_shrink`` mode.
80 This is the default covariance mode. No additional parameters are required
81 beyond those already present on `BasanosConfig` (``shrink``, ``corr``).
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.
91 Examples:
92 >>> cfg = EwmaShrinkConfig()
93 >>> cfg.covariance_mode
94 <CovarianceMode.ewma_shrink: 'ewma_shrink'>
95 """
97 covariance_mode: Literal[CovarianceMode.ewma_shrink] = CovarianceMode.ewma_shrink
99 model_config = {"frozen": True}
102class SlidingWindowConfig(BaseModel):
103 r"""Covariance configuration for the ``sliding_window`` mode.
105 Requires both ``window`` (rolling window length) and ``n_factors`` (number
106 of latent factors for the truncated SVD factor model).
108 **Effective component count** — at each streaming step the number of SVD
109 components actually used is
111 $$
112 k_{\text{eff}} = \min(k,\; W,\; n_{\text{valid}},\; k_{\text{max}})
113 $$
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.
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).
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 """
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 )
190 model_config = {"frozen": True}
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
201CovarianceConfig = Annotated[
202 EwmaShrinkConfig | SlidingWindowConfig,
203 Field(discriminator="covariance_mode"),
204]
205"""Discriminated union of covariance-mode configurations.
207Pydantic selects the correct sub-config based on the ``covariance_mode``
208discriminator field:
210* `EwmaShrinkConfig` when ``covariance_mode="ewma_shrink"``
211* `SlidingWindowConfig` when ``covariance_mode="sliding_window"``
212"""
215class BasanosConfig(BaseModel):
216 r"""Configuration for correlation-aware position optimization.
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.
222 Shrinkage methodology
223 ---------------------
224 ``shrink`` controls linear shrinkage of the EWMA correlation matrix toward
225 the identity:
227 $$
228 C_{\\text{shrunk}} = \\lambda \\cdot C_{\\text{EWMA}} + (1 - \\lambda) \\cdot I_n
229 $$
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.
237 **When to prefer strong shrinkage (low** ``shrink`` **/ high** ``1-shrink``\\ **):**
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).
245 **When to prefer light shrinkage (high** ``shrink``\\ **):**
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.
252 **Practical starting points (daily return data):**
254 Here *n* = number of assets and *T* = ``cfg.corr`` (EWMA lookback).
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 +-----------------------+-------------------+--------------------------------+
268 See `shrink2id` for the full theoretical
269 background and academic references (Ledoit & Wolf, 2004; Chen et al., 2010).
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.
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.
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.
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.
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
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 $$
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)$.
327 ``covariance_config``
328 Pass a `SlidingWindowConfig` instance to enable this mode.
329 The required sub-parameters are:
331 ``window``
332 Rolling window length $W \\geq 1$. Rule of thumb: $W
333 \\geq 2n$ keeps the sample covariance well-posed before truncation.
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.
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 """
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 )
445 model_config = {"frozen": True, "extra": "forbid"}
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.
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.
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
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.
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.
508 All parameters default to ``None``, meaning *keep the existing value*.
509 Pass a non-``None`` value for every field you want to change.
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.
526 Returns:
527 A new `BasanosConfig` with the specified fields replaced and
528 all other fields copied from ``self``.
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 )
559 @property
560 def covariance_mode(self) -> CovarianceMode:
561 """Covariance mode derived from `covariance_config`."""
562 return self.covariance_config.covariance_mode
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
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
578 @property
579 def report(self) -> ConfigReport:
580 """Return a `ConfigReport` facade for this config.
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.
586 To also include a lambda-sweep chart (Sharpe vs λ), use
587 `config_report` instead, which requires price and
588 signal data.
590 Returns:
591 basanos.math._config_report.ConfigReport: Report facade with
592 ``to_html()`` and ``save()`` methods.
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)
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.
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