Monte Carlo Value-at-Risk (Grzelak)¶
Background¶
Monte Carlo Value-at-Risk (VaR) computation using Hull-White model.
This educational code demonstrates VaR estimation using Monte Carlo simulation of interest rate paths under the Hull-White single-factor model. It builds realization of yield curve shifts and revalues an interest rate derivatives portfolio under those scenarios. Based on "Financial Engineering" course by L.A. Grzelak. The course is based on the book "Mathematical Modeling and Computation in Finance: With Exercises and Python and MATLAB Computer Codes", by C.W. Oosterlee and L.A. Grzelak, World Scientific Publishing Europe Ltd, 2019.
@author: Lech A. Grzelak
What This Code Demonstrates¶
- Option Type Enum =============
- Path Generation =============
- Hull-White Model Functions =============
- Swap Pricing =============
- Portfolio =============
- VaR Plotting =============
- Main Calculation =============
Code¶
```python """ Monte Carlo Value-at-Risk (VaR) computation using Hull-White model.
This educational code demonstrates VaR estimation using Monte Carlo simulation of interest rate paths under the Hull-White single-factor model. It builds realization of yield curve shifts and revalues an interest rate derivatives portfolio under those scenarios. Based on "Financial Engineering" course by L.A. Grzelak. The course is based on the book "Mathematical Modeling and Computation in Finance: With Exercises and Python and MATLAB Computer Codes", by C.W. Oosterlee and L.A. Grzelak, World Scientific Publishing Europe Ltd, 2019.
@author: Lech A. Grzelak """
import enum
import numpy as np import matplotlib.pyplot as plt import scipy.stats as st import scipy.integrate as integrate
============= Option Type Enum =============¶
class OptionTypeSwap(enum.Enum): """Defines swap option types: receiver or payer.""" RECEIVER = 1.0 PAYER = -1.0
============= Path Generation =============¶
def generate_paths_hw_euler(num_paths, num_steps, t_end, p0t, lambd, eta): """ Generate Hull-White interest rate paths using Euler scheme.
Parameters
----------
num_paths : int
Number of Monte Carlo paths to generate.
num_steps : int
Number of time steps per path.
t_end : float
Terminal time.
p0t : callable
Zero coupon bond price function P(0, T).
lambd : float
Mean reversion speed parameter.
eta : float
Volatility parameter.
Returns
-------
dict
Dictionary with 'time' array and 'R' array of interest rate paths.
"""
dt_diff = 0.0001
f0t = lambda t: -(np.log(p0t(t + dt_diff)) - np.log(p0t(t - dt_diff))) / (2 * dt_diff)
# Initial interest rate is forward rate at time t -> 0
r0 = f0t(0.00001)
theta = lambda t: (
1.0 / lambd * (f0t(t + dt_diff) - f0t(t - dt_diff)) / (2.0 * dt_diff)
+ f0t(t)
+ eta * eta / (2.0 * lambd * lambd) * (1.0 - np.exp(-2.0 * lambd * t))
)
z = np.random.normal(0.0, 1.0, (num_paths, num_steps))
w = np.zeros((num_paths, num_steps + 1))
r = np.zeros((num_paths, num_steps + 1))
r[:, 0] = r0
time = np.zeros(num_steps + 1)
dt = t_end / float(num_steps)
for i in range(0, num_steps):
# Normalize samples to ensure mean 0 and variance 1
if num_paths > 1:
z[:, i] = (z[:, i] - np.mean(z[:, i])) / np.std(z[:, i])
w[:, i + 1] = w[:, i] + np.sqrt(dt) * z[:, i]
r[:, i + 1] = (
r[:, i]
+ lambd * (theta(time[i]) - r[:, i]) * dt
+ eta * (w[:, i + 1] - w[:, i])
)
time[i + 1] = time[i] + dt
paths = {"time": time, "R": r}
return paths
============= Hull-White Model Functions =============¶
def hw_theta(lambd, eta, p0t): """ Compute the theta parameter for Hull-White model.
Parameters
----------
lambd : float
Mean reversion speed.
eta : float
Volatility.
p0t : callable
Zero coupon bond price function.
Returns
-------
callable
Theta function of time.
"""
dt_diff = 0.0001
f0t = lambda t: -(np.log(p0t(t + dt_diff)) - np.log(p0t(t - dt_diff))) / (2 * dt_diff)
theta = lambda t: (
1.0 / lambd * (f0t(t + dt_diff) - f0t(t - dt_diff)) / (2.0 * dt_diff)
+ f0t(t)
+ eta * eta / (2.0 * lambd * lambd) * (1.0 - np.exp(-2.0 * lambd * t))
)
return theta
def hw_a(lambd, eta, p0t, t1, t2): """ Compute the 'A' coefficient for Hull-White ZCB formula.
Parameters
----------
lambd : float
Mean reversion speed.
eta : float
Volatility.
p0t : callable
Zero coupon bond price function.
t1, t2 : float
Maturity times.
Returns
-------
float
Coefficient A.
"""
tau = t2 - t1
z_grid = np.linspace(0.0, tau, 250)
b_r = lambda tau: 1.0 / lambd * (np.exp(-lambd * tau) - 1.0)
theta = hw_theta(lambd, eta, p0t)
temp1 = lambd * integrate.trapz(theta(t2 - z_grid) * b_r(z_grid), z_grid)
temp2 = (
eta * eta / (4.0 * np.power(lambd, 3.0))
* (np.exp(-2.0 * lambd * tau) * (4 * np.exp(lambd * tau) - 1.0) - 3.0)
+ eta * eta * tau / (2.0 * lambd * lambd)
)
return temp1 + temp2
def hw_b(lambd, eta, t1, t2): """ Compute the 'B' coefficient for Hull-White ZCB formula.
Parameters
----------
lambd : float
Mean reversion speed.
eta : float
Volatility (unused but kept for signature consistency).
t1, t2 : float
Maturity times.
Returns
-------
float
Coefficient B.
"""
return 1.0 / lambd * (np.exp(-lambd * (t2 - t1)) - 1.0)
def hw_zcb(lambd, eta, p0t, t1, t2, rt1): """ Compute Hull-White zero coupon bond price.
Parameters
----------
lambd : float
Mean reversion speed.
eta : float
Volatility.
p0t : callable
Zero coupon bond price function.
t1, t2 : float
Evaluation time and maturity.
rt1 : float or ndarray
Interest rate(s) at time t1.
Returns
-------
float or ndarray
ZCB price(s).
"""
n = np.size(rt1)
if t1 < t2:
b_r = hw_b(lambd, eta, t1, t2)
a_r = hw_a(lambd, eta, p0t, t1, t2)
return np.exp(a_r + b_r * rt1)
else:
return np.ones(n)
def hw_mean_r(p0t, lambd, eta, t): """ Compute mean of Hull-White interest rate at time T.
Parameters
----------
p0t : callable
Zero coupon bond price function.
lambd : float
Mean reversion speed.
eta : float
Volatility.
t : float
Time.
Returns
-------
float
Mean interest rate.
"""
dt_diff = 0.0001
f0t = lambda t: -(np.log(p0t(t + dt_diff)) - np.log(p0t(t - dt_diff))) / (2.0 * dt_diff)
r0 = f0t(0.00001)
theta = hw_theta(lambd, eta, p0t)
z_grid = np.linspace(0.0, t, 2500)
temp = lambda z: theta(z) * np.exp(-lambd * (t - z))
r_mean = r0 * np.exp(-lambd * t) + lambd * integrate.trapz(temp(z_grid), z_grid)
return r_mean
def hw_r_0(p0t, lambd, eta): """ Compute initial Hull-White interest rate.
Parameters
----------
p0t : callable
Zero coupon bond price function.
lambd : float
Mean reversion speed.
eta : float
Volatility.
Returns
-------
float
Initial interest rate r0.
"""
dt_diff = 0.0001
f0t = lambda t: -(np.log(p0t(t + dt_diff)) - np.log(p0t(t - dt_diff))) / (2 * dt_diff)
r0 = f0t(0.00001)
return r0
def hw_mu_frwd_measure(p0t, lambd, eta, t): """ Compute mean under forward measure for Hull-White model.
Parameters
----------
p0t : callable
Zero coupon bond price function.
lambd : float
Mean reversion speed.
eta : float
Volatility.
t : float
Time.
Returns
-------
float
Mean under forward measure.
"""
dt_diff = 0.0001
f0t = lambda t: -(np.log(p0t(t + dt_diff)) - np.log(p0t(t - dt_diff))) / (2 * dt_diff)
r0 = f0t(0.00001)
theta = hw_theta(lambd, eta, p0t)
z_grid = np.linspace(0.0, t, 500)
theta_hat = lambda t, t_mat: theta(t) + eta * eta / lambd * 1.0 / lambd * (
np.exp(-lambd * (t_mat - t)) - 1.0
)
temp = lambda z: theta_hat(z, t) * np.exp(-lambd * (t - z))
r_mean = r0 * np.exp(-lambd * t) + lambd * integrate.trapz(temp(z_grid), z_grid)
return r_mean
def hw_var_r(lambd, eta, t): """ Compute variance of Hull-White interest rate at time T.
Parameters
----------
lambd : float
Mean reversion speed.
eta : float
Volatility.
t : float
Time.
Returns
-------
float
Variance.
"""
return eta * eta / (2.0 * lambd) * (1.0 - np.exp(-2.0 * lambd * t))
def hw_density(p0t, lambd, eta, t): """ Compute probability density function for Hull-White interest rate.
Parameters
----------
p0t : callable
Zero coupon bond price function.
lambd : float
Mean reversion speed.
eta : float
Volatility.
t : float
Time.
Returns
-------
callable
PDF function.
"""
r_mean = hw_mean_r(p0t, lambd, eta, t)
r_var = hw_var_r(lambd, eta, t)
return lambda x: st.norm.pdf(x, r_mean, np.sqrt(r_var))
============= Swap Pricing =============¶
def hw_swap_price(option_type, notional, strike, t, ti, tm, n, r_t, p0t, lambd, eta): """ Compute Hull-White swap price.
Parameters
----------
option_type : OptionTypeSwap
Payer or receiver swap.
notional : float
Notional amount.
strike : float
Strike rate.
t : float
Evaluation time.
ti, tm : float
Swap start and end times.
n : int
Number of payment dates.
r_t : float or ndarray
Interest rate(s) at time t.
p0t : callable
Zero coupon bond price function.
lambd : float
Mean reversion speed.
eta : float
Volatility.
Returns
-------
float or ndarray
Swap price(s).
"""
if n == 1:
ti_grid = np.array([ti, tm])
else:
ti_grid = np.linspace(ti, tm, n)
tau = ti_grid[1] - ti_grid[0]
# Overwrite Ti if t > Ti
prev_ti = ti_grid[np.where(ti_grid < t)]
if np.size(prev_ti) > 0:
ti = prev_ti[-1]
# Handle case when some payments are already done
ti_grid = ti_grid[np.where(ti_grid > t)]
temp = np.zeros(np.size(r_t))
p_t_ti_lambda = lambda ti_arg: hw_zcb(lambd, eta, p0t, t, ti_arg, r_t)
for idx, ti_val in enumerate(ti_grid):
if ti_val > ti:
temp = temp + tau * p_t_ti_lambda(ti_val)
p_t_ti = p_t_ti_lambda(ti)
p_t_tm = p_t_ti_lambda(tm)
if option_type == OptionTypeSwap.PAYER:
swap = (p_t_ti - p_t_tm) - strike * temp
elif option_type == OptionTypeSwap.RECEIVER:
swap = strike * temp - (p_t_ti - p_t_tm)
return swap * notional
============= Portfolio =============¶
def portfolio(p0t, r_t, lambd, eta): """ Compute portfolio value from collection of interest rate swaps.
Parameters
----------
p0t : callable
Zero coupon bond price function.
r_t : float or ndarray
Interest rate(s) at current time.
lambd : float
Mean reversion speed.
eta : float
Volatility.
Returns
-------
float or ndarray
Total portfolio value(s).
"""
value = (
hw_swap_price(OptionTypeSwap.RECEIVER, 1000000, 0.02, 0.0, 0.0, 20, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.PAYER, 500000, 0.01, 0.0, 0.0, 10, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.RECEIVER, 25000, 0.02, 0.0, 0.0, 30, 60, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.PAYER, 74000, 0.005, 0.0, 0.0, 5, 10, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.RECEIVER, 254000, 0.032, 0.0, 0.0, 15, 10, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.RECEIVER, 854000, 0.01, 0.0, 0.0, 7, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.PAYER, 900000, 0.045, 0.0, 0.0, 10, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.PAYER, 400000, 0.02, 0.0, 0.0, 10, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.RECEIVER, 1000000, 0.01, 0.0, 0.0, 14, 20, r_t, p0t, lambd, eta)
+ hw_swap_price(OptionTypeSwap.PAYER, 115000, 0.06, 0.0, 0.0, 9, 10, r_t, p0t, lambd, eta)
)
return value
============= VaR Plotting =============¶
def plot_zcb_comparison(t_grid, exact, proxy): """ Plot ZCB prices from Monte Carlo vs analytical expression.
Parameters
----------
t_grid : ndarray
Maturity times.
exact : ndarray
Analytical ZCB prices.
proxy : ndarray
Monte Carlo ZCB prices.
"""
plt.figure(1)
plt.grid()
plt.plot(t_grid, exact, "-k")
plt.plot(t_grid, proxy, "--r")
plt.legend(["Analytical ZCB", "Monte Carlo ZCB"])
plt.title("P(0,T) from Monte Carlo vs. Analytical expression")
def plot_pnl_histogram(pnl_data, var_estimate, es_estimate): """ Plot histogram of portfolio P&L with VaR and ES markers.
Parameters
----------
pnl_data : ndarray
Portfolio P&L values flattened.
var_estimate : float
Value-at-Risk estimate.
es_estimate : float
Expected shortfall estimate.
"""
plt.figure(2)
plt.hist(pnl_data, 100)
plt.grid()
plt.plot(var_estimate, 0, "or")
plt.plot(es_estimate, 0, "ok")
plt.legend(["VaR", "ES", "P&L"])
============= Main Calculation =============¶
def main(): """ Main computation: compute Monte Carlo VaR using Hull-White model. """ # --------- Configuration --------- num_paths = 2000 # Number of Monte Carlo paths num_steps = 100 # Number of time steps per path lambd = 0.5 # Hull-White mean reversion speed eta = 0.03 # Hull-White volatility
# Define zero coupon bond curve (market data)
p0t = lambda t: np.exp(-0.001 * t)
r0 = hw_r_0(p0t, lambd, eta)
# --------- ZCB Validation ---------
# Compare ZCB from Market and Analytical expression
n_zcb = 25
t_end_zcb = 50
t_grid_zcb = np.linspace(0, t_end_zcb, n_zcb)
exact = np.zeros((n_zcb, 1))
proxy = np.zeros((n_zcb, 1))
for i, ti in enumerate(t_grid_zcb):
proxy[i] = hw_zcb(lambd, eta, p0t, 0.0, ti, r0)
exact[i] = p0t(ti)
plot_zcb_comparison(t_grid_zcb, exact, proxy)
# --------- Path Simulation ---------
# Simulate interest rate paths
t_end = 20
paths = generate_paths_hw_euler(num_paths, num_steps, t_end, p0t, lambd, eta)
r = paths["R"]
time_grid = paths["time"]
dt = time_grid[1] - time_grid[0]
# Compute money market account for discounting (money-back numéraire)
m_t = np.zeros((num_paths, num_steps))
for i in range(0, num_paths):
m_t[i, :] = np.exp(np.cumsum(r[i, 0:-1]) * dt)
# --------- Portfolio Exposure Computation ---------
# Compute portfolio value P&L from rate shifts
r0_val = r[0, 0]
step_size = 10 # Time step for computing P&L
v_m = np.zeros((num_paths, num_steps - step_size))
for i in range(0, num_steps - step_size):
dr = r[:, i + step_size] - r[:, i]
v_t0 = portfolio(p0t, r[:, 0] + dr, lambd, eta)
v_m[:, i] = v_t0
# --------- VaR Calculation ---------
# Flatten P&L vector for statistics
v_t0_vec = np.matrix.flatten(v_m)
print("Value V(t_0)= ", portfolio(p0t, r[0, 0], lambd, eta))
# Confidence level
alpha = 0.05
# Value-at-Risk estimate
hvar_estimate = np.quantile(v_t0_vec, alpha)
print("(H)VaR for alpha = ", alpha, " is equal to=", hvar_estimate)
# Expected shortfall (conditional VaR)
cond_losses = v_t0_vec[v_t0_vec < hvar_estimate]
print("P&L which < VaR_alpha =", cond_losses)
es = np.mean(cond_losses)
print("Expected shortfall = ", es)
# --------- Generate Plots ---------
plot_pnl_histogram(v_t0_vec, hvar_estimate, es)
if name == "main": main() ```
Exercises¶
Exercise 1. Monte Carlo VaR simulates the portfolio value distribution at a future horizon. Describe the three main steps.
Solution to Exercise 1
- Model calibration: Estimate the parameters of the risk factor model (e.g., Hull-White for rates) from market data.
- Simulation: Generate \(N\) scenarios of all risk factors at the VaR horizon (e.g., 10 days). For each scenario, reprice the entire portfolio to obtain \(N\) portfolio values \(V_1, \ldots, V_N\).
- VaR computation: Sort the P&L values \(\Delta V_i = V_i - V_0\). The \(\alpha\)-VaR is the \((1-\alpha)N\)-th smallest P&L. For \(\alpha = 99\%\) and \(N = 10{,}000\): VaR is the 100th worst loss.
Exercise 2. Under the Hull-White model, rate scenarios are simulated for VaR. Why might the real-world measure be more appropriate than the risk-neutral measure for VaR?
Solution to Exercise 2
VaR measures the actual risk of loss over a holding period, which depends on real-world probabilities, not risk-neutral ones. The risk-neutral measure is designed for pricing (where the drift is adjusted to preclude arbitrage) and may assign different probabilities to scenarios than those expected to occur in reality. For VaR, one should use the physical measure with historically estimated drifts and volatilities, so the VaR reflects the true likelihood of adverse scenarios.
Exercise 3. If Monte Carlo VaR with 10,000 paths gives a \(99\%\) VaR estimate of $2.5M with a standard error of $150,000, construct a \(95\%\) confidence interval for the true VaR.
Solution to Exercise 3
The \(95\%\) confidence interval is \(\text{VaR} \pm 1.96 \times \text{SE} = 2{,}500{,}000 \pm 1.96 \times 150{,}000 = 2{,}500{,}000 \pm 294{,}000\).
The interval is \([\$2{,}206{,}000, \$2{,}794{,}000]\). The relative uncertainty is \(294{,}000/2{,}500{,}000 \approx 12\%\), which is high. To reduce the SE to $50,000 (2% relative), one would need \(10{,}000 \times (150/50)^2 = 90{,}000\) paths.
Exercise 4. Compare Monte Carlo VaR to historical VaR in terms of flexibility, computational cost, and model risk.
Solution to Exercise 4
| Feature | Monte Carlo VaR | Historical VaR |
|---|---|---|
| Flexibility | Handles any model, any instrument | Limited to historically observed scenarios |
| Fat tails | Depends on model choice | Naturally captured |
| Computational cost | High (\(N\) full portfolio revaluations) | Low (sort historical returns) |
| Model risk | High (depends on model assumptions) | Low (model-free) |
| Forward-looking | Can incorporate views/forecasts | Purely backward-looking |
| New instruments | Can price any instrument | Requires proxy mapping |