Skip to content

Heath-Jarrow-Morton Simulation (Multi-Factor)

Background

Heath-Jarrow-Morton (HJM) Forward Rate Model - Multi-Factor Implementation

This module implements the Heath-Jarrow-Morton framework for forward rate modeling. The HJM model directly specifies the dynamics of the entire forward rate curve, ensuring no-arbitrage by construction.

The forward rate dynamics are: df(t, T) = mu(t, T) dt + sum_j sigma_j(t, T) dW_j(t)

where the drift mu must satisfy the no-arbitrage condition: mu(t, T) = sum_j sigma_j(t, T) * integral_t^T sigma_j(t, s) ds

Key features:

  • Multi-factor specification with independent Brownian motions
  • No-arbitrage condition enforced via drift specification
  • Forward rate curve evolution
  • Implied zero-coupon bond prices
  • Non-recombining tree structure

Based on: QuantPie Lecture Notes


Code

```python """ Heath-Jarrow-Morton (HJM) Forward Rate Model - Multi-Factor Implementation

This module implements the Heath-Jarrow-Morton framework for forward rate modeling. The HJM model directly specifies the dynamics of the entire forward rate curve, ensuring no-arbitrage by construction.

The forward rate dynamics are: df(t, T) = mu(t, T) dt + sum_j sigma_j(t, T) dW_j(t)

where the drift mu must satisfy the no-arbitrage condition: mu(t, T) = sum_j sigma_j(t, T) * integral_t^T sigma_j(t, s) ds

Key features: - Multi-factor specification with independent Brownian motions - No-arbitrage condition enforced via drift specification - Forward rate curve evolution - Implied zero-coupon bond prices - Non-recombining tree structure

Based on: QuantPie Lecture Notes """

import numpy as np import matplotlib.pyplot as plt from scipy.integrate import quad from scipy.stats import norm

======================================================================

def f(P, T): """ Extract forward rate from zero-coupon bond prices.

Parameters
----------
P : callable
    ZCB price function P(0, T)
T : float
    Maturity time

Returns
-------
float
    Forward rate f(0, T)

Notes
-----
f(0, T) = -d/dT log(P(0, T))
Computed via finite difference
"""
dT = 1e-6
if T <= dT:
    log_P_plus = np.log(P(T + dT))
    log_P_current = np.log(P(T))
    return -(log_P_plus - log_P_current) / dT
else:
    log_P_minus = np.log(P(T - dT))
    log_P_plus = np.log(P(T + dT))
    return -(log_P_plus - log_P_minus) / (2 * dT)

def df_over_dT(P, T): """ Compute derivative of forward rate with respect to maturity.

Parameters
----------
P : callable
    ZCB price function
T : float
    Maturity time

Returns
-------
float
    df/dT(0, T)
"""
dT = 1e-6
f_minus = f(P, T - dT)
f_plus = f(P, T + dT)
return (f_plus - f_minus) / (2 * dT)

def compute_r0(P): """ Extract initial short rate from yield curve.

Parameters
----------
P : callable
    ZCB price function P(0, T)

Returns
-------
float
    r(0) = f(0, 0)
"""
return f(P, 0.0)

class HJMModel: """ Heath-Jarrow-Morton Multi-Factor Forward Rate Model.

The model directly specifies forward rate dynamics under the no-arbitrage condition.

Parameters
----------
P_0 : callable
    Initial zero-coupon bond price function P(0, T)
sigma_funcs : list of callable
    List of volatility functions sigma_j(t, T) for each factor
num_factors : int
    Number of factors (len(sigma_funcs))
"""

def __init__(self, P_0, sigma_funcs, num_factors=2):
    """Initialize HJM model."""
    self.P_0 = P_0
    self.sigma_funcs = sigma_funcs
    self.num_factors = num_factors

    # Extract initial forward rate curve
    self.r0 = compute_r0(P_0)

def _compute_drift(self, t, T):
    """
    Compute drift (mu) satisfying no-arbitrage condition.

    Parameters
    ----------
    t : float
        Current time
    T : float
        Forward rate maturity

    Returns
    -------
    float
        mu(t, T)

    Notes
    -----
    No-arbitrage condition:
    mu(t, T) = sum_j sigma_j(t, T) * integral_t^T sigma_j(t, s) ds
    """
    mu = 0.0

    for j in range(self.num_factors):
        # sigma_j at (t, T)
        sigma_jT = self.sigma_funcs[j](t, T)

        # Integral of sigma_j from t to T
        def integrand(s):
            return self.sigma_funcs[j](t, s)

        integral, _ = quad(integrand, t, T, limit=100)

        # Accumulate drift
        mu += sigma_jT * integral

    return mu

def _compute_volatilities(self, t, T):
    """
    Compute volatilities sigma_j(t, T) for all factors.

    Parameters
    ----------
    t : float
        Current time
    T : float
        Forward rate maturity

    Returns
    -------
    ndarray
        Volatilities for each factor (num_factors,)
    """
    sigmas = np.zeros(self.num_factors)
    for j in range(self.num_factors):
        sigmas[j] = self.sigma_funcs[j](t, T)
    return sigmas

def generate_forward_paths(self, T_horizon, T_maturities, num_steps, num_paths, seed=None):
    """
    Generate sample paths for forward rates.

    Parameters
    ----------
    T_horizon : float
        Time horizon for simulation
    T_maturities : ndarray
        Maturity times for which to track forward rates
    num_steps : int
        Number of time steps
    num_paths : int
        Number of Monte Carlo paths
    seed : int, optional
        Random seed

    Returns
    -------
    t : ndarray
        Time grid (num_steps + 1,)
    F : ndarray
        Forward rate paths (num_paths, num_steps + 1, len(T_maturities))
    R : ndarray
        Short rate paths (num_paths, num_steps + 1)
    """
    if seed is not None:
        np.random.seed(seed)

    # Time grid
    t = np.linspace(0, T_horizon, num_steps + 1)
    dt = t[1] - t[0]
    sqrt_dt = np.sqrt(dt)

    # Number of maturities to track
    n_maturities = len(T_maturities)

    # Initialize arrays
    F = np.zeros((num_paths, num_steps + 1, n_maturities))
    R = np.zeros((num_paths, num_steps + 1))

    # Set initial forward rates
    for m_idx, T_m in enumerate(T_maturities):
        F[:, 0, m_idx] = f(self.P_0, T_m)
    R[:, 0] = self.r0

    # Generate Brownian increments
    dW = np.random.normal(0, 1, (num_paths, num_steps, self.num_factors))

    # Simulation loop
    for i in range(num_steps):
        t_current = t[i]

        for m_idx, T_m in enumerate(T_maturities):
            # Skip if maturity has passed
            if T_m <= t_current:
                if m_idx == 0:
                    F[:, i+1, m_idx] = R[:, i]
                continue

            # Compute drift and volatilities
            mu = self._compute_drift(t_current, T_m)
            sigmas = self._compute_volatilities(t_current, T_m)

            # Forward rate increment
            df = mu * dt + np.dot(sigmas, dW[:, i, :].T) * sqrt_dt

            # Update forward rate
            F[:, i+1, m_idx] = F[:, i, m_idx] + df

        # Short rate is the forward rate at t with maturity t (f(t, t) = r(t))
        # Approximate with forward rate with shortest maturity
        if n_maturities > 0:
            dt_mat = T_maturities[0]
            if dt_mat > 1e-6:
                # Interpolate/extrapolate to get r(t)
                R[:, i+1] = F[:, i+1, 0]
            else:
                R[:, i+1] = R[:, i]

    return t, F, R

def compute_zcb_price(self, t, T, forward_rates, T_maturities):
    """
    Compute zero-coupon bond price from forward rates.

    Parameters
    ----------
    t : float
        Current time
    T : float
        Bond maturity
    forward_rates : ndarray
        Forward rates f(t, s) at current time
    T_maturities : ndarray
        Maturity times for forward rates

    Returns
    -------
    float
        P(t, T)

    Notes
    -----
    P(t, T) = exp(-integral_t^T f(t, s) ds)
    """
    if T <= t:
        return 1.0

    # Integrate forward rates using trapezoidal rule
    # Find indices in T_maturities between t and T
    valid_mask = (T_maturities >= t) & (T_maturities <= T)
    valid_maturities = T_maturities[valid_mask]
    valid_forward_rates = forward_rates[valid_mask]

    if len(valid_maturities) == 0:
        # Extrapolate with first available forward rate
        integral = forward_rates[0] * (T - t)
    else:
        # Numerical integration via trapezoidal rule
        integral = np.trapz(valid_forward_rates, valid_maturities)

    return np.exp(-integral)

def main(): """ Demonstrate HJM multi-factor model with forward rate path simulation. """ print("=" * 70) print("Heath-Jarrow-Morton (HJM) Multi-Factor Model Demonstration") print("=" * 70)

# Simulation parameters
T_horizon = 5.0
num_steps = 50
num_paths = 500

# Model parameters
r0 = 0.05  # Initial short rate (5%)

# Flat initial yield curve
def P_0(T):
    return np.exp(-r0 * T)

print(f"\nInitial short rate r(0) = {r0:.4f}")
print(f"Initial yield curve: flat at {r0*100:.2f}%")
print()

# Define volatility functions for each factor
# Factor 1: slower decay with maturity
def sigma_1(t, T):
    return 0.010 * np.exp(-0.1 * (T - t))

# Factor 2: faster decay with maturity
def sigma_2(t, T):
    return 0.005 * np.exp(-0.5 * (T - t))

sigma_funcs = [sigma_1, sigma_2]
num_factors = len(sigma_funcs)

print(f"Number of factors: {num_factors}")
print(f"  Factor 1: sigma_1(t,T) = 0.010 * exp(-0.1*(T-t))")
print(f"  Factor 2: sigma_2(t,T) = 0.005 * exp(-0.5*(T-t))")
print()

# Create HJM model
print("Creating HJM model...")
hjm = HJMModel(P_0, sigma_funcs, num_factors)

# Maturities to track
T_maturities = np.array([1.0, 2.0, 3.0, 5.0, 7.0, 10.0])

# Generate paths
print("Generating forward rate paths...")
t_grid, F_paths, R_paths = hjm.generate_forward_paths(
    T_horizon, T_maturities, num_steps, num_paths, seed=42
)

print(f"  Generated {num_paths} paths with {num_steps} steps")
print(f"  Time horizon: {T_horizon} years")
print(f"  Tracked maturities: {T_maturities}")
print()

# Statistics
mean_r = np.mean(R_paths, axis=0)
std_r = np.std(R_paths, axis=0)

print(f"Short rate statistics at T={T_horizon}:")
print(f"  Mean r(T):     {mean_r[-1]:.6f}")
print(f"  Std r(T):      {std_r[-1]:.6f}")
print(f"  Min r(T):      {R_paths[:, -1].min():.6f}")
print(f"  Max r(T):      {R_paths[:, -1].max():.6f}")
print()

# Forward rate evolution
print("Forward rates at T=0 (initial curve):")
for m_idx, T_m in enumerate(T_maturities):
    print(f"  f(0, {T_m:.1f}) = {F_paths[0, 0, m_idx]:.6f}")
print()

# Visualization
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Plot 1: Short rate paths
ax = axes[0, 0]
sample_indices = np.arange(0, num_paths, max(1, num_paths // 50))
for idx in sample_indices:
    ax.plot(t_grid, R_paths[idx, :], alpha=0.3, linewidth=0.8)
ax.plot(t_grid, mean_r, 'r-', linewidth=2, label='Mean')
ax.fill_between(t_grid, mean_r - std_r, mean_r + std_r,
                 alpha=0.2, color='red', label='Mean +/- 1 Std')
ax.set_xlabel('Time (years)')
ax.set_ylabel('Short rate r(t)')
ax.set_title('Short Rate Paths')
ax.legend()
ax.grid(True, alpha=0.3)

# Plot 2: Terminal short rate distribution
ax = axes[0, 1]
ax.hist(R_paths[:, -1], bins=40, density=True, alpha=0.7, edgecolor='black')
ax.axvline(mean_r[-1], color='r', linestyle='--', linewidth=2, label='Mean')
ax.set_xlabel('Short rate r(T)')
ax.set_ylabel('Density')
ax.set_title(f'Terminal Distribution of r(T) at T={T_horizon}')
ax.legend()
ax.grid(True, alpha=0.3)

# Plot 3: Forward rate evolution (mean)
ax = axes[1, 0]
for m_idx, T_m in enumerate(T_maturities):
    mean_forward = np.mean(F_paths[:, :, m_idx], axis=0)
    ax.plot(t_grid, mean_forward, label=f'f(t, {T_m:.1f})', linewidth=2)
ax.set_xlabel('Time (years)')
ax.set_ylabel('Forward rate')
ax.set_title('Evolution of Mean Forward Rates')
ax.legend(loc='best', fontsize=9)
ax.grid(True, alpha=0.3)

# Plot 4: Yield curve at different times
ax = axes[1, 1]
time_points_to_plot = [0, num_steps // 2, num_steps]
colors = ['blue', 'green', 'red']
labels = [f't={t_grid[tp]:.2f}' for tp in time_points_to_plot]

for tp_idx, tp in enumerate(time_points_to_plot):
    mean_forwards = np.mean(F_paths[:, tp, :], axis=0)
    ax.plot(T_maturities, mean_forwards, 'o-', color=colors[tp_idx],
            label=labels[tp_idx], linewidth=2, markersize=6)

ax.set_xlabel('Maturity (years)')
ax.set_ylabel('Forward rate')
ax.set_title('Forward Rate Curve at Different Times')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('hjm_simulation_multi_factor.png', dpi=150, bbox_inches='tight')
print("Figure saved as 'hjm_simulation_multi_factor.png'")
plt.show()

if name == 'main': main() ```

