Calibration and Parameter Estimation in Models › Calibrating Implied Volatility Surfaces

Michael BrenndoerferDecember 13, 202510 min read
Quantitative FinanceAll chapters
Part 8 of 15Econometrics and Statistical Modeling
Chapter 45Calibration and Parameter Estimation in Models › Calibrating Implied Volatility Surfaces
Reading section 5 of 17 · Calibrating Implied Volatility Surfaces · 10 minSection 5 of 17 · 10 min

Book contents

Section 5 of 17 · 10 min read

Calibration and Parameter Estimation in Models › Calibrating Implied Volatility SurfacesOne section · Sections in this chapter5 / 17 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We will fit a SABR implied-volatility smile at one maturity to synthetic option quotes. This illustrates a slice of the wider surface-calibration problem, not calibration across maturities.

SABR is widely used for interest-rate option smiles. Its algebraic implied-volatility approximation enables fast calibration, with accuracy that must be checked for the intended strikes, maturities and parameters. Under an appropriate forward or annuity pricing measure, the model specifies:

dFt=σtFtβdWt1dσt=νσtdWt2\begin{aligned} dF_t &= \sigma_t F_t^\beta dW_t^1 \\ d\sigma_t &= \nu \sigma_t dW_t^2 \end{aligned}

where:

  • FtF_t: forward price at time tt
  • σt\sigma_t: stochastic volatility at time tt
  • α\alpha: initial volatility (σ0\sigma_0 in some notations), setting the overall level of the volatility smile
  • ν\nu: volatility of volatility (vol-vol), determining how much the volatility itself fluctuates
  • β\beta: elasticity parameter, controlling the relationship between the forward price and local volatility
  • dWt1,dWt2dW_t^1, dW_t^2: standard Brownian motions with correlation ρ\rho
  • ρ\rho: correlation coefficient between the forward price and volatility processes

The first equation has a constant-elasticity-of-variance backbone determined by β\beta. With β=1\beta=1 this is stochastic-lognormal SABR, and with β=0\beta=0 it is stochastic-normal SABR. The volatility coefficient remains random, so these backbone labels do not by themselves specify a normal or lognormal terminal distribution. The second equation drives its changes with vol-of-vol ν\nu.

The correlation ρ\rho relates signed forward-price and volatility innovations. Negative correlation tends to associate downward forward moves with increases in volatility, contributing to downside implied-volatility skew. It does not specify a deterministic relationship between volatility level and the magnitude of every price move.

Hagan et al. derived an asymptotic approximation for Black implied volatility as a function of strike KK and forward FF. The unshifted form below assumes positive forwards and strikes, α>0\alpha>0, ν≥0\nu\geq0, ∣ρ∣<1|\rho|<1, and T≥0T\geq0, with the ATM and zero-zz limits evaluated continuously. The initial coefficient α\alpha is not generally the Black implied volatility. Check approximation accuracy and no-arbitrage behavior, especially in the wings, at long maturities or for extreme parameters:

σimpl(K)≈α(FK)(1−β)/2[1+(1−β)224log⁡2FK+(1−β)41920log⁡4FK]⋅(zχ(z))⋅[1+T((1−β)2α224(FK)1−β+ρβνα4(FK)(1−β)/2+2−3ρ224ν2)]\begin{aligned} \sigma_{impl}(K) \approx \frac{\alpha}{(FK)^{(1-\beta)/2}\left[1 + \frac{(1-\beta)^2}{24}\log^2\frac{F}{K} + \frac{(1-\beta)^4}{1920}\log^4\frac{F}{K}\right]} & \cdot \left(\frac{z}{\chi(z)}\right) \\ & \cdot \left[1 + T\left(\frac{(1-\beta)^2\alpha^2}{24(FK)^{1-\beta}} + \frac{\rho\beta\nu\alpha}{4(FK)^{(1-\beta)/2}} + \frac{2-3\rho^2}{24}\nu^2\right)\right] \end{aligned}

where:

  • FF: current forward price
  • KK: strike price
  • TT: time to expiration
  • α\alpha: initial volatility level
  • β\beta: elasticity parameter
  • ρ\rho: correlation between Brownian motions
  • ν\nu: volatility of volatility
  • z=να(FK)(1−β)/2log⁡FKz = \frac{\nu}{\alpha}(FK)^{(1-\beta)/2}\log\frac{F}{K}: a vol-of-vol-scaled log-moneyness variable used in the approximation
  • χ(z)=log⁡(1−2ρz+z2+z−ρ1−ρ)\chi(z) = \log\left(\frac{\sqrt{1-2\rho z + z^2} + z - \rho}{1-\rho}\right): skew adjustment function that modifies implied volatility based on the correlation structure

