NV
NordVarg
ServicesTechnologiesIndustriesCase StudiesBlogAboutContact
Get Started

Footer

NV
NordVarg

Software Development & Consulting

GitHubLinkedInTwitter

Services

  • Product Development
  • Quantitative Finance
  • Financial Systems
  • ML & AI

Technologies

  • C++
  • Python
  • Rust
  • OCaml
  • TypeScript
  • React

Company

  • About
  • Case Studies
  • Blog
  • Contact

© 2025 NordVarg. All rights reserved.

November 24, 2025
•
NordVarg Team
•

Interest Rate Models: From Vasicek to HJM

Quantitative Financeinterest-ratesVasicekCIRHull-WhiteHJMLIBOR-market-modelswaption-pricingyield-curve
16 min read
Share:

TL;DR – Interest rate models are essential for pricing bonds, swaps, and swaptions. This guide covers short-rate models (Vasicek, CIR, Hull-White), the HJM framework, and LIBOR market models with calibration to market data and production-ready implementations.

1. Why Model Interest Rates?#

Interest rate models are fundamental for:

  • Bond pricing – Government and corporate bonds
  • Derivatives valuation – Swaps, swaptions, caps, floors
  • Risk management – Duration, convexity, VaR
  • Portfolio optimization – Fixed income strategies
  • Regulatory capital – Basel III market risk

Challenge: Unlike stocks, interest rates are not directly observable. We observe bond prices and must infer the entire yield curve.

2. The Yield Curve and Forward Rates#

2.1 Zero-Coupon Bond Prices#

A zero-coupon bond P(t,T)P(t, T)P(t,T) pays 111 at maturity TTT. Its price at time ttt is: P(t,T)=e−∫tTf(t,s) dsP(t, T) = e^{-\int_t^T f(t, s) \, ds}P(t,T)=e−∫tT​f(t,s)ds

where f(t,T)f(t, T)f(t,T) is the instantaneous forward rate at time ttt for maturity TTT.

2.2 Spot Rate and Yield#

The spot rate (yield to maturity) R(t,T)R(t, T)R(t,T) satisfies: P(t,T)=e−R(t,T)(T−t)P(t, T) = e^{-R(t, T)(T-t)}P(t,T)=e−R(t,T)(T−t)

Relationship to forward rates: R(t,T)=1T−t∫tTf(t,s) dsR(t, T) = \frac{1}{T-t} \int_t^T f(t, s) \, dsR(t,T)=T−t1​∫tT​f(t,s)ds

2.3 Implementation: Bootstrapping the Yield Curve#

python
1import numpy as np
2from scipy.optimize import minimize_scalar
3from scipy.interpolate import CubicSpline
4
5class YieldCurve:
6    """Bootstrap and interpolate yield curves from bond prices."""
7    
8    def __init__(self, maturities: np.ndarray, prices: np.ndarray):
9        """
10        Args:
11            maturities: Time to maturity in years
12            prices: Zero-coupon bond prices P(0, T)
13        """
14        self.maturities = maturities
15        self.prices = prices
16        
17        # Compute spot rates
18        self.spot_rates = -np.log(prices) / maturities
19        
20        # Cubic spline interpolation
21        self.spline = CubicSpline(maturities, self.spot_rates)
22    
23    def zero_rate(self, T: float) -> float:
24        """Get zero rate for maturity T."""
25        return float(self.spline(T))
26    
27    def discount_factor(self, T: float) -> float:
28        """Get discount factor P(0, T)."""
29        return np.exp(-self.zero_rate(T) * T)
30    
31    def forward_rate(self, T1: float, T2: float) -> float:
32        """
33        Compute forward rate f(T1, T2).
34        
35        f(T1, T2) = [R(T2)*T2 - R(T1)*T1] / (T2 - T1)
36        """
37        R1, R2 = self.zero_rate(T1), self.zero_rate(T2)
38        return (R2 * T2 - R1 * T1) / (T2 - T1)
39    
40    def instantaneous_forward(self, T: float) -> float:
41        """
42        Compute instantaneous forward rate f(0, T).
43        
44        f(0, T) = R(T) + T * dR/dT
45        """
46        R = self.zero_rate(T)
47        dR_dT = self.spline.derivative()(T)
48        return R + T * dR_dT
49
50# Example: Bootstrap from market data
51maturities = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30])
52# Hypothetical zero-coupon bond prices
53prices = np.array([0.9975, 0.9945, 0.9880, 0.9720, 0.9540, 
54                   0.9150, 0.8750, 0.8200, 0.6800, 0.5500])
55
56curve = YieldCurve(maturities, prices)
57
58print(f"5Y zero rate: {curve.zero_rate(5.0):.4%}")
59print(f"10Y discount factor: {curve.discount_factor(10.0):.4f}")
60print(f"2Y-5Y forward rate: {curve.forward_rate(2.0, 5.0):.4%}")
61