Exercises

Exercise 1. The HJM no-arbitrage drift condition requires \(\mu(t,T) = \sum_j \sigma_j(t,T)\int_t^T \sigma_j(t,s)\,ds\). For a single-factor model with \(\sigma(t,T) = \sigma_0\,e^{-\lambda(T-t)}\), derive the drift explicitly.

Solution to Exercise 1

The integral is

\[ \int_t^T \sigma_0\,e^{-\lambda(s-t)}\,ds = \sigma_0\,\frac{1 - e^{-\lambda(T-t)}}{\lambda}. \]

The drift is

\[ \mu(t,T) = \sigma_0\,e^{-\lambda(T-t)} \times \sigma_0\,\frac{1 - e^{-\lambda(T-t)}}{\lambda} = \frac{\sigma_0^2}{\lambda}\,e^{-\lambda(T-t)}(1 - e^{-\lambda(T-t)}). \]

This drift ensures the HJM model is arbitrage-free. Note that it depends on the maturity \(T\) through the factor \(e^{-\lambda(T-t)}\), showing that different maturities have different drift rates.


Exercise 2. In a two-factor HJM model with volatility functions \(\sigma_1(t,T) = \sigma_1\) and \(\sigma_2(t,T) = \sigma_2(T-t)\), describe the types of yield curve movements each factor captures.

