Hull-White calibration
This page lists what you need to fit Hull-White's volatility to a strip of swaption vols: the strip, the two shapes of the volatility, the mean reversion you choose, and what the fit reports.
Quick example
This quotes four at-the-money normal vols on a co-terminal strip, fits one flat to them, then fits one piece per expiry. The example reads its rates from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
CollateralSpec, MarketContext, QuoteSheet, SigmaShape, SigmaTerm, StripPoint,
calibrate_hull_white_to_strip,
};
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(SOFR_CLOSE_DATE);
let end = date((2031, 10, 1));
let mean_reversion = 0.03;
let usd = CollateralSpec::Currency("USD".parse()?);
// The curve from the sheet, and one ATM normal vol per strip point, each pinned
// under the label the fit reads.
let strip: Vec<StripPoint> = [1, 2, 3, 4]
.iter()
.map(|years| StripPoint { expiry: date((2026 + years, 10, 1)), end })
.collect();
let quotes = [0.0102, 0.0099, 0.0097, 0.0094];
let sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let mut market = MarketContext::as_of(close).with_recipe_quotes(&usd, &sheet)?;
for (point, vol) in strip.iter().zip(quotes) {
market = market.with_flat_swaption_vol("USD".parse()?, point.expiry, point.end, vol);
println!("{} into {} normal vol {vol:.4}", point.expiry, point.end);
}
// One σ for four quotes: the best fit, not an exact one.
let flat = calibrate_hull_white_to_strip(&market, "USD".parse()?, &usd, &strip, mean_reversion, SigmaShape::Flat)?;
let sigma = flat.flat().expect("a flat fit");
let flat_misfit = flat.fits.iter().map(|f| f.vol_misfit.abs()).fold(0.0, f64::max);
println!("sigma {sigma:.6}");
println!("largest misfit {:.2} bp of normal vol", flat_misfit * 1e4);
// One piece per expiry: as many unknowns as quotes, so every quote is matched.
let piecewise = calibrate_hull_white_to_strip(&market, "USD".parse()?, &usd, &strip, mean_reversion, SigmaShape::Piecewise)?;
let SigmaTerm::Pieces { values, .. } = &piecewise.sigma else {
panic!("a piecewise fit has pieces")
};
for (piece_end, sigma) in piecewise.piece_ends.iter().zip(values) {
println!("piece to {piece_end} sigma {sigma:.6}");
}
let piece_misfit = piecewise.fits.iter().map(|f| f.vol_misfit.abs()).fold(0.0, f64::max);
println!("largest misfit {:.2} bp of normal vol", piece_misfit * 1e4);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
calibration = hlx.calibration
END = dt.date(2031, 10, 1)
MEAN_REVERSION = 0.03
# The curve from the sheet, and one ATM normal vol per strip point, each pinned
# under the label the fit reads.
strip = [(dt.date(2026 + years, 10, 1), END) for years in (1, 2, 3, 4)]
quotes = [0.0102, 0.0099, 0.0097, 0.0094]
market = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("USD", USD_SOFR_CLOSE)
for (expiry, end), vol in zip(strip, quotes):
market = market.with_flat_swaption_vol("USD", expiry, end, vol)
print(f"{expiry} into {end} normal vol {vol:.4f}")
# One σ for four quotes: the best fit, not an exact one.
flat = calibration.hull_white_strip(market, "USD", strip, MEAN_REVERSION, shape="flat")
flat_misfit = max(abs(m) for m in flat.fits().to_dict()["vol_misfit"])
print(f"sigma {flat.sigma():.6f}")
print(f"largest misfit {flat_misfit * 1e4:.2f} bp of normal vol")
# One piece per expiry: as many unknowns as quotes, so every quote is matched.
piecewise = calibration.hull_white_strip(market, "USD", strip, MEAN_REVERSION, shape="piecewise")
pieces = piecewise.sigma().to_dict()
for end, sigma in zip(pieces["end"], pieces["sigma"]):
print(f"piece to {end} sigma {sigma:.6f}")
piece_misfit = max(abs(m) for m in piecewise.fits().to_dict()["vol_misfit"])
print(f"largest misfit {piece_misfit * 1e4:.2f} bp of normal vol")2027-10-01 into 2031-10-01 normal vol 0.0102
2028-10-01 into 2031-10-01 normal vol 0.0099
2029-10-01 into 2031-10-01 normal vol 0.0097
2030-10-01 into 2031-10-01 normal vol 0.0094
sigma 0.010178
largest misfit 5.14 bp of normal vol
piece to 2027-10-01 sigma 0.010458
piece to 2028-10-01 sigma 0.009869
piece to 2029-10-01 sigma 0.009601
piece to 2030-10-01 sigma 0.008757
largest misfit 0.00 bp of normal vol
One flat can't match four quotes, so it leaves a misfit of a few basis points of vol. The piecewise fit has one unknown per quote and matches each one.
How it's priced
The short rate follows
with fitted to today's curve, the mean reversion and the volatility. With held, is fitted so that the model's ATM swaption vols match the quotes: a flat by least squares, a piecewise one piece per expiry, solved in order. Why a callable needs a co-terminal strip is in how calibration works.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| strip quote | an ATM normal vol at one point | MarketContext::with_flat_swaption_vol |
MarketContext.with_flat_swaption_vol |
| strip point | an expiry and the common end | StripPoint |
(expiry, end) |
| fit | with held | calibrate_hull_white_to_strip → CalibratedSigma |
calibration.hull_white_strip → HullWhiteFit |
| shape | flat or piecewise | SigmaShape |
calibration.SigmaShape, or "flat" / "piecewise" |
| strip from a callable | one point per call date | strip_from_callable |
models.strip_from_callable |
| fit from a callable | the fit on the strip a callable implies | calibrate_hull_white |
calibration.hull_white |
| fit and pin | the fit, and a market holding it | calibrate_and_pin |
calibration.hull_white_pinned |
calibration.hull_white_strip takes:
| Argument | Type | Default | Meaning |
|---|---|---|---|
market |
MarketContext |
required | holds the curve and the vols the fit reads |
currency |
str |
required | whose curve and swaption vols are fitted |
strip |
list[tuple[date, date]] |
required | (expiry, end) per point |
mean_reversion |
float |
required | , held fixed by the fit |
collateral |
str or None |
None |
the discounting currency; the currency's own when None |
shape |
SigmaShape or str |
"flat" |
"flat" or "piecewise" |
calibration.hull_white(market, instrument, mean_reversion, shape="flat") and
calibration.hull_white_pinned(...), with the same arguments, read the strip off a callable
instead. The pinned form returns (market, fit).
The result, a HullWhiteFit, has sigma() (a float when flat, a table of end and sigma when
piecewise), shape(), mean_reversion(), currency(), evaluations(), fits() (expiry,
end, market, model, vol_misfit per point, where market and model are prices),
quote_sensitivities() and surface_points().
Defaults and limits
- Choosing : the fit never estimates the mean reversion; it holds the value you pass. A co-terminal strip pins how much the rate moves, not how fast it pulls back, so fitting both to it is unstable. Desks set from experience, typically a few percent a year, or from a separate fit to swaptions of other tenors, and keep it fixed day to day. Change it and moves too.
- Where vols come from: each point reads the flat vol pinned under its label; a point with none
is read at the money off the currency's normal swaption surface, and is listed by
surface_points(). - Pieces: a piecewise fit's pieces end at the strip's expiries.
- Callables: the strip has one point per call date after the valuation date and before maturity; make-whole calls are skipped.
See also
- Source:
crates/helixrs/src/models/hw_calibration.rs,crates/helixrs/src/market/context.rs - Related pages:
- Calibrate Hull-White and pin it, fitting to a callable and pricing on the result.
- Callable bonds, what the fitted model prices.
- SABR, for a smile rather than a strip.
- Bootstrapping, for the curve the model is fitted on.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.