Source code for tensorquantlib.finance.rates

"""Interest rate models: Vasicek, CIR, Nelson-Siegel, and yield curve bootstrap.

Implements short-rate models and yield curve fitting:
- Vasicek (1977): mean-reverting Gaussian short rate
- CIR (1985): mean-reverting square-root short rate
- Nelson-Siegel (1987): parametric yield curve
"""

from __future__ import annotations

import numpy as np

# ---------------------------------------------------------------------------
# Vasicek model
# ---------------------------------------------------------------------------


[docs] def vasicek_bond_price( r0: float, kappa: float, theta: float, sigma: float, T: float, ) -> float: """Zero-coupon bond price under the Vasicek model. dr = kappa * (theta - r) * dt + sigma * dW Parameters ---------- r0 : float Current short rate. kappa : float Mean reversion speed. theta : float Long-term rate level. sigma : float Volatility of short rate. T : float Time to maturity. Returns ------- float Bond price P(0, T). """ if abs(kappa) < 1e-12: # No mean reversion: simple exponential return float(np.exp(-r0 * T)) B = (1.0 - np.exp(-kappa * T)) / kappa A = np.exp((theta - sigma**2 / (2.0 * kappa**2)) * (B - T) - sigma**2 / (4.0 * kappa) * B**2) return float(A * np.exp(-B * r0))
[docs] def vasicek_yield( r0: float, kappa: float, theta: float, sigma: float, T: float, ) -> float: """Continuously compounded yield under Vasicek: y = -ln(P)/T. Returns ------- float Yield for maturity T. """ P = vasicek_bond_price(r0, kappa, theta, sigma, T) return float(-np.log(P) / T)
[docs] def vasicek_option_price( r0: float, kappa: float, theta: float, sigma: float, T_option: float, T_bond: float, K: float, option_type: str = "call", ) -> float: """European option on a zero-coupon bond under Vasicek. Parameters ---------- r0 : float Current short rate. kappa, theta, sigma : float Vasicek parameters. T_option : float Option expiry. T_bond : float Bond maturity (T_bond > T_option). K : float Strike price of the option. option_type : str 'call' or 'put'. Returns ------- float Option price. """ from scipy.stats import norm B_s = (1.0 - np.exp(-kappa * (T_bond - T_option))) / kappa sigma_p = sigma * B_s * np.sqrt((1.0 - np.exp(-2.0 * kappa * T_option)) / (2.0 * kappa)) P_T = vasicek_bond_price(r0, kappa, theta, sigma, T_bond) P_s = vasicek_bond_price(r0, kappa, theta, sigma, T_option) d1 = np.log(P_T / (K * P_s)) / sigma_p + 0.5 * sigma_p d2 = d1 - sigma_p if option_type == "call": price = P_T * norm.cdf(d1) - K * P_s * norm.cdf(d2) else: price = K * P_s * norm.cdf(-d2) - P_T * norm.cdf(-d1) return float(price)
[docs] def vasicek_simulate( r0: float, kappa: float, theta: float, sigma: float, T: float, n_steps: int = 252, n_paths: int = 10_000, seed: int | None = None, ) -> np.ndarray: """Simulate Vasicek short rate paths. Returns ------- array, shape (n_steps + 1, n_paths) Simulated short rate paths including initial value. """ rng = np.random.default_rng(seed) dt = T / n_steps rates = np.empty((n_steps + 1, n_paths)) rates[0] = r0 for i in range(n_steps): Z = rng.standard_normal(n_paths) rates[i + 1] = rates[i] + kappa * (theta - rates[i]) * dt + sigma * np.sqrt(dt) * Z return rates
# --------------------------------------------------------------------------- # CIR model # ---------------------------------------------------------------------------
[docs] def cir_bond_price( r0: float, kappa: float, theta: float, sigma: float, T: float, ) -> float: """Zero-coupon bond price under the CIR model. dr = kappa * (theta - r) * dt + sigma * sqrt(r) * dW Parameters ---------- r0, kappa, theta, sigma, T : float CIR model parameters and maturity. Returns ------- float Bond price P(0, T). """ gamma = np.sqrt(kappa**2 + 2.0 * sigma**2) eg = np.exp(gamma * T) denom = (gamma + kappa) * (eg - 1.0) + 2.0 * gamma B = 2.0 * (eg - 1.0) / denom A = (2.0 * gamma * np.exp((kappa + gamma) * T / 2.0) / denom) ** ( 2.0 * kappa * theta / sigma**2 ) return float(A * np.exp(-B * r0))
[docs] def cir_yield( r0: float, kappa: float, theta: float, sigma: float, T: float, ) -> float: """Continuously compounded yield under CIR.""" P = cir_bond_price(r0, kappa, theta, sigma, T) return float(-np.log(P) / T)
[docs] def cir_simulate( r0: float, kappa: float, theta: float, sigma: float, T: float, n_steps: int = 252, n_paths: int = 10_000, seed: int | None = None, ) -> np.ndarray: """Simulate CIR short rate paths (full truncation scheme). Returns ------- array, shape (n_steps + 1, n_paths) Simulated short rate paths. """ rng = np.random.default_rng(seed) dt = T / n_steps rates = np.empty((n_steps + 1, n_paths)) rates[0] = r0 for i in range(n_steps): Z = rng.standard_normal(n_paths) r_pos = np.maximum(rates[i], 0.0) rates[i + 1] = rates[i] + kappa * (theta - r_pos) * dt + sigma * np.sqrt(r_pos * dt) * Z rates[i + 1] = np.maximum(rates[i + 1], 0.0) return rates
[docs] def feller_condition(kappa: float, theta: float, sigma: float) -> bool: """Check the Feller condition: 2*kappa*theta >= sigma^2. When satisfied, CIR process stays strictly positive. """ return 2.0 * kappa * theta >= sigma**2
# --------------------------------------------------------------------------- # Nelson-Siegel yield curve # ---------------------------------------------------------------------------
[docs] def nelson_siegel( T: float | np.ndarray, beta0: float, beta1: float, beta2: float, tau: float, ) -> float | np.ndarray: """Nelson-Siegel (1987) yield curve model. y(T) = beta0 + beta1 * (1 - exp(-T/tau)) / (T/tau) + beta2 * ((1 - exp(-T/tau)) / (T/tau) - exp(-T/tau)) Parameters ---------- T : float or array Maturity/maturities. beta0 : float Long-term rate (level). beta1 : float Short-term component (slope). beta2 : float Medium-term component (curvature). tau : float Decay parameter (> 0). Returns ------- float or array Yield(s) at the given maturity/maturities. """ T = np.asarray(T, dtype=float) scalar_input = T.ndim == 0 T = np.atleast_1d(T) x = T / tau # Handle T=0 limit factor1 = np.where(x < 1e-12, 1.0, (1.0 - np.exp(-x)) / x) factor2 = factor1 - np.exp(-x) y = beta0 + beta1 * factor1 + beta2 * factor2 return float(y[0]) if scalar_input else y
[docs] def nelson_siegel_calibrate( maturities: np.ndarray, yields: np.ndarray, initial_guess: tuple[float, float, float, float] | None = None, ) -> dict[str, float]: """Calibrate Nelson-Siegel parameters to market yields. Parameters ---------- maturities : array Observed maturities. yields : array Observed yields. initial_guess : tuple, optional Initial (beta0, beta1, beta2, tau). Returns ------- dict {'beta0': ..., 'beta1': ..., 'beta2': ..., 'tau': ..., 'rmse': ...} """ from scipy.optimize import minimize maturities = np.asarray(maturities, dtype=float) yields = np.asarray(yields, dtype=float) if initial_guess is None: initial_guess = (yields[-1], yields[0] - yields[-1], 0.0, 1.0) def objective(params): b0, b1, b2, tau = params if tau <= 0: return 1e10 model = nelson_siegel(maturities, b0, b1, b2, tau) return float(np.sum((model - yields) ** 2)) result = minimize( objective, x0=initial_guess, method="Nelder-Mead", options={"maxiter": 5000, "xatol": 1e-12, "fatol": 1e-12}, ) b0, b1, b2, tau = result.x model_yields = nelson_siegel(maturities, b0, b1, b2, tau) rmse = float(np.sqrt(np.mean((model_yields - yields) ** 2))) return { "beta0": float(b0), "beta1": float(b1), "beta2": float(b2), "tau": float(tau), "rmse": rmse, }
# --------------------------------------------------------------------------- # Yield curve bootstrap # ---------------------------------------------------------------------------
[docs] def bootstrap_yield_curve( maturities: np.ndarray, prices: np.ndarray, ) -> np.ndarray: """Bootstrap zero-coupon yields from bond prices. Assumes prices are for zero-coupon bonds with face value 1. Parameters ---------- maturities : array Bond maturities. prices : array Bond prices (face value 1). Returns ------- array Continuously compounded zero rates. """ maturities = np.asarray(maturities, dtype=float) prices = np.asarray(prices, dtype=float) return -np.log(prices) / maturities