Solution to Exercise 2
  • Factor 1 (\(\sigma_1\) constant): Generates parallel shifts of the forward rate curve. All maturities move by the same amount, capturing the "level" component of yield curve dynamics.
  • Factor 2 (\(\sigma_2(T-t)\) proportional to time to maturity): Generates tilting (steepening/flattening) of the curve. Long-maturity forwards move more than short-maturity forwards, capturing the "slope" component.

Together, these two factors explain the majority of observed yield curve variability (typically \(85-95\%\) according to principal component analysis of historical data).


Exercise 3. Explain why the HJM framework models the entire forward rate curve simultaneously, and what advantage this has over short-rate models like Vasicek or CIR.

Solution to Exercise 3

Short-rate models specify only the dynamics of \(r(t)\) and derive the term structure from the short rate. The resulting yield curve shape is constrained by the model's parametric form. The HJM framework directly specifies \(df(t,T)\) for all maturities \(T\), giving much greater flexibility to match observed yield curve dynamics and volatility term structures.

The advantage is that HJM can reproduce any volatility structure \(\sigma(t,T)\) observed in the market, while short-rate models are limited to specific functional forms. The disadvantage is that HJM models are generally non-Markovian (unless special volatility structures are chosen, like the exponential form that recovers Hull-White).


Exercise 4. The bond price in the HJM framework is \(P(t,T) = \exp\!\left(-\int_t^T f(t,u)\,du\right)\). If the forward curve at time \(t\) is flat at \(f(t,T) = 5\%\) for all \(T\), compute \(P(t, t+5)\).

Solution to Exercise 4
\[ P(t, t+5) = \exp\!\left(-\int_t^{t+5} 0.05\,du\right) = \exp(-0.05 \times 5) = e^{-0.25} \approx 0.7788. \]

A flat forward curve corresponds to a flat yield curve, and the bond price is simply \(e^{-r\tau}\) as in the simple discounting formula.