Calibration and Parameter Estimation in Models › Calibrating Implied Volatility Surfaces
Quantitative FinanceAll chapters 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:
where:
- : forward price at time
- : stochastic volatility at time
- : initial volatility ( in some notations), setting the overall level of the volatility smile
- : volatility of volatility (vol-vol), determining how much the volatility itself fluctuates
- : elasticity parameter, controlling the relationship between the forward price and local volatility
- : standard Brownian motions with correlation
- : correlation coefficient between the forward price and volatility processes
The first equation has a constant-elasticity-of-variance backbone determined by . With this is stochastic-lognormal SABR, and with 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 .
The correlation 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 and forward . The unshifted form below assumes positive forwards and strikes, , , , and , with the ATM and zero- limits evaluated continuously. The initial coefficient 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:
where:
- : current forward price
- : strike price
- : time to expiration
- : initial volatility level
- : elasticity parameter
- : correlation between Brownian motions
- : volatility of volatility
- : a vol-of-vol-scaled log-moneyness variable used in the approximation
- : 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 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.
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 volNow let's generate synthetic market data from known SABR parameters and add noise:
# "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 + noiseStrike | 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.
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.xCalibration 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:

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.
