Advanced Interest Rate Models (HJM and LMM) › Calibration to Market Data

Michael BrenndoerferDecember 7, 202510 min read
Quantitative FinanceAll chapters
Part 7 of 15Interest Rate Models and Derivatives
Chapter 39Advanced Interest Rate Models (HJM and LMM) › Calibration to Market Data
Reading section 8 of 13 · Calibration to Market Data · 10 minSection 8 of 13 · 10 min

Book contents

Section 8 of 13 · 10 min read

Advanced Interest Rate Models (HJM and LMM) › Calibration to Market DataOne section · Sections in this chapter8 / 13 ↓
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

Calibration is the process of choosing model parameters (volatilities, correlations) so that the model reproduces observed market prices. For interest rate models, calibration typically targets cap/floor volatilities and swaption volatilities. The goal is to find parameters that allow the model to price liquid instruments consistently with the market while maintaining reasonable behavior for illiquid or exotic instruments. This balancing act is central to practical derivatives pricing.

Cap Volatility Calibration

Market caps are quoted in terms of implied volatility. Given a quoted cap volatility, we can back out the price using Black's formula, then calibrate the LMM volatilities to match these prices. The calibration process inverts the pricing formula: rather than using volatility to find price, we use price to find volatility.

The caplet volatility structure in LMM has several common parameterizations. A popular choice is the piecewise-constant volatility, where each forward rate has a constant volatility that can differ across maturities. More sophisticated approaches use time-varying or maturity-dependent volatility functions that can capture features like the volatility "hump" often observed at intermediate maturities.

In[21]:
Code
import numpy as np
from scipy.optimize import minimize
from scipy.stats import norm


def black_caplet_price(
    forward_rate, strike, maturity, volatility, discount_factor, delta=0.25
):
    """
    Price a caplet using Black's formula.

    Parameters:
    -----------
    forward_rate : float
        Current forward LIBOR rate L_i(0)
    strike : float
        Cap strike rate K
    maturity : float
        Time to caplet fixing date T_i
    volatility : float
        Forward rate volatility sigma_i
    discount_factor : float
        P(0, T_{i+1}) discount factor to payment date
    delta : float
        Accrual period (default 0.25 for quarterly)

    Returns:
    --------
    price : float
        Caplet price
    """
    if maturity <= 0:
        return max(forward_rate - strike, 0) * delta * discount_factor

    v = volatility * np.sqrt(maturity)
    d1 = (np.log(forward_rate / strike) + 0.5 * v**2) / v
    d2 = d1 - v

    price = (
        delta
        * discount_factor
        * (forward_rate * norm.cdf(d1) - strike * norm.cdf(d2))
    )
    return price


def calibrate_cap_volatilities(
    market_cap_vols, forward_rates, maturities, discount_factors, delta
):
    """
    Calibrate LMM volatilities to match market cap implied volatilities.

    Parameters:
    -----------
    market_cap_vols : array
        Market-quoted cap volatilities for each maturity
    forward_rates : array
        Forward LIBOR rates
    maturities : array
        Cap maturities
    discount_factors : array
        Discount factors for payment dates
    delta : float
        Accrual period

    Returns:
    --------
    calibrated_vols : array
        Calibrated forward rate volatilities
    """
    n = len(forward_rates)

    def objective(vols):
        """Sum of squared errors between model and market cap prices."""
        total_error = 0
        for i in range(n):
            model_price = black_caplet_price(
                forward_rates[i],
                forward_rates[i],  # ATM strike
                maturities[i],
                vols[i],
                discount_factors[i],
                delta,
            )
            market_price = black_caplet_price(
                forward_rates[i],
                forward_rates[i],
                maturities[i],
                market_cap_vols[i],
                discount_factors[i],
                delta,
            )
            total_error += (model_price - market_price) ** 2
        return total_error

    # Initial guess: market vols
    x0 = market_cap_vols.copy()

    # Bounds: positive volatilities
    bounds = [(0.01, 1.0)] * n

    result = minimize(objective, x0, method="L-BFGS-B", bounds=bounds)

    return result.x


# Example: Calibrate to a typical cap volatility term structure
# Market often shows a "hump" in cap volatilities
market_maturities = np.array([0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0])
market_cap_vols = np.array(
    [0.18, 0.22, 0.24, 0.25, 0.24, 0.23, 0.22, 0.21]
)  # Typical hump shape

forward_rates_calib = np.ones(len(market_maturities)) * 0.05
discount_factors_calib = np.exp(-0.05 * (market_maturities + 0.25))

