Coverage for src/basanos/math/_config.py: 100%
64 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-25 12:05 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-25 12:05 +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. The covariance-mode configs live in ``_covariance_config.py`` and
6are re-exported here.
7"""
9import logging
10from typing import TypeVar
12from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
14from ._config_report import ConfigReport
15from ._covariance_config import CovarianceConfig as CovarianceConfig
16from ._covariance_config import CovarianceMode as CovarianceMode
17from ._covariance_config import EwmaShrinkConfig as EwmaShrinkConfig
18from ._covariance_config import SlidingWindowConfig as SlidingWindowConfig
20_logger = logging.getLogger(__name__)
23# Sentinel used in BasanosConfig.replace() to distinguish "not provided"
24# (keep existing value) from "explicitly set to None" (clear the field).
25class _SentinelType:
26 """Sentinel type for BasanosConfig.replace() to distinguish 'not provided' from None."""
29_SENTINEL = _SentinelType()
31_T = TypeVar("_T")
34def _coalesce(override: _T | None, current: _T) -> _T:
35 """Return *override* when it is not ``None``, otherwise *current*.
37 Helper for `BasanosConfig.replace`: a ``None`` override means
38 "keep the existing value". Factoring the per-field ``x if x is None
39 else y`` choice into a named call keeps `replace` flat rather than a
40 long ternary chain.
41 """
42 return current if override is None else override
45class BasanosConfig(BaseModel):
46 r"""Configuration for correlation-aware position optimization.
48 The required parameters (``vola``, ``corr``, ``clip``, ``shrink``, ``aum``)
49 must be supplied by the caller. The optional parameters carry
50 carefully chosen defaults whose rationale is described below.
52 Shrinkage methodology
53 ---------------------
54 ``shrink`` controls linear shrinkage of the EWMA correlation matrix toward
55 the identity:
57 $$
58 C_{\\text{shrunk}} = \\lambda \\cdot C_{\\text{EWMA}} + (1 - \\lambda) \\cdot I_n
59 $$
61 where $\\lambda$ = ``shrink`` and $I_n$ is the identity.
62 Shrinkage regularises the matrix when assets are few relative to the
63 lookback (high concentration ratio $n / T$), reducing the impact of
64 extreme sample eigenvalues and improving the condition number of the matrix
65 passed to the linear solver.
67 **When to prefer strong shrinkage (low** ``shrink`` **/ high** ``1-shrink``\\ **):**
69 * Fewer than ~30 assets with a ``corr`` lookback shorter than 100 days.
70 * High-volatility or crisis regimes where correlations spike and the sample
71 matrix is less representative of the true structure.
72 * Portfolios where estimation noise is more costly than correlation bias
73 (e.g., when the signal-to-noise ratio of ``mu`` is low).
75 **When to prefer light shrinkage (high** ``shrink``\\ **):**
77 * Many assets with a long lookback (low concentration ratio).
78 * The EWMA correlation structure carries genuine diversification information
79 that you want the solver to exploit.
80 * Out-of-sample testing shows that position stability is not a concern.
82 **Practical starting points (daily return data):**
84 Here *n* = number of assets and *T* = ``cfg.corr`` (EWMA lookback).
86 +-----------------------+-------------------+--------------------------------+
87 | n (assets) / T (corr) | Suggested shrink | Notes |
88 +=======================+===================+================================+
89 | n > 20, T < 40 | 0.3 - 0.5 | Near-singular matrix likely; |
90 | | | strong regularisation needed. |
91 +-----------------------+-------------------+--------------------------------+
92 | n ~ 10, T ~ 60 | 0.5 - 0.7 | Balanced regime. |
93 +-----------------------+-------------------+--------------------------------+
94 | n < 10, T > 100 | 0.7 - 0.9 | Well-conditioned sample; |
95 | | | light shrinkage for stability. |
96 +-----------------------+-------------------+--------------------------------+
98 See `shrink2id` for the full theoretical
99 background and academic references (Ledoit & Wolf, 2004; Chen et al., 2010).
101 Default rationale
102 -----------------
103 ``denom_tol = 1e-12``
104 Positions are zeroed when the normalisation denominator
105 ``inv_a_norm(μ, Σ)`` falls at or below this threshold. The
106 value 1e-12 provides ample headroom above float64 machine
107 epsilon (~2.2e-16) while remaining negligible relative to any
108 economically meaningful signal magnitude.
110 ``position_scale = 1e6``
111 The dimensionless risk position is multiplied by this factor
112 before being passed to `Portfolio`.
113 A value of 1e6 means positions are expressed in units of one
114 million of the base currency, a conventional denomination for
115 institutional-scale portfolios where AUM is measured in hundreds
116 of millions.
118 ``min_corr_denom = 1e-14``
119 The EWMA correlation denominator ``sqrt(var_x * var_y)`` is
120 compared against this threshold; when at or below it the
121 correlation is set to NaN rather than dividing by a near-zero
122 value. The default 1e-14 is safely above float64 underflow
123 while remaining negligible for any realistic return series.
124 Advanced users may tighten this guard (larger value) when
125 working with very-low-variance synthetic data.
127 ``max_nan_fraction = 0.9``
128 `ExcessiveNullsError` is raised
129 during construction when the null fraction in any asset price
130 column **strictly exceeds** this threshold. The default 0.9
131 permits up to 90 % missing prices (e.g., illiquid or recently
132 listed assets in a long history) while rejecting columns that
133 are almost entirely null and would contribute no useful
134 information. Callers who want a stricter gate can lower this
135 value; callers running on sparse data can raise it toward 1.0.
137 Sliding-window mode
138 -------------------
139 When ``covariance_config`` is a `SlidingWindowConfig`, the EWMA
140 correlation estimator is replaced by a rolling-window factor model
141 (Section 4.4 of basanos.pdf). At each timestamp *t* the
142 $W \\times n$ submatrix of the $W$ most recent
143 volatility-adjusted returns is decomposed via truncated SVD to extract
144 $k$ latent factors. The resulting correlation estimate is
146 $$
147 \\hat{C}_t^{(W,k)}
148 = \\frac{1}{W}\\mathbf{V}_{k,t}\\mathbf{\\Sigma}_{k,t}^2
149 \\mathbf{V}_{k,t}^\\top + \\hat{D}_t
150 $$
152 where $\\hat{D}_t$ enforces unit diagonal. The linear system
153 $\\hat{C}_t^{(W,k)}\\mathbf{x}_t = \\boldsymbol{\\mu}_t$ is solved
154 via the Woodbury identity (`solve`)
155 at cost $O(k^3 + kn)$ per step rather than $O(n^3)$.
157 ``covariance_config``
158 Pass a `SlidingWindowConfig` instance to enable this mode.
159 The required sub-parameters are:
161 ``window``
162 Rolling window length $W \\geq 1$. Rule of thumb: $W
163 \\geq 2n$ keeps the sample covariance well-posed before truncation.
165 ``n_factors``
166 Number of latent factors $k \\geq 1$. $k = 1$
167 recovers the single market-factor model; larger $k$ captures
168 finer correlation structure at the cost of higher estimation noise.
170 Examples:
171 >>> cfg = BasanosConfig(vola=32, corr=64, clip=3.0, shrink=0.5, aum=1e8)
172 >>> cfg.vola
173 32
174 >>> cfg.corr
175 64
176 >>> sw_cfg = BasanosConfig(
177 ... vola=16, corr=32, clip=3.0, shrink=0.5, aum=1e6,
178 ... covariance_config=SlidingWindowConfig(window=60, n_factors=3),
179 ... )
180 >>> sw_cfg.covariance_mode
181 <CovarianceMode.sliding_window: 'sliding_window'>
182 """
184 vola: int = Field(..., gt=0, description="EWMA lookback for volatility normalization.")
185 corr: int = Field(..., gt=0, description="EWMA lookback for correlation estimation.")
186 clip: float = Field(..., gt=0.0, description="Clipping threshold for volatility adjustment.")
187 shrink: float = Field(
188 ...,
189 ge=0.0,
190 le=1.0,
191 description=(
192 "Retention weight λ for linear shrinkage of the EWMA correlation matrix toward "
193 "the identity: C_shrunk = λ·C_ewma + (1-λ)·I. "
194 "λ=1.0 uses the raw EWMA matrix (no shrinkage); λ=0.0 replaces it entirely "
195 "with the identity (maximum shrinkage, positions are treated as uncorrelated). "
196 "Values in [0.3, 0.8] are typical for daily financial return data. "
197 "Lower values improve numerical stability when assets are many relative to the "
198 "lookback (high concentration ratio n/T). See shrink2id() for full guidance. "
199 "Only used when covariance_mode='ewma_shrink'."
200 ),
201 )
202 aum: float = Field(..., gt=0.0, description="Assets under management for portfolio scaling.")
203 denom_tol: float = Field(
204 default=1e-12,
205 gt=0.0,
206 description=(
207 "Minimum normalisation denominator; positions are zeroed at or below this value. "
208 "The default 1e-12 is well above float64 machine epsilon (~2.2e-16) while "
209 "remaining negligible for any economically meaningful signal."
210 ),
211 )
212 position_scale: float = Field(
213 default=1e6,
214 gt=0.0,
215 description=(
216 "Multiplicative scaling factor applied to dimensionless risk positions to obtain "
217 "cash positions in base-currency units. Defaults to 1e6 (one million), a "
218 "conventional denomination for institutional portfolios."
219 ),
220 )
221 min_corr_denom: float = Field(
222 default=1e-14,
223 gt=0.0,
224 description=(
225 "Guard threshold for the EWMA correlation denominator sqrt(var_x * var_y). "
226 "When the denominator is at or below this value the correlation is set to NaN "
227 "instead of dividing by a near-zero number. "
228 "The default 1e-14 is safely above float64 underflow while being negligible for "
229 "any realistic return variance."
230 ),
231 )
232 max_nan_fraction: float = Field(
233 default=0.9,
234 gt=0.0,
235 lt=1.0,
236 description=(
237 "Maximum tolerated fraction of null values in any asset price column. "
238 "ExcessiveNullsError is raised during construction when the null fraction "
239 "strictly exceeds this threshold. "
240 "The default 0.9 allows up to 90 % missing prices while rejecting columns "
241 "that are almost entirely null."
242 ),
243 )
244 covariance_config: CovarianceConfig = Field(
245 default_factory=EwmaShrinkConfig,
246 description=(
247 "Covariance estimation configuration. "
248 "Pass EwmaShrinkConfig() (default) for EWMA correlation with linear shrinkage "
249 "toward the identity, or SlidingWindowConfig(window=W, n_factors=k) for a "
250 "rolling-window factor model. See Section 4.4 of basanos.pdf."
251 ),
252 )
253 cost_per_unit: float = Field(
254 default=0.0,
255 ge=0.0,
256 description=(
257 "One-way trading cost per unit of position change. "
258 "At each period, the cost deduction is sum(|x_t - x_{t-1}|) * cost_per_unit "
259 "where x_t is the cash position vector. Defaults to 0.0 (no cost). "
260 "The resulting net-of-cost NAV is exposed via Portfolio.net_cost_nav."
261 ),
262 )
263 max_turnover: float | None = Field(
264 default=None,
265 gt=0.0,
266 description=(
267 "Optional turnover budget per period in cash-position units. "
268 "When set, the L1 norm of position changes sum(|x_t - x_{t-1}|) is capped "
269 "at this value at every solve step by proportionally scaling the position "
270 "delta toward the previous position. Must be strictly positive when provided. "
271 "Defaults to None (no turnover constraint)."
272 ),
273 )
275 model_config = {"frozen": True, "extra": "forbid"}
277 @model_validator(mode="before")
278 @classmethod
279 def _reject_legacy_flat_kwargs(cls, data: dict[str, object]) -> dict[str, object]:
280 """Raise an informative TypeError when the pre-v0.4 flat kwargs are used.
282 Before v0.4 callers passed ``covariance_mode``, ``n_factors``, and
283 ``window`` as top-level keyword arguments to `BasanosConfig`.
284 Those fields were replaced by the nested discriminated union
285 ``covariance_config``. Without this validator Pydantic raises a
286 generic ``extra_forbidden`` error that gives no migration guidance.
288 Examples:
289 >>> BasanosConfig(
290 ... vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6,
291 ... covariance_mode="sliding_window", window=30, n_factors=2,
292 ... ) # doctest: +IGNORE_EXCEPTION_DETAIL
293 Traceback (most recent call last):
294 ...
295 TypeError: ...
296 """
297 legacy_keys = {"covariance_mode", "n_factors", "window"}
298 found = legacy_keys & data.keys()
299 if found:
300 found_str = ", ".join(f"'{k}'" for k in sorted(found))
301 msg = (
302 f"BasanosConfig received legacy keyword argument(s): {found_str}. "
303 "These flat fields were removed in v0.4. "
304 "Migrate to the nested covariance_config API:\n\n"
305 " # Before (v0.3 and earlier):\n"
306 " BasanosConfig(..., covariance_mode='sliding_window', window=30, n_factors=2)\n\n"
307 " # After (v0.4+):\n"
308 " from basanos.math import SlidingWindowConfig\n"
309 " BasanosConfig(..., covariance_config=SlidingWindowConfig(window=30, n_factors=2))\n\n"
310 "For the default EWMA-shrink mode no covariance_config argument is needed."
311 )
312 raise TypeError(msg)
313 return data
315 def replace(
316 self,
317 *,
318 vola: int | None = None,
319 corr: int | None = None,
320 clip: float | None = None,
321 shrink: float | None = None,
322 aum: float | None = None,
323 denom_tol: float | None = None,
324 position_scale: float | None = None,
325 min_corr_denom: float | None = None,
326 max_nan_fraction: float | None = None,
327 covariance_config: "CovarianceConfig | None" = None,
328 cost_per_unit: float | None = None,
329 max_turnover: float | _SentinelType | None = _SENTINEL,
330 ) -> "BasanosConfig":
331 """Return a new `BasanosConfig` with selected fields replaced.
333 Unlike `model_copy`, this method uses explicit constructor kwarg
334 forwarding so that any new required field added to
335 `BasanosConfig` surfaces immediately as a type or lint error at
336 the call site, rather than silently failing at runtime.
338 All parameters default to ``None``, meaning *keep the existing value*.
339 Pass a non-``None`` value for every field you want to change.
341 Args:
342 vola: EWMA lookback for volatility normalisation.
343 corr: EWMA lookback for correlation estimation.
344 clip: Clipping threshold for volatility adjustment.
345 shrink: Retention weight λ ∈ [0, 1] for linear shrinkage.
346 aum: Assets under management for portfolio scaling.
347 denom_tol: Minimum normalisation denominator.
348 position_scale: Multiplicative scaling factor for cash positions.
349 min_corr_denom: Guard threshold for the EWMA correlation denominator.
350 max_nan_fraction: Maximum tolerated null fraction per price column.
351 covariance_config: Covariance estimation configuration.
352 cost_per_unit: One-way trading cost per unit of position change.
353 max_turnover: Optional turnover budget per period in cash-position
354 units. Pass ``None`` explicitly to clear an existing budget.
356 Returns:
357 A new `BasanosConfig` with the specified fields replaced and
358 all other fields copied from ``self``.
360 Examples:
361 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
362 >>> cfg2 = cfg.replace(shrink=0.8)
363 >>> cfg2.shrink
364 0.8
365 >>> cfg2.vola == cfg.vola
366 True
367 >>> cfg3 = cfg.replace(cost_per_unit=0.001, max_turnover=1e5)
368 >>> cfg3.cost_per_unit
369 0.001
370 >>> cfg3.max_turnover
371 100000.0
372 """
373 new_max_turnover: float | None = self.max_turnover if isinstance(max_turnover, _SentinelType) else max_turnover
374 return BasanosConfig(
375 vola=_coalesce(vola, self.vola),
376 corr=_coalesce(corr, self.corr),
377 clip=_coalesce(clip, self.clip),
378 shrink=_coalesce(shrink, self.shrink),
379 aum=_coalesce(aum, self.aum),
380 denom_tol=_coalesce(denom_tol, self.denom_tol),
381 position_scale=_coalesce(position_scale, self.position_scale),
382 min_corr_denom=_coalesce(min_corr_denom, self.min_corr_denom),
383 max_nan_fraction=_coalesce(max_nan_fraction, self.max_nan_fraction),
384 covariance_config=_coalesce(covariance_config, self.covariance_config),
385 cost_per_unit=_coalesce(cost_per_unit, self.cost_per_unit),
386 max_turnover=new_max_turnover,
387 )
389 @property
390 def covariance_mode(self) -> CovarianceMode:
391 """Covariance mode derived from `covariance_config`."""
392 return self.covariance_config.covariance_mode
394 @property
395 def window(self) -> int | None:
396 """Sliding window length, or ``None`` when not in ``sliding_window`` mode."""
397 if isinstance(self.covariance_config, SlidingWindowConfig):
398 return self.covariance_config.window
399 return None
401 @property
402 def n_factors(self) -> int | None:
403 """Number of latent factors, or ``None`` when not in ``sliding_window`` mode."""
404 if isinstance(self.covariance_config, SlidingWindowConfig):
405 return self.covariance_config.n_factors
406 return None
408 @property
409 def report(self) -> ConfigReport:
410 """Return a `ConfigReport` facade for this config.
412 Generates a self-contained HTML report summarising all configuration
413 parameters, a shrinkage-guidance table, and a theory section on
414 Ledoit-Wolf shrinkage.
416 To also include a lambda-sweep chart (Sharpe vs λ), use
417 `config_report` instead, which requires price and
418 signal data.
420 Returns:
421 basanos.math._config_report.ConfigReport: Report facade with
422 ``to_html()`` and ``save()`` methods.
424 Examples:
425 >>> from basanos.math import BasanosConfig
426 >>> cfg = BasanosConfig(vola=10, corr=20, clip=3.0, shrink=0.5, aum=1e6)
427 >>> report = cfg.report
428 >>> html = report.to_html()
429 >>> "Parameters" in html
430 True
431 """
432 return ConfigReport(config=self)
434 @field_validator("corr")
435 @classmethod
436 def corr_greater_than_vola(cls, v: int, info: ValidationInfo) -> int:
437 """Optionally enforce corr ≥ vola for stability.
439 Pydantic v2 passes ValidationInfo; use info.data to access other fields.
440 """
441 vola = info.data.get("vola") if hasattr(info, "data") else None
442 if vola is not None and v < vola:
443 raise ValueError
444 return v