3. Short-Rate Models#

Short-rate models specify the dynamics of the instantaneous short rate rtr_trt​.

3.1 Vasicek Model (1977)#

SDE: drt=κ(θ−rt)dt+σdWtdr_t = \kappa(\theta - r_t) dt + \sigma dW_tdrt​=κ(θ−rt​)dt+σdWt​

Where:

  • κ\kappaκ = mean reversion speed
  • θ\thetaθ = long-term mean
  • σ\sigmaσ = volatility

Properties:

  • Mean-reverting: Rates pull toward θ\thetaθ
  • Gaussian: Can go negative (unrealistic but analytically tractable)
  • Affine: Bond prices have closed-form solutions

Zero-Coupon Bond Price: P(t,T)=A(t,T)e−B(t,T)rtP(t, T) = A(t, T) e^{-B(t, T) r_t}P(t,T)=A(t,T)e−B(t,T)rt​

where: B(t,T)=1−e−κ(T−t)κB(t, T) = \frac{1 - e^{-\kappa(T-t)}}{\kappa}B(t,T)=κ1−e−κ(T−t)​ A(t,T)=exp⁡((θ−σ22κ2)(B(t,T)−(T−t))−σ2B(t,T)24κ)A(t, T) = \exp\left(\left(\theta - \frac{\sigma^2}{2\kappa^2}\right)(B(t, T) - (T-t)) - \frac{\sigma^2 B(t, T)^2}{4\kappa}\right)A(t,T)=exp((θ−2κ2σ2​)(B(t,T)−(T−t))−4κσ2B(t,T)2​)

python
1class VasicekModel:
2    """Vasicek short-rate model."""
3    
4    def __init__(self, kappa: float, theta: float, sigma: float, r0: float):
5        self.kappa = kappa
6        self.theta = theta
7        self.sigma = sigma
8        self.r0 = r0
9    
10    def B(self, t: float, T: float) -> float:
11        """B(t, T) function."""
12        tau = T - t
13        if self.kappa == 0:
14            return tau
15        return (1 - np.exp(-self.kappa * tau)) / self.kappa
16    
17    def A(self, t: float, T: float) -> float:
18        """A(t, T) function."""
19        tau = T - t
20        B_val = self.B(t, T)
21        
22        term1 = (self.theta - self.sigma**2 / (2 * self.kappa**2)) * (B_val - tau)
23        term2 = self.sigma**2 * B_val**2 / (4 * self.kappa)
24        
25        return np.exp(term1 - term2)
26    
27    def bond_price(self, t: float, T: float, r_t: float) -> float:
28        """Zero-coupon bond price P(t, T)."""
29        return self.A(t, T) * np.exp(-self.B(t, T) * r_t)
30    
31    def simulate(self, T: float, n_steps: int, n_paths: int = 1) -> np.ndarray:
32        """Simulate short rate paths using Euler-Maruyama."""
33        dt = T / n_steps
34        sqrt_dt = np.sqrt(dt)
35        
36        r = np.zeros((n_paths, n_steps + 1))
37        r[:, 0] = self.r0
38        
39        for i in range(n_steps):
40            dW = np.random.normal(0, sqrt_dt, n_paths)
41            r[:, i+1] = r[:, i] + self.kappa * (self.theta - r[:, i]) * dt + self.sigma * dW
42        
43        return r
44    
45    def calibrate(self, maturities: np.ndarray, market_prices: np.ndarray) -> dict:
46        """
47        Calibrate model to market bond prices.
48        
49        Minimize sum of squared pricing errors.
50        """
51        def objective(params):
52            kappa, theta, sigma = params
53            model = VasicekModel(kappa, theta, sigma, self.r0)
54            
55            model_prices = np.array([
56                model.bond_price(0, T, self.r0) for T in maturities
57            ])
58            
59            return np.sum((model_prices - market_prices)**2)
60        
61        from scipy.optimize import minimize
62        
63        # Initial guess
64        x0 = [0.1, 0.05, 0.01]
65        bounds = [(0.01, 1.0), (0.0, 0.2), (0.001, 0.1)]
66        
67        result = minimize(objective, x0, bounds=bounds, method='L-BFGS-B')
68        
69        return {
70            'kappa': result.x[0],
71            'theta': result.x[1],
72            'sigma': result.x[2],
73            'error': result.fun
74        }
75
76# Example usage
77model = VasicekModel(kappa=0.15, theta=0.05, sigma=0.01, r0=0.03)
78
79# Price a 5-year zero-coupon bond
80price_5y = model.bond_price(0, 5, 0.03)
81print(f"5Y bond price: {price_5y:.4f}")
82
83# Simulate rate paths
84paths = model.simulate(T=10, n_steps=1000, n_paths=100)
85
86import matplotlib.pyplot as plt
87t = np.linspace(0, 10, 1001)
88plt.figure(figsize=(12, 6))
89for i in range(10):
90    plt.plot(t, paths[i], alpha=0.5)
91plt.axhline(y=model.theta, color='r', linestyle='--', label=f'Long-term mean = {model.theta}')
92plt.xlabel('Time (years)')
93plt.ylabel('Short Rate')
94plt.title('Vasicek Model: Simulated Rate Paths')
95plt.legend()
96plt.grid(True, alpha=0.3)
97plt.show()
98

