Skip to content

SSVI Volatility Surface

quantflow.options.ssvi.SSVI pydantic-model

Bases: BaseModel

eSSVI (extended Surface SVI) parametrisation of the implied volatility surface.

The SSVI surface of Gatheral and Jacquier (2014) is extended with a maturity dependent correlation, following Hendriks and Martini (2019). Each maturity slice is described by three parameters stored at the node maturities:

  • the total ATM variance \(\theta_\tau\)
  • the curvature \(\psi_\tau\)
  • the correlation \(\rho_\tau\)

The total implied variance \(w(k) = \sigma^2(k) \cdot \tau\) at log-strike \(k = \log(K/F)\) is

\[\begin{equation} w(k, \tau) = \frac{\theta_\tau}{2}\left[1 + \rho_\tau \varphi_\tau k + \sqrt{\left(\varphi_\tau k + \rho_\tau\right)^2 + 1 - \rho_\tau^2}\right] \end{equation}\]

where the shape function is the ratio of curvature and total ATM variance:

\[\begin{equation} \varphi_\tau = \frac{\psi_\tau}{\theta_\tau} \end{equation}\]

The parameters have direct smile interpretations. The total ATM variance sets the level of the slice, \(w(0, \tau) = \theta_\tau\). The curvature and correlation set the slope and convexity of the total variance at the money:

\[\begin{equation} \begin{aligned} \left.\frac{\partial w}{\partial k}\right|_{k=0} &= \rho_\tau \psi_\tau \\ \left.\frac{\partial^2 w}{\partial k^2}\right|_{k=0} &= \frac{\psi_\tau^2 \left(1 - \rho_\tau^2\right)}{2 \theta_\tau} \end{aligned} \end{equation}\]

The sign of the correlation therefore tilts the smile (negative for the left skew typical of equities) while the curvature scales both the tilt and the bend around the money.

In the wings the total variance grows linearly, \(w \to \frac{\psi_\tau (1 \pm \rho_\tau)}{2} |k|\) as \(k \to \pm\infty\), so the curvature also sets the wing slopes. Lee's moment formula caps these slopes at 2, which is exactly the first butterfly condition \(\psi_\tau (1 + |\rho_\tau|) < 4\) of no_butterfly_arbitrage.

The shape function acts as a moneyness rescaling: the log-strike enters the formula only through the product \(\varphi_\tau k\), so \(\varphi_\tau\) measures how far a strike is from the money relative to the width of the smile at that maturity. Short maturities combine a small \(\theta\) with a large \(\varphi\) (the whole smile lives within a few percent of the forward), long maturities the opposite.

Absence of calendar spread arbitrage requires both \(\theta\) and \(\psi\) to be non decreasing in maturity, and the nodes are validated accordingly. Between nodes, the quantities \(\theta\), \(\psi\) and \(\rho \psi\) are interpolated linearly, the natural interpolation of Corbetta et al. (2019), which preserves the absence of static arbitrage of the interpolated slices. Outside the node range the surface extrapolates flat.

Use fit_surface to calibrate the slice parameters with the star calibration algorithm of the same paper, which enforces the absence of butterfly and calendar spread arbitrage by construction.

Fields:

ttm pydantic-field

ttm

Times to maturity in years, strictly increasing

theta pydantic-field

theta

Total ATM variances at each maturity, same length as ttm, positive and non decreasing

psi pydantic-field

psi

Curvatures \(\psi_i = \theta_i \varphi_i\) at each maturity, same length as ttm, positive and non decreasing

rho pydantic-field

rho

Correlations at each maturity, same length as ttm, each strictly inside the interval (-1, 1)

atm_variance

atm_variance(ttm)

Interpolated total ATM variance \(\theta(\tau)\).

