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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 05:58 +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 TYPE_CHECKING, Annotated, Literal
12from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
14if TYPE_CHECKING:
15 from ._config_report import ConfigReport
17_logger = logging.getLogger(__name__)
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."""
26_SENTINEL = _SentinelType()
29class CovarianceMode(enum.StrEnum):
30 r"""Covariance estimation mode for the Basanos optimizer.
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
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 $$
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`.
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 """
61 ewma_shrink = "ewma_shrink"
62 sliding_window = "sliding_window"
65class EwmaShrinkConfig(BaseModel):
66 """Covariance configuration for the ``ewma_shrink`` mode.
68 This is the default covariance mode. No additional parameters are required
69 beyond those already present on `BasanosConfig` (``shrink``, ``corr``).
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.
79 Examples:
80 >>> cfg = EwmaShrinkConfig()
81 >>> cfg.covariance_mode
82 <CovarianceMode.ewma_shrink: 'ewma_shrink'>
83 """
85 covariance_mode: Literal[CovarianceMode.ewma_shrink] = CovarianceMode.ewma_shrink
87 model_config = {"frozen": True}
90class SlidingWindowConfig(BaseModel):
91 r"""Covariance configuration for the ``sliding_window`` mode.
93 Requires both ``window`` (rolling window length) and ``n_factors`` (number
94 of latent factors for the truncated SVD factor model).
96 **Effective component count** — at each streaming step the number of SVD
97 components actually used is
99 $$
100 k_{\text{eff}} = \min(k,\; W,\; n_{\text{valid}},\; k_{\text{max}})
101 $$
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.
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).
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 """
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 )
178 model_config = {"frozen": True}
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
189CovarianceConfig = Annotated[
190 EwmaShrinkConfig | SlidingWindowConfig,
191 Field(discriminator="covariance_mode"),
192]
193"""Discriminated union of covariance-mode configurations.
195Pydantic selects the correct sub-config based on the ``covariance_mode``
196discriminator field:
198* `EwmaShrinkConfig` when ``covariance_mode="ewma_shrink"``
199* `SlidingWindowConfig` when ``covariance_mode="sliding_window"``
200"""
203class BasanosConfig(BaseModel):
204 r"""Configuration for correlation-aware position optimization.
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.
210 Shrinkage methodology
211 ---------------------
212 ``shrink`` controls linear shrinkage of the EWMA correlation matrix toward
213 the identity:
215 $$
216 C_{\\text{shrunk}} = \\lambda \\cdot C_{\\text{EWMA}} + (1 - \\lambda) \\cdot I_n
217 $$
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.
225 **When to prefer strong shrinkage (low** ``shrink`` **/ high** ``1-shrink``\\ **):**
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).
233 **When to prefer light shrinkage (high** ``shrink``\\ **):**
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.
240 **Practical starting points (daily return data):**
242 Here *n* = number of assets and *T* = ``cfg.corr`` (EWMA lookback).
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 +-----------------------+-------------------+--------------------------------+
256 See `shrink2id` for the full theoretical
257 background and academic references (Ledoit & Wolf, 2004; Chen et al., 2010).
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.
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.
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.
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.
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
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 $$
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)$.
315 ``covariance_config``
316 Pass a `SlidingWindowConfig` instance to enable this mode.
317 The required sub-parameters are:
319 ``window``
320 Rolling window length $W \\geq 1$. Rule of thumb: $W
321 \\geq 2n$ keeps the sample covariance well-posed before truncation.
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.
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 """
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 )
433 model_config = {"frozen": True, "extra": "forbid"}
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.
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.
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
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.
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.
496 All parameters default to ``None``, meaning *keep the existing value*.
497 Pass a non-``None`` value for every field you want to change.
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.
514 Returns:
515 A new `BasanosConfig` with the specified fields replaced and
516 all other fields copied from ``self``.
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 )
547 @property
548 def covariance_mode(self) -> CovarianceMode:
549 """Covariance mode derived from `covariance_config`."""
550 return self.covariance_config.covariance_mode
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
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
566 @property
567 def report(self) -> "ConfigReport":
568 """Return a `ConfigReport` facade for this config.
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.
574 To also include a lambda-sweep chart (Sharpe vs λ), use
575 `config_report` instead, which requires price and
576 signal data.
578 Returns:
579 basanos.math._config_report.ConfigReport: Report facade with
580 ``to_html()`` and ``save()`` methods.
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
592 return ConfigReport(config=self)
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.
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