3.2 Cox-Ingersoll-Ross (CIR) Model (1985)#

SDE: drt=κ(θ−rt)dt+σrt dWtdr_t = \kappa(\theta - r_t) dt + \sigma \sqrt{r_t} \, dW_tdrt​=κ(θ−rt​)dt+σrt​​dWt​

Key difference: Volatility is σrt\sigma \sqrt{r_t}σrt​​ (state-dependent), ensuring rt≥0r_t \geq 0rt​≥0 if 2κθ≥σ22\kappa\theta \geq \sigma^22κθ≥σ2 (Feller condition).

Bond Price: P(t,T)=A(t,T)e−B(t,T)rtP(t, T) = A(t, T) e^{-B(t, T) r_t}P(t,T)=A(t,T)e−B(t,T)rt​

where (more complex than Vasicek): γ=κ2+2σ2\gamma = \sqrt{\kappa^2 + 2\sigma^2}γ=κ2+2σ2​ B(t,T)=2(eγ(T−t)−1)(γ+κ)(eγ(T−t)−1)+2γB(t, T) = \frac{2(e^{\gamma(T-t)} - 1)}{(\gamma + \kappa)(e^{\gamma(T-t)} - 1) + 2\gamma}B(t,T)=(γ+κ)(eγ(T−t)−1)+2γ2(eγ(T−t)−1)​ A(t,T)=[2γe(κ+γ)(T−t)/2(γ+κ)(eγ(T−t)−1)+2γ]2κθ/σ2A(t, T) = \left[\frac{2\gamma e^{(\kappa+\gamma)(T-t)/2}}{(\gamma + \kappa)(e^{\gamma(T-t)} - 1) + 2\gamma}\right]^{2\kappa\theta/\sigma^2}A(t,T)=[(γ+κ)(eγ(T−t)−1)+2γ2γe(κ+γ)(T−t)/2​]2κθ/σ2