The formula decomposes into three interpretable parts. The first fraction establishes the primary volatility level, scaled appropriately for the moneyness. The skew adjustment term zχ(z)\frac{z}{\chi(z)} modifies this level based on how correlation between price and volatility affects options at different strikes. The final bracket in the formula provides a time-dependent correction factor that accounts for how the smile dynamics evolve as we look further into the future.

This formula looks intimidating, but it's straightforward to implement. Let's calibrate SABR to synthetic option data.

In[5]:
Code
import numpy as np


def sabr_implied_vol(F, K, T, alpha, beta, rho, nu):
    """
    Scalar unshifted SABR Black-IV approximation (Hagan et al. 2002).
    """
    values = (F, K, T, alpha, beta, rho, nu)
    if not all(np.isscalar(x) and np.isfinite(x) for x in values):
        raise ValueError("SABR inputs must be finite scalars")
    if F <= 0 or K <= 0 or T < 0 or alpha <= 0 or nu < 0:
        raise ValueError("Require positive F, K, alpha and nonnegative T, nu")
    if not 0 <= beta <= 1 or abs(rho) >= 1:
        raise ValueError("Require beta in [0, 1] and rho in (-1, 1)")
    if abs(F - K) < 1e-10:
        # ATM approximation
        vol = (
            alpha
            / (F ** (1 - beta))
            * (
                1
                + T
                * (
                    ((1 - beta) ** 2 / 24) * alpha**2 / (F ** (2 - 2 * beta))
                    + 0.25 * rho * beta * nu * alpha / (F ** (1 - beta))
                    + (2 - 3 * rho**2) / 24 * nu**2
                )
            )
        )
        return vol

    FK = F * K
    log_FK = np.log(F / K)
    FK_mid = FK ** ((1 - beta) / 2)

    # z and the ratio z / chi(z)
    z = (nu / alpha) * FK_mid * log_FK

    # Handle small z
    if abs(z) < 1e-10:
        chi_z = 1
    else:
        sqrt_term = np.sqrt(1 - 2 * rho * z + z**2)
        chi_z = z / np.log((sqrt_term + z - rho) / (1 - rho))

    # Coefficient terms
    term1 = (
        1 + ((1 - beta) ** 2 / 24) * log_FK**2 + ((1 - beta) ** 4 / 1920) * log_FK**4
    )

    term2 = 1 + T * (
        ((1 - beta) ** 2 / 24) * alpha**2 / (FK ** (1 - beta))
        + 0.25 * rho * beta * nu * alpha / FK_mid
        + (2 - 3 * rho**2) / 24 * nu**2
    )

    vol = (alpha / (FK_mid * term1)) * chi_z * term2
    return vol

Now let's generate synthetic market data from known SABR parameters and add noise:

In[6]:
Code
# "True" market parameters (what we're trying to recover)
F_true = 100  # Forward price
sabr_maturity = 1.0  # Keep this example's one-year maturity separate from other models
alpha_true = 0.20
beta_true = 0.5  # We'll fix beta in calibration
rho_true = -0.30
nu_true = 0.40

# Generate strikes around ATM
strikes = np.array([80, 85, 90, 95, 100, 105, 110, 115, 120])

# Calculate "market" implied vols with some noise
np.random.seed(42)
market_vols = np.array(
    [
        sabr_implied_vol(
            F_true, K, sabr_maturity, alpha_true, beta_true, rho_true, nu_true
        )
        for K in strikes
    ]
)
noise = np.random.normal(0, 0.005, len(strikes))  # 50bp noise
market_vols_noisy = market_vols + noise
Out[7]:
Console
Strike | True IV | Market IV (noisy)
----------------------------------------
   80  |  0.0463 |     0.0487
   85  |  0.0391 |     0.0384
   90  |  0.0322 |     0.0354
   95  |  0.0255 |     0.0331
  100  |  0.0202 |     0.0191
  105  |  0.0202 |     0.0190
  110  |  0.0237 |     0.0316
  115  |  0.0278 |     0.0316
  120  |  0.0317 |     0.0294

