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

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. The covariance-mode configs live in ``_covariance_config.py`` and 

6are re-exported here. 

7""" 

8 

9import logging 

10from typing import TypeVar 

11 

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

13 

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 

19 

20_logger = logging.getLogger(__name__) 

21 

22 

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.""" 

27 

28 

29_SENTINEL = _SentinelType() 

30 

31_T = TypeVar("_T") 

32 

33 

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

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

36 

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 

43 

44 

45class BasanosConfig(BaseModel): 

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

47 

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. 

51 

52 Shrinkage methodology 

53 --------------------- 

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

55 the identity: 

56 

57 $$ 

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

59 $$ 

60 

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. 

66 

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

68 

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). 

74 

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

76 

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. 

81 

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

83 

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

85 

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 +-----------------------+-------------------+--------------------------------+ 

97 

98 See `shrink2id` for the full theoretical 

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

100 

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. 

109 

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. 

117 

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. 

126 

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. 

136 

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 

145 

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 $$ 

151 

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)$. 

156 

157 ``covariance_config`` 

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

159 The required sub-parameters are: 

160 

161 ``window`` 

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

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

164 

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. 

169 

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 """ 

183 

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 ) 

274 

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

276 

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. 

281 

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. 

287 

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 

314 

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. 

332 

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. 

337 

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

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

340 

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. 

355 

356 Returns: 

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

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

359 

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 ) 

388 

389 @property 

390 def covariance_mode(self) -> CovarianceMode: 

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

392 return self.covariance_config.covariance_mode 

393 

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 

400 

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 

407 

408 @property 

409 def report(self) -> ConfigReport: 

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

411 

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. 

415 

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

417 `config_report` instead, which requires price and 

418 signal data. 

419 

420 Returns: 

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

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

423 

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) 

433 

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. 

438 

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