Options on bonds, swaptions and caps
rl.ZeroCouponBondOption, rl.CouponBondOption, rl.Swaption and rl.CapFloor under
SwitchingVasicek, SwitchingHullWhite or SwitchingG2.
The problem
Under the Vasicek model, a call expiring at \(T\) with strike \(K\) on the zero-coupon bond maturing at \(S\) is
The bond at expiry depends on the regime then: on \(\{y_T = j\}\), \(P(T, S) = A_j\,e^{-b\,r_T}\) with \(b = B(S - T)\) and \(A_j = a_j(S - T)\) from the bond’s reduced system. So the option pays when \(r_T < r_j^* = \log(A_j/K)/b\): there is one exercise boundary per regime.
Reduction
The price is a sum over the regime at expiry of terms of the form
Without the indicator on \(r_T\) this is a bond-like quantity with two changes. The payoff contains \(e^{-c\,r}\), so the coefficient of the rate starts at \(c\) rather than zero,
and it is paid only in regime \(j\), so the terminal vector is the unit vector \(e_j\) rather than \(\mathbf 1\):
The indicator on \(r_T\) is restored by Gil–Pelaez inversion in the complex exponent \(c - iu\):
The regime at expiry costs one solve per regime and nothing else.
Closed form, first order
With the terminal vector \(e_j = \tfrac12\mathbf 1 + \tfrac12s_j(1, -1)^\top\), \(s_1 = 1\), \(s_2 = -1\), the stationary half is carried as for a bond and the other half relaxes, leaving a first-order imprint through \(\tilde g(0)\), which is not zero here because \(B_c(0) = c\):
The term in \(\tilde g(T)\) is the memory of the regime at the start; the term in \(\tilde g(0)\) is its mirror image, the memory of the regime at expiry. Because \(B_c = c\,e^{-at} + B\), the first-order factor is a polynomial in \(c\), \(\Pi_j(c) = \sum_{k=0}^4\pi_{jk}c^k\), and a factor \(c^k\) on the transform is the \(k\)-th derivative of the Gaussian density of \(r_T\) under the averaged model’s \(T\)-forward measure, \(r_T \sim N(\mu, v)\). The price is then
\(R_0\) is Jamshidian’s formula, so at \(\varepsilon = 0\) this is the averaged model’s option price. The ingredients are
Python
The closed form is regimelib._engine.bond_option_explicit.call; the library’s engines solve the Gil–Pelaez
integrals without expanding.
from regimelib._engine.bond_option_explicit import call as closed_form_call
r0, a, b, sigma, T, S = 0.04, 0.5, [0.05, 0.03], [0.015, 0.010], 1.0, 4.0
print(" lam K Jamshidian, averaged closed form, first order library, numerical")
for lam in (10.0, 20.0, 40.0):
model = rl.SwitchingVasicek(rl.RegimeChain.twoState(lam, lam), r0, a, b, sigma)
for K in (0.86, 0.90):
option = rl.ZeroCouponBondOption("call", K, T, S)
option.setPricingEngine(rl.NumericalSwitchingEngine(model, regime=0))
averaged = closed_form_call(a, b, sigma, r0, T, S, K, lam, start=0, order=0)
first = closed_form_call(a, b, sigma, r0, T, S, K, lam, start=0, order=1)
print(f"{lam:4.0f} {K:.2f} {averaged:.8f} {first:.8f} {option.NPV():.8f}")
lam K Jamshidian, averaged closed form, first order library, numerical
10 0.86 0.02638714 0.02622112 0.02621545
10 0.90 0.00135934 0.00134597 0.00134605
20 0.86 0.02638714 0.02630380 0.02630239
20 0.90 0.00135934 0.00135215 0.00135217
40 0.86 0.02638714 0.02634539 0.02634504
40 0.90 0.00135934 0.00135562 0.00135562
Each doubling of the switching rate divides the first-order error by about four.
Coupon bonds, swaptions and caps
A coupon bond \(\sum_kc_kA_{kj}e^{-b_kr}\) is decreasing in \(r\) in every regime, so Jamshidian’s decomposition holds regime by regime. In regime \(j\) there is one \(r_j^*\) at which the coupon bond equals the strike, and
The strikes differ by regime, which is the only change from the model without regimes. A receiver swaption is the call on the coupon bond at par and a payer swaption the put. A caplet on \([t_{k-1}, t_k]\) with strike \(K\) is \(1 + \tau_kK\) puts on the bond maturing at \(t_k\), struck at \(1/(1 + \tau_kK)\) and expiring at \(t_{k-1}\). Under Hull–White the deterministic shift scales each cash flow; under G2++ the single Gaussian variable \(B_ax_T + B_bz_T\) takes the place of \(r_T\).
lam = 10.0
model = rl.SwitchingVasicek(rl.RegimeChain.twoState(lam, lam), 0.03, 0.5, [0.06, 0.02], [0.015, 0.008])
swaption = rl.Swaption("payer", 2.0, [3.0, 4.0, 5.0, 6.0, 7.0], 0.035, notional=100.0)
swaption.setPricingEngine(rl.NumericalSwitchingEngine(model, regime=0))
reference = swaption.NPV()
print(f"2x5 payer swaption, numerical {reference:.6f}")
for order in (0, 1, 2, 4):
swaption.setPricingEngine(rl.FastSwitchingEngine(model, order=order, regime=0))
print(f" expansion, order {order} {swaption.NPV():.6f} error {abs(swaption.NPV() - reference):.1e}")
cap = rl.CapFloor("cap", [0.5 * k for k in range(1, 11)], 0.04, notional=100.0)
cap.setPricingEngine(rl.NumericalSwitchingEngine(model, regime=0))
print(f"semiannual cap at 4%, 0.5y to 5y {cap.NPV():.6f}")
2x5 payer swaption, numerical 1.924124
expansion, order 0 1.895914 error 2.8e-02
expansion, order 1 1.923805 error 3.2e-04
expansion, order 2 1.924123 error 1.9e-06
expansion, order 4 1.924124 error 3.7e-08
semiannual cap at 4%, 0.5y to 5y 1.171073
Bermudan swaptions depend on the path of the rate and are priced on the coupled equations on a grid (Other instruments).