Short Rate Simulation¶
Background¶
Hull-White Short Rate Model Simulation
This module implements the one-factor Hull-White interest rate model. The Hull-White model is a Gaussian short-rate model that extends Vasicek by allowing time-dependent parameters.
The SDE is: dr(t) = [theta(t) - lambda * r(t)] dt + sigma * dW(t) where theta(t) is chosen to match the initial yield curve
Key features:
- Analytical zero-coupon bond prices (ZCB)
- Closed-form option prices on bonds
- Calibration to market yield curve
- Cap/floor pricing
Based on: QuantPie Lecture Notes
Code¶
```python """ Hull-White Short Rate Model Simulation
This module implements the one-factor Hull-White interest rate model. The Hull-White model is a Gaussian short-rate model that extends Vasicek by allowing time-dependent parameters.
The SDE is: dr(t) = [theta(t) - lambda * r(t)] dt + sigma * dW(t) where theta(t) is chosen to match the initial yield curve
Key features: - Analytical zero-coupon bond prices (ZCB) - Closed-form option prices on bonds - Calibration to market yield curve - Cap/floor pricing
Based on: QuantPie Lecture Notes """
import numpy as np import matplotlib.pyplot as plt from scipy.integrate import quad from scipy.stats import norm from scipy.optimize import brentq from enum import Enum
======================================================================¶
class OptionType(Enum): """Enumeration for option types.""" CALL = 1 PUT = -1
class OptionTypeSwap(Enum): """Enumeration for swaption types.""" PAYER = 1 RECEIVER = -1
def f(P, T): """ Extract forward rate from zero-coupon bond prices.
Parameters
----------
P : callable
ZCB price function P(t, T) where t is fixed
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
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)
Notes
-----
r(0) = f(0, 0) = -d/dT log(P(0, T))|_{T=0}
"""
return f(P, 0.0)
def compute_theta(P, T, lambd, sigma): """ Compute time-dependent theta for Hull-White calibration.
Parameters
----------
P : callable
ZCB price function
T : float
Time
lambd : float
Mean reversion rate (lambda)
sigma : float
Volatility
Returns
-------
float
theta(T)
Notes
-----
theta(T) = df/dT(0,T) + lambda*f(0,T) + sigma^2/(2*lambda^2) * (1 - exp(-2*lambda*T))^2
"""
f_T = f(P, T)
df_dT = df_over_dT(P, T)
if lambd > 1e-8:
sigma_term = sigma**2 / (2 * lambd**2) * (1 - np.exp(-2 * lambd * T))**2
else:
sigma_term = sigma**2 * T**2 / 2
return df_dT + lambd * f_T + sigma_term
def compute_theta_T(theta_func, T): """ Wrapper for theta function at time T.
Parameters
----------
theta_func : callable
Theta function theta(T)
T : float
Time
Returns
-------
float
theta(T)
"""
return theta_func(T)
def compute_sigma_P(sigma, lambd, T, U): """ Compute bond price volatility for option pricing.
Parameters
----------
sigma : float
Short rate volatility
lambd : float
Mean reversion rate
T : float
Option expiration
U : float
Bond maturity (U > T)
Returns
-------
float
sigma_P
Notes
-----
sigma_P = sigma/lambda * (1 - exp(-lambda*(U-T)))
Volatility of bond price at time T
"""
if lambd > 1e-8:
return sigma / lambd * (1 - np.exp(-lambd * (U - T)))
else:
return sigma * (U - T)
def compute_mu_r_T(r_0, lambd, T): """ Compute mean of short rate under risk-neutral measure.
Parameters
----------
r_0 : float
Initial short rate
lambd : float
Mean reversion rate
T : float
Time
Returns
-------
float
E[r(T)]
Notes
-----
E[r(T)] = f(0, T) + sigma^2/(2*lambda^2) * (1 - exp(-lambda*T))^2
"""
return r_0
def compute_mu_r_T_ForwardMeasure(theta_func, lambd, T, U): """ Compute mean of short rate under forward measure (T, U).
Parameters
----------
theta_func : callable
Theta function
lambd : float
Mean reversion rate
T : float
Current time
U : float
Maturity of bond
Returns
-------
float
Mean under forward measure
"""
# Integral of theta from 0 to T
def integrand(s):
return compute_theta_T(theta_func, s) * np.exp(lambd * (s - T))
integral, _ = quad(integrand, 0, T)
return np.exp(-lambd * T) * integral
def compute_sigma_square_r_T(sigma, lambd, T): """ Compute variance of short rate.
Parameters
----------
sigma : float
Short rate volatility
lambd : float
Mean reversion rate
T : float
Time
Returns
-------
float
Var[r(T)]
Notes
-----
Var[r(T)] = sigma^2/(2*lambda) * (1 - exp(-2*lambda*T))
"""
if lambd > 1e-8:
return sigma**2 / (2 * lambd) * (1 - np.exp(-2 * lambd * T))
else:
return sigma**2 * T
def compute_A(P_0, sigma, lambd, T, U): """ Compute A coefficient for ZCB pricing formula.
Parameters
----------
P_0 : callable
Initial ZCB price P(0, S)
sigma : float
Volatility
lambd : float
Mean reversion rate
T : float
Current time
U : float
Bond maturity
Returns
-------
float
A(T, U)
Notes
-----
Bond price: P(t, U) = A(t, U) * exp(-B(t, U) * r(t))
"""
B_TU = compute_B(lambd, T, U)
sigma_P_TU = compute_sigma_P(sigma, lambd, T, U)
# A = P(0,U) / P(0,T) * exp(sigma_P^2 / 4*lambda^2 * (exp(-2*lambda*T) - 1)^2)
# Simplified computation
if lambd > 1e-8:
exp_term = np.exp(sigma_P_TU**2 / (2 * lambd) * (1 - np.exp(-2 * lambd * T)))
else:
exp_term = 1.0
return P_0(U) / P_0(T) * exp_term
def compute_B(lambd, T, U): """ Compute B coefficient for ZCB pricing formula.
Parameters
----------
lambd : float
Mean reversion rate
T : float
Current time
U : float
Bond maturity
Returns
-------
float
B(T, U)
Notes
-----
B(t, U) = 1/lambda * (1 - exp(-lambda*(U-t)))
"""
if lambd > 1e-8:
return 1.0 / lambd * (1 - np.exp(-lambd * (U - T)))
else:
return U - T
def compute_ZCB(A_T_U, B_T_U, r_T): """ Compute zero-coupon bond price.
Parameters
----------
A_T_U : float
A coefficient
B_T_U : float
B coefficient
r_T : float
Short rate at time T
Returns
-------
float
P(T, U)
"""
return A_T_U * np.exp(-B_T_U * r_T)
def generate_sample_paths(r0, theta_func, lambd, sigma, T, num_steps, num_paths, seed=None): """ Generate Hull-White short rate paths via Euler discretization.
Parameters
----------
r0 : float
Initial short rate
theta_func : callable
Theta function theta(t)
lambd : float
Mean reversion rate
sigma : float
Volatility
T : float
Final time
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,)
R : ndarray
Short rates (num_paths, num_steps + 1)
M : ndarray
Money market account (num_paths, num_steps + 1)
"""
if seed is not None:
np.random.seed(seed)
# Time grid
t = np.linspace(0, T, num_steps + 1)
dt = t[1] - t[0]
sqrt_dt = np.sqrt(dt)
# Initialize
R = np.zeros((num_paths, num_steps + 1))
M = np.ones((num_paths, num_steps + 1))
R[:, 0] = r0
# Brownian increments
Z = np.random.normal(0, 1, (num_paths, num_steps))
# Euler discretization: dr = (theta - lambda*r) dt + sigma dW
for i in range(num_steps):
theta_t = compute_theta_T(theta_func, t[i])
dW = sqrt_dt * Z[:, i]
# dr = (theta(t) - lambda*r(t)) dt + sigma dW
dR = (theta_t - lambd * R[:, i]) * dt + sigma * dW
R[:, i+1] = R[:, i] + dR
# dM/M = r(t) dt (money market account)
M[:, i+1] = M[:, i] * np.exp(R[:, i] * dt)
return t, R, M
def SwapPrice(fixed_rate, t_fixing, t_settlement, t_maturity, notional, P_func): """ Compute swap price given cash flows.
Parameters
----------
fixed_rate : float
Fixed rate of swap
t_fixing : ndarray
Fixing times
t_settlement : ndarray
Settlement times
t_maturity : float
Swap maturity
notional : float
Notional principal
P_func : callable
ZCB price function P(t, T)
Returns
-------
float
Swap price (fixed leg value)
"""
price = 0.0
for t_fix, t_settle in zip(t_fixing, t_settlement):
if t_settle <= t_maturity:
tau = t_settle - t_fix # day count fraction
cf = fixed_rate * tau * notional
df = P_func(t_settle)
price += cf * df
return price
def SwapPrice_HW(R_paths, M_paths, r0, theta_func, lambd, sigma, fixed_rate, t_fixing, t_settlement, t_maturity, notional, P_0, num_steps, T): """ Compute swap price under Hull-White model (Monte Carlo).
Parameters
----------
R_paths : ndarray
Short rate paths
M_paths : ndarray
Money market paths
r0 : float
Initial short rate
theta_func : callable
Theta function
lambd : float
Mean reversion
sigma : float
Volatility
fixed_rate : float
Fixed swap rate
t_fixing : ndarray
Fixing times
t_settlement : ndarray
Settlement times
t_maturity : float
Maturity
notional : float
Notional
P_0 : callable
Initial ZCB prices
num_steps : int
Number of time steps
T : float
Total time horizon
Returns
-------
float
Expected swap value
"""
num_paths = R_paths.shape[0]
swap_values = np.zeros(num_paths)
t_grid = np.linspace(0, T, num_steps + 1)
for path_idx in range(num_paths):
r_path = R_paths[path_idx, :]
m_path = M_paths[path_idx, :]
pv = 0.0
for t_fix, t_settle in zip(t_fixing, t_settlement):
if t_settle <= T:
tau = t_settle - t_fix
cf = fixed_rate * tau * notional
# Find closest time step
idx = np.argmin(np.abs(t_grid - t_settle))
discount = 1.0 / m_path[idx]
pv += cf * discount
swap_values[path_idx] = pv
return np.mean(swap_values)
def ZCBCallPutPrice(call_put_type, K, T, U, P_0, r0, sigma, lambd): """ Compute call/put option on zero-coupon bond.
Parameters
----------
call_put_type : OptionType
CALL or PUT
K : float
Strike price
T : float
Option expiration
U : float
Bond maturity (U > T)
P_0 : callable
Initial ZCB prices
r0 : float
Initial short rate
sigma : float
Volatility
lambd : float
Mean reversion
Returns
-------
float
Option price
"""
B_TU = compute_B(lambd, T, U)
sigma_P = compute_sigma_P(sigma, lambd, T, U)
# A coefficient
A_TU = compute_A(P_0, sigma, lambd, T, U)
# Forward bond price at T
P_T = A_TU * np.exp(-B_TU * r0)
# Black76 formula on bond
d1 = (np.log(P_T / K) + 0.5 * sigma_P**2) / sigma_P
d2 = d1 - sigma_P
if call_put_type == OptionType.CALL:
price = P_0(U) * norm.cdf(d1) - K * P_0(T) * norm.cdf(d2)
else: # PUT
price = K * P_0(T) * norm.cdf(-d2) - P_0(U) * norm.cdf(-d1)
return price
def CapletFloorletPrice(caplet_type, K, t_fixing, t_settlement, notional, day_count, P_0, r0, sigma, lambd): """ Compute caplet/floorlet price.
Parameters
----------
caplet_type : OptionType
CALL (caplet) or PUT (floorlet)
K : float
Strike rate
t_fixing : float
Fixing time
t_settlement : float
Settlement time
notional : float
Notional
day_count : float
Day count fraction (tau)
P_0 : callable
Initial ZCB prices
r0 : float
Initial rate
sigma : float
Volatility
lambd : float
Mean reversion
Returns
-------
float
Caplet/floorlet price
"""
tau = day_count
# Forward LIBOR: L(0, t_fixing, t_settlement)
L_0 = (P_0(t_fixing) - P_0(t_settlement)) / (tau * P_0(t_settlement))
# Volatility of LIBOR
sigma_L = sigma / (1 + K * tau)
# Black76
d1 = (np.log(L_0 / K) + 0.5 * sigma_L**2 * t_fixing) / (sigma_L * np.sqrt(t_fixing))
d2 = d1 - sigma_L * np.sqrt(t_fixing)
df = P_0(t_settlement)
if caplet_type == OptionType.CALL:
price = notional * tau * df * (L_0 * norm.cdf(d1) - K * norm.cdf(d2))
else: # FLOOR
price = notional * tau * df * (K * norm.cdf(-d2) - L_0 * norm.cdf(-d1))
return price
def CallPutPrice(call_put_type, S, K, T, r, sigma): """ Black-Scholes call/put price (for equity options).
Parameters
----------
call_put_type : OptionType
CALL or PUT
S : float
Spot price
K : float
Strike
T : float
Time to expiration
r : float
Risk-free rate
sigma : float
Volatility
Returns
-------
float
Option price
"""
d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
if call_put_type == OptionType.CALL:
price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
else: # PUT
price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
return price
def ImpliedVolatilityBlack76(market_price, F, K, T, option_type, tol=1e-6): """ Compute implied volatility using Black76 model (for interest rate options).
Parameters
----------
market_price : float
Market option price
F : float
Forward price
K : float
Strike
T : float
Time to expiration
option_type : OptionType
CALL or PUT
tol : float
Tolerance for root finding
Returns
-------
float
Implied volatility
"""
def objective(sigma):
d1 = (np.log(F / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
if option_type == OptionType.CALL:
price = F * norm.cdf(d1) - K * norm.cdf(d2)
else:
price = K * norm.cdf(-d2) - F * norm.cdf(-d1)
return price - market_price
try:
iv = brentq(objective, 0.001, 5.0)
return iv
except ValueError:
return np.nan
Shortcuts¶
def E(value, lambd, T): """Shortcut for exponential discount.""" return np.exp(-lambd * T) * value
def E1(value, lambd, T): """Shortcut for (1 - exp(-lambda*T)) / lambda.""" if lambd > 1e-8: return (1 - np.exp(-lambd * T)) / lambd * value else: return T * value
def L(sigma, lambd, T): """Shortcut for bond volatility.""" return sigma / lambd * (1 - np.exp(-lambd * T))
def main(): """ Demonstrate Hull-White model with path simulation and visualization. """ print("=" * 70) print("Hull-White Short Rate Model Demonstration") print("=" * 70)
# Parameters
T_total = 10.0
num_steps = 100
num_paths = 1000
# Model parameters
lambd = 0.1 # Mean reversion rate (10%)
sigma = 0.015 # Volatility (1.5%)
r0 = 0.05 # Initial rate (5%)
print(f"\nModel Parameters:")
print(f" Lambda (mean reversion): {lambd:.4f}")
print(f" Sigma (volatility): {sigma:.4f}")
print(f" r(0) (initial rate): {r0:.4f}")
print()
# Simple yield curve: flat at 5%
def P_0(T):
return np.exp(-r0 * T)
# Theta function (for flat curve, simplifies)
def theta_func(t):
return r0 * lambd + sigma**2 / (2 * lambd) * (1 - np.exp(-2 * lambd * t))**2
# Generate paths
print("Generating Hull-White paths...")
t_grid, R_paths, M_paths = generate_sample_paths(
r0, theta_func, lambd, sigma, T_total, num_steps, num_paths, seed=42
)
print(f" Generated {num_paths} paths with {num_steps} steps")
print(f" Time horizon: {T_total} years")
print()
# Statistics
mean_rate = np.mean(R_paths, axis=0)
std_rate = np.std(R_paths, axis=0)
print("Short rate statistics at final time T={}:".format(T_total))
print(f" Mean r(T): {mean_rate[-1]:.4f}")
print(f" Std r(T): {std_rate[-1]:.4f}")
print(f" Min r(T): {R_paths[:, -1].min():.4f}")
print(f" Max r(T): {R_paths[:, -1].max():.4f}")
print()
# Option pricing example
T_opt = 2.0 # Option expiration
U_opt = 5.0 # Bond maturity
K_opt = 0.90 # Strike
call_price = ZCBCallPutPrice(OptionType.CALL, K_opt, T_opt, U_opt, P_0, r0, sigma, lambd)
put_price = ZCBCallPutPrice(OptionType.PUT, K_opt, T_opt, U_opt, P_0, r0, sigma, lambd)
print(f"Bond Option Prices (T={T_opt}, U={U_opt}, K={K_opt}):")
print(f" Call price: {call_price:.6f}")
print(f" Put price: {put_price:.6f}")
print()
# Visualization
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Plot 1: Sample 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_rate, 'r-', linewidth=2, label='Mean')
ax.fill_between(t_grid, mean_rate - std_rate, mean_rate + std_rate,
alpha=0.2, color='red', label='Mean +/- 1 Std')
ax.set_xlabel('Time (years)')
ax.set_ylabel('Short Rate r(t)')
ax.set_title('Hull-White Short Rate Paths')
ax.legend()
ax.grid(True, alpha=0.3)
# Plot 2: Terminal distribution
ax = axes[0, 1]
ax.hist(R_paths[:, -1], bins=40, density=True, alpha=0.7, edgecolor='black')
ax.axvline(mean_rate[-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_total}')
ax.legend()
ax.grid(True, alpha=0.3)
# Plot 3: Mean and confidence bands
ax = axes[1, 0]
percentile_95 = np.percentile(R_paths, 95, axis=0)
percentile_5 = np.percentile(R_paths, 5, axis=0)
ax.plot(t_grid, mean_rate, 'b-', linewidth=2, label='Mean')
ax.fill_between(t_grid, percentile_5, percentile_95, alpha=0.3, label='5%-95%')
ax.set_xlabel('Time (years)')
ax.set_ylabel('Short Rate r(t)')
ax.set_title('Short Rate Evolution: Mean and Confidence Interval')
ax.legend()
ax.grid(True, alpha=0.3)
# Plot 4: Bond prices over time
ax = axes[1, 1]
# Compute bond prices along paths for a fixed maturity
U_fixed = 5.0
B_TU_5 = compute_B(lambd, t_grid, U_fixed)
A_TU_5 = compute_A(P_0, sigma, lambd, t_grid, U_fixed)
bond_prices = np.zeros((num_paths, len(t_grid)))
for i, t in enumerate(t_grid):
if i < len(B_TU_5):
bond_prices[:, i] = A_TU_5[i] * np.exp(-B_TU_5[i] * R_paths[:, i])
# Note: proper vectorization of compute_B and compute_A would be needed for full paths
# For demo, just plot initial values
bond_vals = np.array([compute_A(P_0, sigma, lambd, 0, U_fixed) *
np.exp(-compute_B(lambd, 0, U_fixed) * r)
for r in R_paths[:, -1]])
ax.hist(bond_vals, bins=40, density=True, alpha=0.7, edgecolor='black')
ax.set_xlabel(f'Bond Price P(T, {U_fixed})')
ax.set_ylabel('Density')
ax.set_title(f'Distribution of Bond Prices at T={T_total}')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('hull_white_short_rate_simulation.png', dpi=150, bbox_inches='tight')
print("Figure saved as 'hull_white_short_rate_simulation.png'")
plt.show()
if name == 'main': main() ```
Exercises¶
Exercise 1. Write the Hull-White short rate simulation formula using the Euler-Maruyama scheme and identify each term.
Solution to Exercise 1
where \(Z \sim \mathcal{N}(0,1)\). The terms are:
- \(\lambda[\theta(t) - r(t)]\,\Delta t\): Mean-reverting drift pulling \(r\) toward the time-dependent target \(\theta(t)\).
- \(\eta\sqrt{\Delta t}\,Z\): Random shock with volatility \(\eta\).
- \(\theta(t)\): Calibrated to match the initial yield curve.
Exercise 2. For the Hull-White model, the exact simulation formula is \(r(t+\Delta t) = r(t)e^{-\lambda\Delta t} + \alpha(t+\Delta t) - \alpha(t)e^{-\lambda\Delta t} + \eta\sqrt{\frac{1-e^{-2\lambda\Delta t}}{2\lambda}}\,Z\) where \(\alpha(t) = f(0,t) + \frac{\eta^2}{2\lambda^2}(1-e^{-\lambda t})^2\). Explain the advantage of this formula.
Solution to Exercise 2
The exact formula samples from the true conditional distribution of \(r(t+\Delta t) | r(t)\), which is Gaussian. There is zero discretization error regardless of step size. The Euler scheme, by contrast, approximates the continuous dynamics and introduces error of order \(O(\Delta t)\). The exact scheme allows the use of larger time steps (e.g., one step per year) without loss of accuracy, dramatically reducing computation time for applications like Bermudan swaption pricing where evaluations at exercise dates suffice.
Exercise 3. If \(\lambda = 0.05\), \(\eta = 0.01\), and the initial forward curve is flat at \(3\%\), compute \(r(0)\) and \(\alpha(0)\).
Solution to Exercise 3
The initial short rate is \(r(0) = f(0,0) = 3\% = 0.03\).
At \(t = 0\), \((1 - e^{-\lambda \times 0})^2 = 0\), so \(\alpha(0) = f(0,0) = 0.03\).
Exercise 4. Describe how to compute the money market account \(M(T) = \exp\!\left(\int_0^T r(s)\,ds\right)\) from simulated short rate paths and its use in pricing.
Solution to Exercise 4
The integral is approximated numerically: \(\int_0^T r(s)\,ds \approx \sum_{i=0}^{N-1} r(t_i)\,\Delta t\) (left-rectangle rule) or \(\sum \frac{r(t_i) + r(t_{i+1})}{2}\Delta t\) (trapezoidal rule). Then \(M(T) = \exp(\text{sum})\).
For pricing, the risk-neutral valuation formula is \(V(0) = \mathbb{E}[\text{payoff}/M(T)]\). The Monte Carlo estimate is \(\hat{V} = \frac{1}{N}\sum_{j=1}^N \text{payoff}_j / M_j(T)\). The money market account acts as the stochastic discount factor, converting future payoffs to present values under the risk-neutral measure.