Bermudan swaptions
This page lists what you need to price a Bermudan swaption, the right to enter a swap on any of several dates, on the Hull-White lattice: every argument it takes, the model it needs, and how accurate the lattice is.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This prices payer Bermudans with one, two and three exercise dates, receivers with one and three, and compares one-date parity on the lattice with the closed-form European. The market is the SOFR close with a Hull-White model pinned; see quote sheets and recipes for how a close becomes a market.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, BermudanSwaption, CollateralSpec, EuropeanSwaption, MarketContext,
OvernightIndexedSwap, QuoteSheet, Side, SwaptionEngine, SwaptionKind, SwaptionUnderlying,
Termination, template,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn underlying(close: NaiveDate, kind: SwaptionKind) -> Outcome<OvernightIndexedSwap> {
let side = if kind == SwaptionKind::Payer {
Side::Payer
} else {
Side::Receiver
};
let AnyInstrument::Ois(swap) = template("USD-SOFR-OIS")?
.swap(close)?
.with_maturity("1Y5Y".parse::<Termination>()?)?
.with_notional(10_000_000.0)
.with_fixed_rate(0.0480)
.with_side(side)
.build()?
else {
unreachable!("a SOFR swap is an overnight indexed swap")
};
Ok(swap)
}
fn bermudan(close: NaiveDate, dates: &[NaiveDate], kind: SwaptionKind) -> Outcome<AnyInstrument> {
let swap = SwaptionUnderlying::Ois(underlying(close, kind)?);
let option = BermudanSwaption::new(swap, dates.to_vec(), 0.0480, kind)?;
Ok(AnyInstrument::BermudanSwaption(Box::new(option)))
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let market = MarketContext::as_of(close)
.with_recipe_quotes(
&CollateralSpec::Currency("USD".parse()?),
&QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
)?
.with_hull_white("USD".parse()?, 0.03, 0.01);
let (first, second, third) = (
date((2027, 10, 5)),
date((2028, 10, 5)),
date((2029, 10, 5)),
);
let value = |dates: &[NaiveDate], kind: SwaptionKind| -> Outcome<f64> {
Ok(bermudan(close, dates, kind)?.npv(&market)?)
};
// One date, two dates, three: each added right can only add value.
let one = value(&[first], SwaptionKind::Payer)?;
let two = value(&[first, second], SwaptionKind::Payer)?;
let three = value(&[first, second, third], SwaptionKind::Payer)?;
println!("one date {one:.2}");
println!("two dates {two:.2}");
println!("three dates {three:.2}");
// The same holds for the right to receive fixed.
let r_one = value(&[first], SwaptionKind::Receiver)?;
let r_three = value(&[first, second, third], SwaptionKind::Receiver)?;
println!("receiver, one date {r_one:.2}");
println!("receiver, three dates {r_three:.2}");
// With one date the right to pay less the right to receive is the swap itself.
// The Hull-White European is exact; the lattice is a discretisation of the same
// model, so its parity holds to within its grid.
let swap = AnyInstrument::Ois(underlying(close, SwaptionKind::Payer)?).npv(&market)?;
let european = |kind: SwaptionKind| -> Outcome<f64> {
let swap = SwaptionUnderlying::Ois(underlying(close, kind)?);
let option = EuropeanSwaption::new(swap, first, 0.0480, kind)?
.with_engine(SwaptionEngine::HullWhite);
Ok(AnyInstrument::EuropeanSwaption(Box::new(option)).npv(&market)?)
};
let european_parity = european(SwaptionKind::Payer)? - european(SwaptionKind::Receiver)?;
let lattice_parity = one - r_one;
println!("forward swap {swap:.2}");
println!("european pay - rec {european_parity:.2}");
println!("one-date pay - rec {lattice_parity:.2}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_hull_white("USD", 0.03, 0.01)
)
sofr = hlx.templates.get("USD-SOFR-OIS")
FIRST, SECOND, THIRD = dt.date(2027, 10, 5), dt.date(2028, 10, 5), dt.date(2029, 10, 5)
def underlying(side):
return sofr.swap(SOFR_CLOSE_DATE).with_maturity("1Y5Y").with_notional(10_000_000.0)\
.with_fixed_rate(0.0480).with_side(side).build()
def bermudan(dates, kind="payer"):
side = "payer" if kind == "payer" else "receiver"
return hlx.products.bermudan_swaption(underlying(side), dates, strike=0.0480, kind=kind)
# One date, two dates, three: each added right can only add value.
one = bermudan([FIRST]).npv(market)
two = bermudan([FIRST, SECOND]).npv(market)
three = bermudan([FIRST, SECOND, THIRD]).npv(market)
print(f"one date {one:.2f}")
print(f"two dates {two:.2f}")
print(f"three dates {three:.2f}")
# The same holds for the right to receive fixed.
r_one = bermudan([FIRST], "receiver").npv(market)
r_three = bermudan([FIRST, SECOND, THIRD], "receiver").npv(market)
print(f"receiver, one date {r_one:.2f}")
print(f"receiver, three dates {r_three:.2f}")
# With one date the right to pay less the right to receive is the swap itself.
# The Hull-White European is exact; the lattice is a discretisation of the same
# model, so its parity holds to within its grid.
swap = underlying("payer").npv(market)
def european(kind):
side = "payer" if kind == "payer" else "receiver"
return hlx.products.european_swaption(
underlying(side), FIRST, strike=0.0480, kind=kind, engine="hull_white"
).npv(market)
european_parity = european("payer") - european("receiver")
lattice_parity = one - r_one
print(f"forward swap {swap:.2f}")
print(f"european pay - rec {european_parity:.2f}")
print(f"one-date pay - rec {lattice_parity:.2f}")one date 163218.94
two dates 204679.50
three dates 223085.87
receiver, one date 157992.72
receiver, three dates 217156.49
forward swap 5186.68
european pay - rec 5186.68
one-date pay - rec 5226.23
More dates never lower the value. With one date, payer less receiver is the forward swap: exact for the European, and to within the lattice's grid for the Bermudan.
How it's priced
On a lattice of the short rate under Hull-White, the option is valued backward from the last exercise date. At each exercise date the holder takes the larger of exercising, the remaining swap's value , and holding, the discounted expectation of the next node:
Each added exercise date can only raise the value, since the holder can always ignore it. With a single exercise date the Bermudan is a European, and holding a payer while selling a receiver on the same swap and strike is the swap itself, so
the forward swap's value today. Where Bermudans appear, and why they need a short-rate model, is covered in how rates products work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the model | MarketContext::with_hull_white |
MarketContext.with_hull_white |
|
| the option | a Bermudan swaption | BermudanSwaption::new with SwaptionUnderlying::Ois |
products.bermudan_swaption |
| its value on the lattice | AnyInstrument::npv |
Instrument.npv |
|
| the European | one exercise date, closed form | EuropeanSwaption::new(..).with_engine(SwaptionEngine::HullWhite) |
products.european_swaption(..., engine="hull_white") |
| the forward swap's value | AnyInstrument::npv on the swap |
Instrument.npv on the swap |
| Argument | Type | Default | Meaning |
|---|---|---|---|
underlying |
Instrument |
required | the swap the holder may enter; in Rust a SwaptionUnderlying (SwaptionUnderlying::Ois for a SOFR swap) |
exercise_dates |
list[datetime.date] |
required | strictly ascending; the last must fall before the underlying's last fixed payment |
strike |
float |
required | the fixed rate entered on exercise |
kind |
SwaptionKind or str |
required | "payer" or "receiver" |
with_hull_white(currency, mean_reversion, sigma) |
str, float, float or list[tuple[date, float]] |
required | pins the Hull-White model the lattice uses, on the market; sigma is one number or piecewise (date, σ) pieces |
Defaults and limits
- Model: the market must carry a Hull-White model for the currency (
with_hull_white); calibrate it to co-terminal Europeans on Hull-White. - Accuracy: the lattice runs about 50 time steps a year (
DEFAULT_STEPS_PER_YEARin the source), plus a step on each exercise date; the grid density is not a public argument. The example checks one-date parity to within 1e-5 of notional on the lattice, against 1e-9 of notional for the closed-form European. - Strike: a SOFR swap pays two days after each accrual end, so the option is struck at , where is the fixed spread that makes a par floating leg reprice those lagged coupons.
- Lattice: the backward induction runs inside the pricer; no public call steps through it.
See also
- Source:
crates/helixrs/src/instruments/swaptions.rs,crates/helixrs/src/models/lattice.rs - Related pages:
- Swaptions, for one exercise date.
- Callable swaps, a swap with a Bermudan inside it.
- Hull-White, for the model.
- Callable bonds, for the option inside a bond.
- Rates conventions, for the swap conventions the underlying is drafted on.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.