python
1class CIRModel:
2    """Cox-Ingersoll-Ross model."""
3    
4    def __init__(self, kappa: float, theta: float, sigma: float, r0: float):
5        self.kappa = kappa
6        self.theta = theta
7        self.sigma = sigma
8        self.r0 = r0
9        
10        # Check Feller condition
11        if 2 * kappa * theta < sigma**2:
12            print("Warning: Feller condition not satisfied. Rates may hit zero.")
13    
14    def B(self, t: float, T: float) -> float:
15        """B(t, T) function."""
16        tau = T - t
17        gamma = np.sqrt(self.kappa**2 + 2 * self.sigma**2)
18        
19        exp_gamma_tau = np.exp(gamma * tau)
20        numerator = 2 * (exp_gamma_tau - 1)
21        denominator = (gamma + self.kappa) * (exp_gamma_tau - 1) + 2 * gamma
22        
23        return numerator / denominator
24    
25    def A(self, t: float, T: float) -> float:
26        """A(t, T) function."""
27        tau = T - t
28        gamma = np.sqrt(self.kappa**2 + 2 * self.sigma**2)
29        
30        exp_gamma_tau = np.exp(gamma * tau)
31        numerator = 2 * gamma * np.exp((self.kappa + gamma) * tau / 2)
32        denominator = (gamma + self.kappa) * (exp_gamma_tau - 1) + 2 * gamma
33        
34        exponent = 2 * self.kappa * self.theta / (self.sigma**2)
35        
36        return (numerator / denominator) ** exponent
37    
38    def bond_price(self, t: float, T: float, r_t: float) -> float:
39        """Zero-coupon bond price."""
40        return self.A(t, T) * np.exp(-self.B(t, T) * r_t)
41    
42    def simulate(self, T: float, n_steps: int, n_paths: int = 1) -> np.ndarray:
43        """
44        Simulate using full truncation scheme (Andersen, 2008).
45        
46        Ensures non-negativity while maintaining accuracy.
47        """
48        dt = T / n_steps
49        sqrt_dt = np.sqrt(dt)
50        
51        r = np.zeros((n_paths, n_steps + 1))
52        r[:, 0] = self.r0
53        
54        for i in range(n_steps):
55            dW = np.random.normal(0, sqrt_dt, n_paths)
56            
57            # Full truncation: use max(r, 0) in diffusion term
58            r_pos = np.maximum(r[:, i], 0)
59            
60            r[:, i+1] = r[:, i] + self.kappa * (self.theta - r_pos) * dt + \
61                        self.sigma * np.sqrt(r_pos) * dW
62            
63            # Ensure non-negativity
64            r[:, i+1] = np.maximum(r[:, i+1], 0)
65        
66        return r
67
68# Example
69cir = CIRModel(kappa=0.15, theta=0.05, sigma=0.02, r0=0.03)
70price_cir = cir.bond_price(0, 5, 0.03)
71print(f"CIR 5Y bond price: {price_cir:.4f}")
72

3.3 Hull-White Model (1990)#

Extension of Vasicek with time-dependent parameters to fit the initial yield curve exactly: drt=[θ(t)−κrt]dt+σdWtdr_t = [\theta(t) - \kappa r_t] dt + \sigma dW_tdrt​=[θ(t)−κrt​]dt+σdWt​

Calibration: Choose θ(t)\theta(t)θ(t) to match market bond prices: θ(t)=∂fM(0,t)∂t+κfM(0,t)+σ22κ(1−e−2κt)\theta(t) = \frac{\partial f^M(0, t)}{\partial t} + \kappa f^M(0, t) + \frac{\sigma^2}{2\kappa}(1 - e^{-2\kappa t})θ(t)=∂t∂fM(0,t)​+κfM(0,t)+2κσ2​(1−e−2κt)

where fM(0,t)f^M(0, t)fM(0,t) is the market instantaneous forward rate.

python
1class HullWhiteModel:
2    """Hull-White one-factor model."""
3    
4    def __init__(self, kappa: float, sigma: float, yield_curve: YieldCurve):
5        self.kappa = kappa
6        self.sigma = sigma
7        self.curve = yield_curve
8    
9    def theta(self, t: float) -> float:
10        """
11        Time-dependent drift calibrated to initial curve.
12        
13        theta(t) = df/dt + kappa*f + sigma^2/(2*kappa)*(1 - exp(-2*kappa*t))
14        """
15        f_t = self.curve.instantaneous_forward(t)
16        
17        # Numerical derivative of forward rate
18        dt = 0.0001
19        f_plus = self.curve.instantaneous_forward(t + dt)
20        df_dt = (f_plus - f_t) / dt
21        
22        term3 = (self.sigma**2 / (2 * self.kappa)) * (1 - np.exp(-2 * self.kappa * t))
23        
24        return df_dt + self.kappa * f_t + term3
25    
26    def bond_price(self, t: float, T: float, r_t: float) -> float:
27        """Zero-coupon bond price."""
28        tau = T - t
29        
30        # B(t, T)
31        B = (1 - np.exp(-self.kappa * tau)) / self.kappa
32        
33        # A(t, T) - more complex due to time-dependent theta
34        P_market_T = self.curve.discount_factor(T)
35        P_market_t = self.curve.discount_factor(t)
36        f_t = self.curve.instantaneous_forward(t)
37        
38        log_A = np.log(P_market_T / P_market_t) + B * f_t - \
39                (self.sigma**2 / (4 * self.kappa)) * B**2 * (1 - np.exp(-2 * self.kappa * t))
40        
41        return np.exp(log_A - B * r_t)
42    
43    def simulate(self, T: float, n_steps: int, n_paths: int = 1) -> np.ndarray:
44        """Simulate short rate paths."""
45        dt = T / n_steps
46        sqrt_dt = np.sqrt(dt)
47        
48        r = np.zeros((n_paths, n_steps + 1))
49        r[:, 0] = self.curve.instantaneous_forward(0)
50        
51        t = np.linspace(0, T, n_steps + 1)
52        
53        for i in range(n_steps):
54            theta_t = self.theta(t[i])
55            dW = np.random.normal(0, sqrt_dt, n_paths)
56            
57            r[:, i+1] = r[:, i] + (theta_t - self.kappa * r[:, i]) * dt + self.sigma * dW
58        
59        return r
60
61# Example
62hw = HullWhiteModel(kappa=0.1, sigma=0.01, yield_curve=curve)
63price_hw = hw.bond_price(0, 5, curve.instantaneous_forward(0))
64print(f"Hull-White 5Y bond price: {price_hw:.4f}")
65print(f"Market 5Y bond price: {curve.discount_factor(5):.4f}")
66

