Esc

    The API is under testing and may change before release.

    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 σ\sigma 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.

    Rust
    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(())
    }
    Python
    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 σ\sigma 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

    drt=(θ(t)−a rt)dt+σ(t) dWt,dr_t = \left(\theta(t) - a\, r_t\right) dt + \sigma(t)\, dW_t ,

    with θ\theta fitted to today's curve, aa the mean reversion and σ\sigma the volatility. With aa held, σ\sigma is fitted so that the model's ATM swaption vols match the quotes: a flat σ\sigma by least squares, a piecewise σ\sigma 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 σ\sigma with aa 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 aa, 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 aa: 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 aa 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 σ\sigma 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.