PARAMETER DESCRIPTION
ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def atm_variance(
    self,
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Interpolated total ATM variance $\theta(\tau)$."""
    return maybe_float(self._interp(ttm, self._theta))

curvature

curvature(ttm)

Interpolated curvature \(\psi(\tau)\).

PARAMETER DESCRIPTION
ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def curvature(
    self,
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Interpolated curvature $\psi(\tau)$."""
    return maybe_float(self._interp(ttm, self._psi))

correlation

correlation(ttm)

Interpolated correlation \(\rho(\tau)\).

The interpolation is linear in \(\rho \psi\) and \(\psi\), so the correlation is their ratio rather than a direct linear interpolation.

PARAMETER DESCRIPTION
ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def correlation(
    self,
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Interpolated correlation $\rho(\tau)$.

    The interpolation is linear in $\rho \psi$ and $\psi$, so the
    correlation is their ratio rather than a direct linear interpolation.
    """
    rho_psi = self._interp(ttm, self._rho * self._psi)
    psi = self._interp(ttm, self._psi)
    return maybe_float(rho_psi / psi)

phi

phi(ttm)

Shape function \(\varphi_\tau = \psi_\tau / \theta_\tau\).

PARAMETER DESCRIPTION
ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def phi(
    self,
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Shape function $\varphi_\tau = \psi_\tau / \theta_\tau$."""
    return maybe_float(
        self._interp(ttm, self._psi) / self._interp(ttm, self._theta)
    )

total_variance

total_variance(k, ttm)

Total implied variance \(w(k, \tau)\).

Returns an array broadcast from the shapes of \(k\) and \(\tau\).

PARAMETER DESCRIPTION
k

Log-moneyness log(K/F), scalar or array

TYPE: ArrayLike

ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def total_variance(
    self,
    k: Annotated[ArrayLike, Doc("Log-moneyness log(K/F), scalar or array")],
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Total implied variance $w(k, \tau)$.

    Returns an array broadcast from the shapes of $k$ and $\tau$.
    """
    k_arr = np.asarray(k, dtype=float)
    theta = self._interp(ttm, self._theta)
    rho = np.asarray(self.correlation(ttm), dtype=float)
    pk = self._interp(ttm, self._psi) / theta * k_arr
    w = 0.5 * theta * (1 + rho * pk + np.sqrt((pk + rho) ** 2 + 1 - rho**2))
    return maybe_float(np.asarray(w, dtype=float))

iv

iv(k, ttm)

Implied volatility \(\sigma(k, \tau) = \sqrt{w(k, \tau) / \tau}\).

Returns an array of the same shape as \(k\). The eSSVI total variance is strictly positive for \(|\rho| < 1\), so no clipping is required.

PARAMETER DESCRIPTION
k

Log-moneyness log(K/F), scalar or array

TYPE: ArrayLike

ttm

Time to maturity in years, scalar or array

TYPE: ArrayLike

Source code in quantflow/options/ssvi.py
def iv(
    self,
    k: Annotated[ArrayLike, Doc("Log-moneyness log(K/F), scalar or array")],
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years, scalar or array")],
) -> FloatArrayLike:
    r"""Implied volatility $\sigma(k, \tau) = \sqrt{w(k, \tau) / \tau}$.

    Returns an array of the same shape as $k$. The eSSVI total variance is
    strictly positive for $|\rho| < 1$, so no clipping is required.
    """
    tau = np.asarray(ttm, dtype=float)
    return maybe_float(np.asarray(np.sqrt(self.total_variance(k, ttm) / tau)))

no_butterfly_arbitrage

no_butterfly_arbitrage(ttm=None)

True if the slice satisfies the sufficient conditions for absence of butterfly arbitrage.

The conditions, from Theorem 4.2 of Gatheral and Jacquier (2014), expressed in terms of the curvature \(\psi = \theta \varphi\), are:

\[\begin{equation} \begin{aligned} \psi (1 + |\rho_\tau|) &< 4 \\ \frac{\psi^2}{\theta} (1 + |\rho_\tau|) &\leq 4 \end{aligned} \end{equation}\]
PARAMETER DESCRIPTION
ttm

Optional maturity to check. All nodes are checked when omitted

TYPE: float | None DEFAULT: None

Source code in quantflow/options/ssvi.py
def no_butterfly_arbitrage(
    self,
    ttm: Annotated[
        float | None,
        Doc("Optional maturity to check. All nodes are checked when omitted"),
    ] = None,
) -> bool:
    r"""True if the slice satisfies the sufficient conditions for absence
    of butterfly arbitrage.

    The conditions, from Theorem 4.2 of
    [Gatheral and Jacquier (2014)](../../bibliography.md#gatheral_jacquier),
    expressed in terms of the curvature $\psi = \theta \varphi$, are:

    \begin{equation}
    \begin{aligned}
        \psi (1 + |\rho_\tau|) &< 4 \\
        \frac{\psi^2}{\theta} (1 + |\rho_\tau|) &\leq 4
    \end{aligned}
    \end{equation}
    """
    ttms = [ttm] if ttm is not None else [float(t) for t in self.ttm]
    for tau in ttms:
        theta = float(self.atm_variance(tau))
        psi = float(self.curvature(tau))
        rho = abs(float(self.correlation(tau)))
        if not (psi * (1 + rho) < 4 and psi * psi * (1 + rho) / theta <= 4):
            return False
    return True

no_calendar_arbitrage

no_calendar_arbitrage()

True if the surface nodes satisfy the conditions for absence of calendar spread arbitrage.

The conditions, necessary and sufficient from Hendriks and Martini (2019), require \(\theta\) and \(\psi\) non decreasing (enforced by the node validation) together with, for consecutive maturities \(\tau_1 < \tau_2\):

\[\begin{equation} \left|\rho_2 \psi_2 - \rho_1 \psi_1\right| \leq \psi_2 - \psi_1 \end{equation}\]

Because \(\theta\), \(\psi\) and \(\rho \psi\) interpolate linearly, node conditions extend to the whole interpolated surface (Section 5 of Corbetta et al. (2019)).

Source code in quantflow/options/ssvi.py
def no_calendar_arbitrage(self) -> bool:
    r"""True if the surface nodes satisfy the conditions for absence of
    calendar spread arbitrage.

    The conditions, necessary and sufficient from
    [Hendriks and Martini (2019)](../../bibliography.md#hendriks_martini),
    require $\theta$ and $\psi$ non decreasing (enforced by the node
    validation) together with, for consecutive maturities
    $\tau_1 < \tau_2$:

    \begin{equation}
        \left|\rho_2 \psi_2 - \rho_1 \psi_1\right| \leq \psi_2 - \psi_1
    \end{equation}

    Because $\theta$, $\psi$ and $\rho \psi$ interpolate linearly, node
    conditions extend to the whole interpolated surface (Section 5 of
    [Corbetta et al. (2019)](../../bibliography.md#ssvi_calibration)).
    """
    if self._psi.size < 2:
        return True
    psi = self._psi
    rho_psi = self._rho * self._psi
    return bool(np.all(np.abs(np.diff(rho_psi)) <= np.diff(psi) + 1e-9))

no_static_arbitrage

no_static_arbitrage()

True if the surface satisfies the implemented static checks.

Source code in quantflow/options/ssvi.py
def no_static_arbitrage(self) -> bool:
    """True if the surface satisfies the implemented static checks."""
    return self.no_butterfly_arbitrage() and self.no_calendar_arbitrage()

fit_surface classmethod

fit_surface(slices, weight_decay=0.5)

Calibrate the eSSVI surface slice by slice with the star calibration algorithm of Corbetta et al. (2019).

Slices are sorted by maturity and calibrated going forward. The star calibration provides the starting point of each slice: the slice is anchored to the quote closest to the ATM forward, which eliminates the total ATM variance analytically (\(\theta = \theta^* - \rho \psi k^*\)), and the remaining pair \((\rho, \psi)\) is found by sampling the correlation on a progressively refined grid and, for each sample, minimising over the curvature with a bounded one dimensional search. The curvature bounds enforce the butterfly conditions and the calendar conditions with respect to the previously calibrated slice.

The three parameters are then refined jointly with a direct search that releases the ATM anchor, letting all quotes set the level of the slice, while rejecting any candidate that violates the butterfly or calendar conditions, so the fitted surface remains free of static arbitrage by construction.

The objective is the mean absolute implied variance error with an exponential weight per quote:

\[\begin{equation} e^{-\lambda |d|} \quad \text{with} \quad d = \frac{k + \theta^* / 2}{\sqrt{\theta^*}} \end{equation}\]

where \(\lambda\) is the weight_decay parameter and \(d\) is the convexity adjusted moneyness evaluated at the ATM total variance \(\theta^*\).

The weight is centered at \(d = 0\), the median of the risk-neutral distribution, and concentrates the fit around the money where quotes are most reliable, while its slow exponential decay keeps far from the money quotes contributing to the fit.

The error is measured in implied variance rather than implied volatility, which gives the smile wings, where the variance is largest, a stronger pull on the fit.

PARAMETER DESCRIPTION
slices

One (log-moneyness, implied volatilities, time to maturity) tuple per maturity slice

TYPE: Sequence[tuple[ArrayLike, ArrayLike, float]]

weight_decay

Exponential decay rate of the quote weights with distance from the money, measured by the convexity adjusted moneyness. 0, the default, weights all quotes equally, larger values concentrate the fit at the money. Must be non negative

TYPE: float DEFAULT: 0.5

Source code in quantflow/options/ssvi.py
@classmethod
def fit_surface(
    cls,
    slices: Annotated[
        Sequence[tuple[ArrayLike, ArrayLike, float]],
        Doc(
            "One (log-moneyness, implied volatilities, time to maturity) "
            "tuple per maturity slice"
        ),
    ],
    weight_decay: Annotated[
        float,
        Doc(
            "Exponential decay rate of the quote weights with distance "
            "from the money, measured by the convexity adjusted "
            "moneyness. 0, the default, weights all quotes equally, "
            "larger values concentrate the fit at the money. "
            "Must be non negative"
        ),
    ] = 0.5,
) -> Self:
    r"""Calibrate the eSSVI surface slice by slice with the star
    calibration algorithm of
    [Corbetta et al. (2019)](../../bibliography.md#ssvi_calibration).

    Slices are sorted by maturity and calibrated going forward. The star
    calibration provides the starting point of each slice: the slice is
    anchored to the quote closest to the ATM forward, which eliminates
    the total ATM variance analytically ($\theta = \theta^* - \rho \psi
    k^*$), and the remaining pair $(\rho, \psi)$ is found by sampling the
    correlation on a progressively refined grid and, for each sample,
    minimising over the curvature with a bounded one dimensional search.
    The curvature bounds enforce the butterfly conditions and the
    calendar conditions with respect to the previously calibrated slice.

    The three parameters are then refined jointly with a direct search
    that releases the ATM anchor, letting all quotes set the level of
    the slice, while rejecting any candidate that violates the butterfly
    or calendar conditions, so the fitted surface remains free of static
    arbitrage by construction.

    The objective is the mean absolute implied variance error with an
    exponential weight per quote:

    \begin{equation}
        e^{-\lambda |d|} \quad \text{with} \quad
        d = \frac{k + \theta^* / 2}{\sqrt{\theta^*}}
    \end{equation}

    where $\lambda$ is the weight_decay parameter and $d$ is the
    [convexity adjusted moneyness](../../glossary.md#moneyness-convexity-adjusted)
    evaluated at the ATM total variance $\theta^*$.

    The weight is centered at $d = 0$, the median of the risk-neutral
    distribution, and concentrates the fit around the money where quotes
    are most reliable, while its slow exponential decay keeps far from
    the money quotes contributing to the fit.

    The error is measured in implied variance rather than implied
    volatility, which gives the smile wings, where the variance is
    largest, a stronger pull on the fit.
    """
    if not slices:
        raise ValueError("at least one maturity slice is required")
    data = []
    for k, iv, ttm in slices:
        k_arr = np.asarray(k, dtype=float)
        iv_arr = np.asarray(iv, dtype=float)
        if k_arr.size == 0 or iv_arr.size == 0:
            raise ValueError("k and iv must contain at least one quote")
        if k_arr.shape != iv_arr.shape:
            raise ValueError("k and iv must have the same shape")
        data.append((k_arr, iv_arr, float(ttm)))
    data.sort(key=lambda item: item[2])
    ttms = []
    thetas = []
    psis = []
    rhos = []
    prev: tuple[float, float, float] | None = None
    for k_arr, iv_arr, ttm in data:
        theta, psi, rho = _fit_slice(k_arr, iv_arr, ttm, prev, weight_decay)
        if prev is not None:
            # guard the non decreasing validation against rounding noise
            theta = max(theta, prev[0])
            psi = max(psi, prev[1])
        ttms.append(ttm)
        thetas.append(theta)
        psis.append(psi)
        rhos.append(rho)
        prev = (theta, psi, rho * psi)
    return cls(
        ttm=[to_decimal(round(value, 10)) for value in ttms],
        theta=[to_decimal(round(value, 10)) for value in thetas],
        psi=[to_decimal(round(value, 10)) for value in psis],
        rho=[to_decimal(round(value, 10)) for value in rhos],
    )

fit_vol_surface classmethod

fit_vol_surface(surface, weight_decay=0.0)

Fit an eSSVI model to a volatility surface.

PARAMETER DESCRIPTION
surface

Volatility surface with calculated implied volatilities and converged options

TYPE: VolSurface[Any]

weight_decay

Exponential decay rate of the quote weights with distance from the money, see fit_surface

TYPE: float DEFAULT: 0.0

Source code in quantflow/options/ssvi.py
@classmethod
def fit_vol_surface(
    cls,
    surface: Annotated[
        VolSurface[Any],
        Doc(
            "Volatility surface with calculated implied volatilities and "
            "converged options"
        ),
    ],
    weight_decay: Annotated[
        float,
        Doc(
            "Exponential decay rate of the quote weights with distance "
            "from the money, see [fit_surface][..fit_surface]"
        ),
    ] = 0.0,
) -> Self:
    """Fit an eSSVI model to a volatility surface."""
    slices = []
    for index, maturity in enumerate(surface.maturities):
        options = list(surface.option_prices(index=index, converged=True))
        if not options:
            continue
        log_strike = np.array([option.log_strike for option in options])
        order = np.argsort(log_strike)
        slices.append(
            (
                log_strike[order],
                np.array([option.iv for option in options])[order],
                maturity.ttm(surface.ref_date),
            )
        )
    return cls.fit_surface(slices, weight_decay=weight_decay)