calibrated_vols = calibrate_cap_volatilities(
    market_cap_vols,
    forward_rates_calib,
    market_maturities,
    discount_factors_calib,
    delta=0.25,
)
Out[22]:
Visualization
Line chart comparing market cap volatilities shown with circular markers and calibrated model volatilities shown with square markers. Both series overlap from 0.5 to 4 years and peak at 25% near the two-year maturity.
Calibration fit of LMM forward-rate volatilities to market cap data. Market observations and calibrated values overlap across maturities, reproducing the volatility hump that peaks at 25% around two years.
Calibration Results:
  0.5y: Market = 18.0%, Model = 18.0%
  1.0y: Market = 22.0%, Model = 22.0%
  1.5y: Market = 24.0%, Model = 24.0%
  2.0y: Market = 25.0%, Model = 25.0%
  2.5y: Market = 24.0%, Model = 24.0%
  3.0y: Market = 23.0%, Model = 23.0%
  3.5y: Market = 22.0%, Model = 22.0%
  4.0y: Market = 21.0%, Model = 21.0%

The calibration results demonstrate the model's ability to fit the term structure of volatility. The calibrated forward rate volatilities track the market cap volatilities, capturing the hump shape around the 2-year mark that is characteristic of interest rate markets. This hump reflects the market's view that rates are most uncertain at intermediate horizons: near-term rates are anchored by current monetary policy, while very long-term rates are stabilized by mean reversion.

Swaption Volatility Surface Calibration

The swaption market provides a rich volatility surface across different expiries and swap tenors. Calibrating the LMM to this surface is more challenging because swaption volatility depends on both individual forward rate volatilities and their correlations. Unlike cap calibration, where each caplet can be calibrated independently, swaption calibration requires simultaneously fitting multiple interdependent parameters.

A typical swaption volatility surface (often called a "vol cube" when strikes are included) shows variation across:

  • Expiry: Time until the option expires
  • Tenor: Length of the underlying swap
  • Strike: Often expressed as offset from ATM
In[23]:
Code
# Create a synthetic swaption volatility surface
expiries = np.array([0.5, 1, 2, 3, 5])  # Option expiries
tenors = np.array([1, 2, 3, 5, 7, 10])  # Swap tenors

# Typical swaption vol surface (ATM)
swaption_vol_surface = np.array(
    [
        [0.25, 0.24, 0.23, 0.21, 0.19, 0.17],  # 6m expiry
        [0.24, 0.23, 0.22, 0.20, 0.18, 0.16],  # 1y expiry
        [0.22, 0.21, 0.20, 0.18, 0.17, 0.15],  # 2y expiry
        [0.20, 0.19, 0.18, 0.17, 0.16, 0.14],  # 3y expiry
        [0.18, 0.17, 0.16, 0.15, 0.14, 0.13],  # 5y expiry
    ]
)
Out[24]:
Visualization
3D surface plot of swaption volatility across expiry and tenor dimensions.
Market implied volatility surface for ATM swaptions. The 3D topology shows declining volatility across both swap tenor and option expiry, a stylized fact of interest rate markets driven by mean reversion and long-term stability expectations.
Out[25]:
Visualization
Annotated heatmap of ATM swaption volatility with option expiries from 0.5 to 5 years on the vertical axis and swap tenors from 1 to 10 years on the horizontal axis. Values decline from 25% at bottom-left to 13% at top-right.
Heatmap of the ATM swaption volatility surface. Volatility is highest at 25% for the 0.5-year expiry and 1-year tenor in the bottom-left, then declines across both dimensions to 13% for the 5-year expiry and 10-year tenor in the top-right.

Joint Calibration Challenge

Calibrating the LMM to both caps and swaptions simultaneously is challenging because caps constrain individual forward rate volatilities while swaptions constrain both volatilities and correlations. A model that fits cap volatilities perfectly may still misprice swaptions if the correlation structure is wrong. Conversely, adjusting correlations to fit swaptions may introduce inconsistencies with cap prices. A common approach involves:

  1. Stage 1: Calibrate forward rate volatilities to the cap volatility curve
  2. Stage 2: Calibrate correlations to match swaption volatilities, given the volatilities from Stage 1

More sophisticated approaches use global optimization to calibrate volatilities and correlations jointly, treating the entire parameter set as a single optimization problem. This avoids the suboptimality that can arise from sequential calibration but requires more computational resources and careful attention to the objective function weighting.

