Coverage for src / qsmile / models / svi.py: 96%
47 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 22:47 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 22:47 +0000
1"""SVI (Stochastic Volatility Inspired) raw parameterisation."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import ClassVar
8import numpy as np
9from numpy.typing import ArrayLike, NDArray
11from qsmile.core.coords import XCoord, YCoord
12from qsmile.models.base import SmileModel
15@dataclass
16class SVIModel(SmileModel):
17 """Raw SVI parameterisation: model definition and fitted parameters.
19 The SVI raw parameterisation models total implied variance as:
21 w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2))
23 where k = ln(K/F) is log-moneyness.
25 Pass this class to ``fit()`` as the model, and receive instances
26 back as fitted parameters::
28 result = fit(sd, model=SVIModel)
29 result.params # → SVIModel instance
30 result.params.evaluate(k)
32 Parameters
33 ----------
34 a : float
35 Vertical translation (overall variance level).
36 b : float
37 Slope of the wings. Must be >= 0.
38 rho : float
39 Correlation / rotation. Must be in (-1, 1).
40 m : float
41 Horizontal translation (log-moneyness shift).
42 sigma : float
43 Curvature at the vertex. Must be > 0.
44 """
46 a: float
47 b: float
48 rho: float
49 m: float
50 sigma: float
52 # -- Class-level model metadata (excluded from dataclass fields) --
54 native_x_coord: ClassVar[XCoord] = XCoord.LogMoneynessStrike
55 native_y_coord: ClassVar[YCoord] = YCoord.TotalVariance
56 param_names: ClassVar[tuple[str, ...]] = ("a", "b", "rho", "m", "sigma")
57 bounds: ClassVar[tuple[list[float], list[float]]] = (
58 [-np.inf, 0.0, -0.999, -np.inf, 1e-8],
59 [np.inf, np.inf, 0.999, np.inf, np.inf],
60 )
62 def __post_init__(self) -> None:
63 """Validate SVI parameter constraints."""
64 super().__post_init__()
65 if self.b < 0:
66 msg = f"b must be non-negative, got {self.b}"
67 raise ValueError(msg)
68 if not (-1 < self.rho < 1):
69 msg = f"rho must be in (-1, 1), got {self.rho}"
70 raise ValueError(msg)
71 if self.sigma <= 0:
72 msg = f"sigma must be positive, got {self.sigma}"
73 raise ValueError(msg)
75 def _evaluate(self, x: ArrayLike) -> NDArray[np.float64] | np.float64:
76 """Compute SVI total variance at the given log-moneyness values.
78 w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2))
79 """
80 k = np.asarray(x, dtype=np.float64)
81 d = k - self.m
82 return self.a + self.b * (self.rho * d + np.sqrt(d**2 + self.sigma**2))
84 @staticmethod
85 def initial_guess(x: NDArray[np.float64], y: NDArray[np.float64]) -> NDArray[np.float64]:
86 """Compute a heuristic initial guess for SVI parameters from market data.
88 Parameters
89 ----------
90 x : NDArray[np.float64]
91 Log-moneyness values.
92 y : NDArray[np.float64]
93 Observed total variance values.
94 """
95 # a: ATM total variance (closest to k=0)
96 atm_idx = int(np.argmin(np.abs(x)))
97 a0 = float(y[atm_idx])
99 # Estimate slope and curvature from a quadratic fit: w ≈ c0 + c1*k + c2*k²
100 if len(x) >= 3:
101 coeffs = np.polyfit(x, y, 2)
102 c2, c1, _c0 = coeffs
103 b0 = max(abs(c1) + 2 * abs(c2), 0.01)
104 rho0 = np.clip(c1 / b0, -0.9, 0.9)
105 else:
106 b0 = max(float(np.std(y)) * 2, 0.01)
107 rho0 = 0.0
109 m0 = float(x[atm_idx])
110 sigma0 = max(float(np.std(x)) * 0.5, 0.01)
112 return np.array([a0, b0, rho0, m0, sigma0])