Calibrate Hull-White and pin it
You'll fit Hull-White's volatility to the swaptions a callable bond could be called into, get back a market with the fit pinned, and price the bond on it.
Before you start
- helixio installed: see installing.
- The callable bond's terms, a curve, and one at-the-money normal vol per call date.
- A mean reversion. The fit holds it fixed; see Hull-White calibration for how to pick one.
Steps
- Book the callable: a fixed-rate bond and its call schedule.
- Mark the curve, read the bond's strip with
models.strip_from_callable, and quote one vol per strip point withwith_flat_swaption_vol. - Fit and pin in one call:
calibration.hull_white_pinned(market, callable, mean_reversion)returns the pinned market and the fit. - Price the callable on the pinned market.
The comments in the program match these steps.
Rust
use chrono::NaiveDate;
use helixrs::{
AnyInstrument, Callability, CallableBond, CollateralSpec, Currency, DayCountFraction,
FixedRateBond, Frequency, Locator, MarketContext, QuoteSheet, SigmaShape, calibrate_and_pin,
hull_white_mean_reversion_label, hull_white_sigma_label, strip_from_callable,
};
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 main() -> Outcome<()> {
let close = date((2026, 10, 1));
let maturity = date((2032, 10, 1));
let mean_reversion = 0.03;
let usd: Currency = "USD".parse()?;
// Step 1: the callable. A six-year 4.75% semiannual bond, callable at par on
// three anniversaries.
let bond = FixedRateBond::bullet(usd, close, maturity, 0.0475, Frequency::SemiAnnual, DayCountFraction::Thirty360, 100.0)?;
let calls: Vec<Callability> = (0..3).map(|k| Callability::call(date((2028 + k, 10, 1)), 100.0)).collect();
let callable = CallableBond::new(bond, calls.clone());
// Step 2: a curve, and one at-the-money normal vol per swaption the bond
// implies: one per call date, each on a swap running to maturity.
let mut sheet = QuoteSheet::new();
for (tenor, quote) in [("1Y", 0.0441), ("2Y", 0.0452), ("5Y", 0.0468), ("10Y", 0.0481)] {
sheet = sheet.with("USD-SOFR-OIS", Locator::parse(tenor)?, quote);
}
let mut market = MarketContext::as_of(close).with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)?;
let strip = strip_from_callable(&callable, close);
for (point, vol) in strip.iter().zip([0.0105, 0.0101, 0.0098]) {
market = market.with_flat_swaption_vol(usd, point.expiry, point.end, vol);
}
// Step 3: fit σ with the mean reversion you chose, and get the market back with
// both pinned.
let (pinned, fit) = calibrate_and_pin(&market, &callable, mean_reversion, SigmaShape::Flat)?;
for row in &fit.fits {
println!(
"{} into {} price {:.4} model {:.4} vol misfit {:+.2} bp",
row.point.expiry,
row.point.end,
row.market,
row.model,
row.vol_misfit * 1e4
);
}
let sigma = fit.flat().expect("a flat fit");
println!("sigma {sigma:.6} at mean reversion {:.2}", fit.mean_reversion);
// Step 4: price the callable on the pinned market.
let instrument = AnyInstrument::Callable(Box::new(callable));
let value = instrument.npv(&pinned)?;
println!("callable value {value:.6}");
Ok(())
}Python
import datetime as dt
import helixio as hlx
CLOSE = dt.date(2026, 10, 1)
MATURITY = dt.date(2032, 10, 1)
MEAN_REVERSION = 0.03
# Step 1: the callable. A six-year 4.75% semiannual bond, callable at par on
# three anniversaries.
bond = hlx.products.fixed_rate_bond(
"USD", CLOSE, MATURITY, coupon=0.0475, frequency="SemiAnnual", day_count="30/360"
)
calls = [(dt.date(2028 + k, 10, 1), 100.0, "call") for k in range(3)]
callable_bond = hlx.products.callable_bond(bond, calls)
# Step 2: a curve, and one at-the-money normal vol per swaption the bond
# implies: one per call date, each on a swap running to maturity.
sheet = {"USD-SOFR-OIS": {"1Y": 0.0441, "2Y": 0.0452, "5Y": 0.0468, "10Y": 0.0481}}
market = hlx.MarketContext(CLOSE).with_recipe_quotes("USD", sheet)
strip = hlx.models.strip_from_callable(callable_bond, CLOSE)
for (expiry, end), vol in zip(strip, [0.0105, 0.0101, 0.0098]):
market = market.with_flat_swaption_vol("USD", expiry, end, vol)
# Step 3: fit σ with the mean reversion you chose, and get the market back with
# both pinned.
pinned, fit = hlx.calibration.hull_white_pinned(market, callable_bond, MEAN_REVERSION)
rows = fit.fits().to_dict()
for expiry, end, quoted, model, misfit in zip(rows["expiry"], rows["end"], rows["market"], rows["model"], rows["vol_misfit"]):
print(f"{expiry} into {end} price {quoted:.4f} model {model:.4f} vol misfit {misfit * 1e4:+.2f} bp")
print(f"sigma {fit.sigma():.6f} at mean reversion {fit.mean_reversion():.2f}")
# Step 4: price the callable on the pinned market.
value = callable_bond.npv(pinned)
print(f"callable value {value:.6f}")2028-10-01 into 2032-10-01 price 0.0193 model 0.0188 vol misfit -2.81 bp
2029-10-01 into 2032-10-01 price 0.0166 model 0.0168 vol misfit +1.14 bp
2030-10-01 into 2032-10-01 price 0.0121 model 0.0126 vol misfit +4.17 bp
sigma 0.010638 at mean reversion 0.03
callable value 98.067145
Check it worked
- The fit has one row per call date, each on a swap running to the bond's maturity.
- With a flat the misfits are small and of both signs. If one point dominates, try
shape="piecewise", which matches every quote. - The bond prices on the pinned market. On the market before pinning it raises a
PricingError, because no model volatility is filed there.
Related
- Hull-White calibration, the reference for the fit and its shapes.
- Callable bonds, for the bond's option-adjusted spread and risk.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.