In[26]:
Code
def approximate_swaption_vol(
    forward_vols, correlation, delta, start_idx, end_idx, expiry
):
    """
    Approximate swaption volatility from forward rate volatilities and correlations.
    Uses the "frozen" approximation where weights are computed at time 0.
    """
    n_rates = end_idx - start_idx

    # Equal weights approximation (simplified)
    weights = np.ones(n_rates) / n_rates

    # Calculate approximate swaption variance
    variance = 0
    for i in range(n_rates):
        for j in range(n_rates):
            idx_i = start_idx + i
            idx_j = start_idx + j
            if idx_i < len(forward_vols) and idx_j < len(forward_vols):
                variance += (
                    weights[i]
                    * weights[j]
                    * correlation[idx_i, idx_j]
                    * forward_vols[idx_i]
                    * forward_vols[idx_j]
                    * expiry
                )

    return np.sqrt(variance / expiry) if expiry > 0 else 0


def calibrate_correlation(
    forward_vols, target_swaption_vols, expiries, tenors, delta
):
    """
    Calibrate correlation parameter to match swaption volatilities.
    Uses exponential correlation structure.
    """
    n = len(forward_vols)

    def objective(beta):
        """Sum of squared errors for swaption vols."""
        beta = beta[0]
        if beta <= 0:
            return 1e10

        corr = generate_correlation_matrix(n, "exponential", {"beta": beta})

        total_error = 0
        for i, expiry in enumerate(expiries[:3]):  # Use first 3 expiries
            for j, tenor in enumerate(tenors[:3]):  # Use first 3 tenors
                start_idx = int(expiry / delta)
                end_idx = int((expiry + tenor) / delta)

                if end_idx <= n:
                    model_vol = approximate_swaption_vol(
                        forward_vols, corr, delta, start_idx, end_idx, expiry
                    )
                    target_vol = target_swaption_vols[i, j]
                    total_error += (model_vol - target_vol) ** 2

        return total_error

    result = minimize(
        objective, [0.2], method="Nelder-Mead", bounds=[(0.01, 1.0)]
    )
    return result.x[0]


# Calibrate correlation to swaption surface
# Extend forward vols to cover swaption maturities
extended_forward_vols = np.concatenate(
    [calibrated_vols, np.ones(12) * calibrated_vols[-1]]
)

calibrated_beta = calibrate_correlation(
    extended_forward_vols, swaption_vol_surface, expiries, tenors, delta=0.25
)

# Generate calibrated correlation matrix for comparison
n = len(extended_forward_vols)
calibrated_corr = generate_correlation_matrix(
    n, "exponential", {"beta": calibrated_beta}
)

# Calculate comparison values
comparison_data = []
delta_val = 0.25
for i, expiry in enumerate(expiries[:3]):
    for j, tenor in enumerate(tenors[:3]):
        start_idx = int(expiry / delta_val)
        end_idx = int((expiry + tenor) / delta_val)
        if end_idx <= n:
            model_vol = approximate_swaption_vol(
                extended_forward_vols,
                calibrated_corr,
                delta_val,
                start_idx,
                end_idx,
                expiry,
            )
            market_vol = swaption_vol_surface[i, j]
            comparison_data.append((expiry, tenor, market_vol, model_vol))
Out[27]:
Console
Calibrated correlation decay parameter (beta): 0.0100

Swaption Vol Comparison (Expiry x Tenor):
Market vs Model:
  0.5y x 1y: Market = 25.0%, Model = 23.9%
  0.5y x 2y: Market = 24.0%, Model = 22.3%
  0.5y x 3y: Market = 23.0%, Model = 21.6%
  1.0y x 1y: Market = 24.0%, Model = 22.4%
  1.0y x 2y: Market = 23.0%, Model = 21.5%
  1.0y x 3y: Market = 22.0%, Model = 21.1%
  2.0y x 1y: Market = 22.0%, Model = 20.9%
  2.0y x 2y: Market = 21.0%, Model = 20.7%
  2.0y x 3y: Market = 20.0%, Model = 20.6%

The calibration shows that while we can match some swaptions well, perfect fit across the entire surface requires more sophisticated volatility and correlation parameterizations. This is an active area of research and practical development. In production systems, you often use more flexible correlation structures (such as angle-based parameterizations) and volatility functions that depend on both time and maturity to achieve better fits.

Out[28]:
Visualization
Line plot showing swaption implied volatility decreasing as the correlation decay parameter beta increases.
Sensitivity of swaption volatility to the correlation decay parameter $\beta$. The downward slope indicates that assuming faster correlation decay (higher $\beta$) reduces the implied volatility of the swap rate, as the component forward rates offset each other's movements more frequently.