4. Swaption Pricing#

A swaption is an option to enter a swap. Key for hedging interest rate risk.

4.1 Swap Basics#

A payer swap exchanges fixed rate KKK for floating rate (LIBOR) over tenor [T0,Tn][T_0, T_n][T0​,Tn​].

Swap value at time ttt: Vswap(t)=∑i=1nδiP(t,Ti)[L(t,Ti−1,Ti)−K]V_{\text{swap}}(t) = \sum_{i=1}^n \delta_i P(t, T_i) [L(t, T_{i-1}, T_i) - K]Vswap​(t)=∑i=1n​δi​P(t,Ti​)[L(t,Ti−1​,Ti​)−K]

where L(t,Ti−1,Ti)L(t, T_{i-1}, T_i)L(t,Ti−1​,Ti​) is the forward LIBOR rate.

4.2 Swaption Pricing in Hull-White#

Payer swaption: Option to enter payer swap at time T0T_0T0​ with strike KKK.

Jamshidian's trick: Decompose swaption into portfolio of bond options.

python
1def swaption_price_hw(
2    model: HullWhiteModel,
3    T_option: float,
4    T_start: float,
5    T_end: float,
6    strike: float,
7    notional: float = 1.0,
8    n_periods: int = 4
9) -> float:
10    """
11    Price payer swaption using Jamshidian's decomposition.
12    
13    Args:
14        T_option: Swaption expiry
15        T_start: Swap start
16        T_end: Swap end
17        strike: Fixed rate
18        n_periods: Number of payment periods per year
19    """
20    from scipy.stats import norm
21    
22    # Payment dates
23    dt = 1 / n_periods
24    payment_dates = np.arange(T_start + dt, T_end + dt, dt)
25    
26    # Compute critical rate r* such that swap value = 0
27    # This requires solving a nonlinear equation
28    def swap_value(r_star):
29        pv_fixed = sum(strike * dt * model.bond_price(T_option, T_i, r_star) 
30                      for T_i in payment_dates)
31        pv_float = model.bond_price(T_option, T_start, r_star) - \
32                   model.bond_price(T_option, T_end, r_star)
33        return pv_float - pv_fixed
34    
35    from scipy.optimize import brentq
36    r_star = brentq(swap_value, -0.1, 0.3)
37    
38    # Price as sum of bond options using Black's formula
39    swaption_value = 0.0
40    
41    for T_i in payment_dates:
42        # Strike price for bond option
43        K_i = model.bond_price(T_option, T_i, r_star)
44        
45        # Volatility of bond price
46        B = (1 - np.exp(-model.kappa * (T_i - T_option))) / model.kappa
47        sigma_P = model.sigma * B * np.sqrt((1 - np.exp(-2 * model.kappa * T_option)) / 
48                                            (2 * model.kappa))
49        
50        # Current bond price
51        P_0_Ti = model.curve.discount_factor(T_i)
52        
53        # Black's formula for bond option
54        d1 = (np.log(P_0_Ti / K_i) + 0.5 * sigma_P**2 * T_option) / \
55             (sigma_P * np.sqrt(T_option))
56        d2 = d1 - sigma_P * np.sqrt(T_option)
57        
58        option_value = P_0_Ti * norm.cdf(d1) - K_i * norm.cdf(d2)
59        
60        swaption_value += strike * dt * option_value
61    
62    return notional * swaption_value
63
64# Example
65swaption_price = swaption_price_hw(hw, T_option=1.0, T_start=1.0, 
66                                   T_end=6.0, strike=0.05)
67print(f"Payer swaption price: {swaption_price:.6f}")
68

5. Heath-Jarrow-Morton (HJM) Framework#