This deliberately simplified example adds independent Gaussian noise to model-generated implied volatilities. Actual quote errors can reflect spreads, staleness, dependence and other market effects; the synthetic noise is not an empirical model of those errors. We now fit the selected objective to these synthetic quotes.

In[8]:
Code
from scipy.optimize import minimize


def validate_sabr_quotes(strikes, market_vols, F, T, beta):
    strikes = np.asarray(strikes, dtype=float)
    market_vols = np.asarray(market_vols, dtype=float)
    if (
        strikes.ndim != 1
        or market_vols.ndim != 1
        or strikes.size == 0
        or strikes.shape != market_vols.shape
    ):
        raise ValueError("Require nonempty equal-length one-dimensional quote arrays")
    if (
        not np.all(np.isfinite(strikes))
        or not np.all(strikes > 0)
        or not np.all(np.isfinite(market_vols))
        or not np.all(market_vols > 0)
    ):
        raise ValueError("Require positive finite strikes and implied volatilities")
    if (
        not all(np.isfinite(x) for x in (F, T, beta))
        or F <= 0
        or T < 0
        or not 0 <= beta <= 1
    ):
        raise ValueError("Invalid SABR market setup")
    return strikes, market_vols


def calibration_objective(params, F, T, strikes, market_vols, beta):
    """Sum of squared IV errors; invalid candidates have infinite loss."""
    strikes, market_vols = validate_sabr_quotes(strikes, market_vols, F, T, beta)
    params = np.asarray(params, dtype=float)
    if params.shape != (3,) or not np.all(np.isfinite(params)):
        return np.inf
    alpha, rho, nu = params
    if alpha <= 0 or nu < 0 or abs(rho) >= 1:
        return np.inf
    try:
        model_vols = np.array(
            [sabr_implied_vol(F, K, T, alpha, beta, rho, nu) for K in strikes]
        )
    except (ValueError, FloatingPointError, OverflowError):
        return np.inf
    if not np.all(np.isfinite(model_vols)) or not np.all(model_vols > 0):
        return np.inf
    return float(np.sum((model_vols - market_vols) ** 2))


def require_successful_fit(result, label):
    if (
        not result.success
        or not np.isfinite(result.fun)
        or not np.all(np.isfinite(result.x))
    ):
        raise RuntimeError(f"{label} failed: {result.message}")
    return result


# Fix beta at 0.5 (common convention)
beta_fixed = 0.5

# Initial guess
x0 = [0.25, -0.20, 0.35]

# Bounds for parameters
bounds = [(0.01, 1.0), (-0.99, 0.99), (0.01, 2.0)]

# Calibrate using L-BFGS-B (handles bounds)
result = minimize(
    calibration_objective,
    x0,
    args=(F_true, sabr_maturity, strikes, market_vols_noisy, beta_fixed),
    method="L-BFGS-B",
    bounds=bounds,
)

require_successful_fit(result, "SABR calibration")
alpha_cal, rho_cal, nu_cal = result.x
Out[9]:
Console
Calibration Results:
----------------------------------------
Parameter |   True   | Calibrated |  Error
----------------------------------------
  alpha   |  0.2000  |   0.2264   | 0.0264
  rho     | -0.3000  |  -0.2943   | 0.0057
  nu      |  0.4000  |   0.3911   | 0.0089

Final SSE: 0.000120
RMSE (implied volatility): 36.46 bps

The fitted parameters and their discrepancies from the generating values are reported above. This is one noisy synthetic fit, not evidence of reliable parameter recovery or unique identification. Compare the IV RMSE with the added 50-basis-point noise scale and the intended pricing tolerance. The plot shows the fitted smile and the generating curve separately:

Out[11]:
Visualization
Line plot showing calibrated SABR volatility smile fitting noisy market data points across strikes from 80 to 120.
SABR fit to noisy synthetic one-year implied-volatility quotes. The solid curve is the fit found in this run; parameter discrepancies are reported separately and need not be small.

The fitted smile approximates these synthetic one-year quotes. A least-squares fit need not interpolate every noisy observation. Validate approximation accuracy, interpolation, extrapolation and no-arbitrage conditions before using strikes outside the observed range.

Out[13]:
Visualization
Filled contour plot of the SABR calibration objective over rho and nu, with a star marking the calibrated optimum and a circle marking the true parameter values.
SABR objective on a finite 50-by-50 rho-nu grid with alpha fixed at its calibrated value and high losses clipped for display. The displayed slice shows a low-loss basin near the calibrated point; it does not establish a global minimum over the full parameter space.