Key insight: Model the entire forward rate curve, not just the short rate.

Forward rate dynamics: df(t,T)=α(t,T)dt+σ(t,T)dWtdf(t, T) = \alpha(t, T) dt + \sigma(t, T) dW_tdf(t,T)=α(t,T)dt+σ(t,T)dWt​

No-arbitrage condition (drift restriction): α(t,T)=σ(t,T)∫tTσ(t,s)ds\alpha(t, T) = \sigma(t, T) \int_t^T \sigma(t, s) dsα(t,T)=σ(t,T)∫tT​σ(t,s)ds

This ensures consistency with bond price dynamics.

python
1class HJMModel:
2    """Heath-Jarrow-Morton framework."""
3    
4    def __init__(self, sigma_func: callable, initial_curve: YieldCurve):
5        """
6        Args:
7            sigma_func: Volatility function sigma(t, T)
8            initial_curve: Initial forward rate curve
9        """
10        self.sigma = sigma_func
11        self.curve = initial_curve
12    
13    def drift(self, t: float, T: float) -> float:
14        """
15        Compute drift alpha(t, T) from no-arbitrage condition.
16        
17        alpha(t, T) = sigma(t, T) * integral_t^T sigma(t, s) ds
18        """
19        # Numerical integration
20        from scipy.integrate import quad
21        
22        sigma_tT = self.sigma(t, T)
23        integral, _ = quad(lambda s: self.sigma(t, s), t, T)
24        
25        return sigma_tT * integral
26    
27    def simulate_forward_curve(
28        self,
29        T_max: float,
30        n_time_steps: int,
31        n_maturity_points: int
32    ) -> np.ndarray:
33        """
34        Simulate evolution of forward rate curve.
35        
36        Returns:
37            Array of shape (n_time_steps + 1, n_maturity_points)
38        """
39        dt = T_max / n_time_steps
40        sqrt_dt = np.sqrt(dt)
41        
42        # Maturity grid
43        maturities = np.linspace(0, T_max, n_maturity_points)
44        
45        # Initialize forward curve
46        f = np.zeros((n_time_steps + 1, n_maturity_points))
47        for i, T in enumerate(maturities):
48            f[0, i] = self.curve.instantaneous_forward(T)
49        
50        # Simulate
51        t = np.linspace(0, T_max, n_time_steps + 1)
52        
53        for i in range(n_time_steps):
54            dW = np.random.normal(0, sqrt_dt)
55            
56            for j, T in enumerate(maturities):
57                if T > t[i]:  # Only simulate forward rates in the future
58                    alpha = self.drift(t[i], T)
59                    sigma = self.sigma(t[i], T)
60                    
61                    f[i+1, j] = f[i, j] + alpha * dt + sigma * dW
62        
63        return f
64
65# Example: Constant volatility HJM
66def sigma_constant(t, T):
67    return 0.01  # 1% volatility
68
69hjm = HJMModel(sigma_constant, curve)
70forward_curves = hjm.simulate_forward_curve(T_max=5, n_time_steps=100, 
71                                            n_maturity_points=50)
72
73# Plot evolution
74fig = plt.figure(figsize=(14, 6))
75ax = fig.add_subplot(111, projection='3d')
76
77t_grid = np.linspace(0, 5, 101)
78T_grid = np.linspace(0, 5, 50)
79T_mesh, t_mesh = np.meshgrid(T_grid, t_grid)
80
81ax.plot_surface(t_mesh, T_mesh, forward_curves, cmap='viridis', alpha=0.8)
82ax.set_xlabel('Time t')
83ax.set_ylabel('Maturity T')
84ax.set_zlabel('Forward Rate f(t, T)')
85ax.set_title('HJM Forward Rate Surface Evolution')
86plt.show()
87

6. LIBOR Market Model (BGM)#

Models forward LIBOR rates directly (market observables).

Forward LIBOR L(t,Ti,Ti+1)L(t, T_i, T_{i+1})L(t,Ti​,Ti+1​) satisfies: dLi(t)=σi(t)Li(t)dWiQTi+1(t)dL_i(t) = \sigma_i(t) L_i(t) dW_i^{\mathbb{Q}^{T_{i+1}}}(t)dLi​(t)=σi​(t)Li​(t)dWiQTi+1​​(t)

under the Ti+1T_{i+1}Ti+1​-forward measure.

Caplet pricing: Closed-form via Black's formula.

python
1class LIBORMarketModel:
2    """LIBOR Market Model (BGM)."""
3    
4    def __init__(
5        self,
6        initial_forwards: np.ndarray,
7        volatilities: np.ndarray,
8        correlations: np.ndarray,
9        tenors: np.ndarray
10    ):
11        """
12        Args:
13            initial_forwards: Initial forward LIBOR rates
14            volatilities: Volatility for each forward rate
15            correlations: Correlation matrix between rates
16            tenors: Tenor structure (e.g., [0.25, 0.5, 0.75, ...])
17        """
18        self.L0 = initial_forwards
19        self.sigma = volatilities
20        self.rho = correlations
21        self.tenors = tenors
22        self.n_rates = len(initial_forwards)
23        
24        # Cholesky decomposition for correlated Brownian motions
25        self.chol = np.linalg.cholesky(correlations)
26    
27    def simulate(
28        self,
29        T: float,
30        n_steps: int,
31        n_paths: int = 1
32    ) -> np.ndarray:
33        """
34        Simulate LIBOR rates using log-normal dynamics.
35        
36        Returns:
37            Array of shape (n_paths, n_steps + 1, n_rates)
38        """
39        dt = T / n_steps
40        sqrt_dt = np.sqrt(dt)
41        
42        L = np.zeros((n_paths, n_steps + 1, self.n_rates))
43        L[:, 0, :] = self.L0
44        
45        for step in range(n_steps):
46            # Generate correlated normal samples
47            Z = np.random.normal(0, 1, (n_paths, self.n_rates))
48            dW = (self.chol @ Z.T).T * sqrt_dt
49            
50            # Update each forward rate
51            for i in range(self.n_rates):
52                # Drift correction for measure change
53                drift = 0.0
54                for j in range(i+1, self.n_rates):
55                    delta = self.tenors[j] - self.tenors[j-1]
56                    drift += (delta * self.sigma[j] * L[step, j] * 
57                             self.rho[i, j] * self.sigma[i]) / \
58                             (1 + delta * L[step, j])
59                
60                # Log-normal update
61                L[:, step+1, i] = L[:, step, i] * np.exp(
62                    (drift - 0.5 * self.sigma[i]**2) * dt + 
63                    self.sigma[i] * dW[:, i]
64                )
65        
66        return L
67    
68    def caplet_price(
69        self,
70        strike: float,
71        maturity_idx: int,
72        notional: float = 1.0
73    ) -> float:
74        """
75        Price caplet using Black's formula.
76        
77        Caplet pays: delta * max(L(T_i) - K, 0) at time T_{i+1}
78        """
79        from scipy.stats import norm
80        
81        T = self.tenors[maturity_idx]
82        delta = self.tenors[maturity_idx+1] - self.tenors[maturity_idx]
83        L0 = self.L0[maturity_idx]
84        sigma = self.sigma[maturity_idx]
85        
86        # Black's formula
87        d1 = (np.log(L0 / strike) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
88        d2 = d1 - sigma * np.sqrt(T)
89        
90        # Discount factor (approximate)
91        df = 1 / (1 + L0 * delta)
92        
93        return notional * delta * df * (L0 * norm.cdf(d1) - strike * norm.cdf(d2))
94
95# Example
96n_rates = 20
97tenors = np.linspace(0.25, 5.0, n_rates)
98initial_forwards = 0.03 * np.ones(n_rates)  # Flat 3% curve
99volatilities = 0.2 * np.ones(n_rates)  # 20% vol
100correlations = np.eye(n_rates) * 0.7 + 0.3  # 70% correlation
101
102lmm = LIBORMarketModel(initial_forwards, volatilities, correlations, tenors)
103
104# Simulate
105paths = lmm.simulate(T=5.0, n_steps=100, n_paths=1000)
106
107# Price a caplet
108caplet_price = lmm.caplet_price(strike=0.04, maturity_idx=10)
109print(f"Caplet price: {caplet_price:.6f}")
110

7. Model Calibration#

7.1 Calibration to Swaptions#

Minimize pricing error between model and market swaption prices.

python
1def calibrate_hull_white_to_swaptions(
2    market_swaption_prices: np.ndarray,
3    swaption_maturities: np.ndarray,
4    swap_tenors: np.ndarray,
5    strikes: np.ndarray,
6    yield_curve: YieldCurve
7) -> dict:
8    """
9    Calibrate Hull-White model to market swaption prices.
10    
11    Optimize kappa and sigma to minimize pricing error.
12    """
13    def objective(params):
14        kappa, sigma = params
15        model = HullWhiteModel(kappa, sigma, yield_curve)
16        
17        errors = []
18        for i, (T_opt, tenor, K) in enumerate(zip(swaption_maturities, 
19                                                   swap_tenors, strikes)):
20            model_price = swaption_price_hw(model, T_opt, T_opt, 
21                                           T_opt + tenor, K)
22            market_price = market_swaption_prices[i]
23            errors.append((model_price - market_price)**2)
24        
25        return np.sum(errors)
26    
27    from scipy.optimize import minimize
28    
29    # Initial guess
30    x0 = [0.1, 0.01]
31    bounds = [(0.01, 1.0), (0.001, 0.1)]
32    
33    result = minimize(objective, x0, bounds=bounds, method='L-BFGS-B')
34    
35    return {
36        'kappa': result.x[0],
37        'sigma': result.x[1],
38        'rmse': np.sqrt(result.fun / len(market_swaption_prices))
39    }
40

8. Production Considerations#

8.1 Performance Optimization#

cpp
1// C++ implementation for production speed
2#include <vector>
3#include <cmath>
4#include <Eigen/Dense>
5
6class HullWhiteModelCpp {
7private:
8    double kappa_;
9    double sigma_;
10    std::vector<double> forward_curve_;
11    
12public:
13    HullWhiteModelCpp(double kappa, double sigma, 
14                     const std::vector<double>& forwards)
15        : kappa_(kappa), sigma_(sigma), forward_curve_(forwards) {}
16    
17    double bondPrice(double t, double T, double r_t) const {
18        double tau = T - t;
19        double B = (1.0 - std::exp(-kappa_ * tau)) / kappa_;
20        
21        // Simplified A calculation
22        double f_t = interpolateForward(t);
23        double log_A = -B * f_t - (sigma_ * sigma_ / (4.0 * kappa_)) * 
24                       B * B * (1.0 - std::exp(-2.0 * kappa_ * t));
25        
26        return std::exp(log_A - B * r_t);
27    }
28    
29    std::vector<std::vector<double>> simulate(
30        double T, int n_steps, int n_paths
31    ) {
32        // Vectorized simulation using Eigen
33        // 10x faster than Python
34        // ... implementation
35    }
36    
37private:
38    double interpolateForward(double t) const {
39        // Linear interpolation
40        // ... implementation
41    }
42};
43

8.2 Numerical Stability#

  • CIR: Use full truncation or exact simulation (Broadie-Kaya)
  • Hull-White: Cache theta function evaluations
  • LMM: Use predictor-corrector for drift calculation

9. Production Checklist#

  • Implement analytical solutions where available (Vasicek, CIR)
  • Use exact simulation for CIR (avoid discretization bias)
  • Cache yield curve interpolations (expensive)
  • Implement Greeks (delta, gamma, vega) via finite differences or adjoint
  • Validate against market data (swaptions, caps/floors)
  • Add convergence tests for Monte Carlo
  • Profile and optimize hot paths (C++ for inner loops)
  • Implement parallel simulation (multi-threading)
  • Handle negative rates (shifted models)
  • Document model assumptions and limitations

References#

  1. Brigo, D. & Mercurio, F. (2006). Interest Rate Models - Theory and Practice. Springer.
  2. Andersen, L. & Piterbarg, V. (2010). Interest Rate Modeling. Atlantic Financial Press.
  3. Rebonato, R. (2002). Modern Pricing of Interest-Rate Derivatives. Princeton.
  4. Glasserman, P. (2003). Monte Carlo Methods in Financial Engineering. Springer.

Interest rate modeling is essential for fixed income derivatives. Start with Vasicek for intuition, use Hull-White for market calibration, and LMM for exotic derivatives. Always validate against market swaption prices and implement in C++ for production performance.

NT

NordVarg Team

Technical Writer

NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.

interest-ratesVasicekCIRHull-WhiteHJM

Join 1,000+ Engineers

Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.

✓Weekly articles
✓Industry insights
✓No spam, ever

Related Posts

Nov 25, 2025•8 min read
Principal Component Analysis for Yield Curves and Volatility Surfaces
Quantitative FinancePCAyield-curve
Nov 25, 2025•9 min read
Kalman Filtering for State-Space Models in Finance
Quantitative FinanceKalman-filterstate-space-models
Nov 26, 2025•11 min read
GPU-Accelerated Portfolio Optimization: When 10 Hours Becomes 10 Seconds
Quantitative Financeportfolio-optimizationGPU

Interested in working together?