← learnModule 7 · Calibration6 min read

Hull-White: swaptions from a root-find, not a tape

Jamshidian decomposition needs a 1-D root-find for the critical short rate and a cumulative normal for every zero-bond option — a third, structurally different reason a model can end up off the tape.

7.2's Heston price was noisy but still recordable — an adjoint sweep just had to survive the Monte-Carlo estimator. 7.3's Heston characteristic function was deterministic but needed sin/cos/a complex logarithm the tape doesn't have. Hull-White's term-structure-consistent swaption price breaks a third way: pricing it means running an actual iterative root-find inside the pricer, plus a cumulative normal distribution function on the result. A tape is a fixed sequence of arithmetic, recorded once; a root-find doesn't know how many iterations it needs until it's run. How do you calibrate a model whose own pricing formula contains a solver?

The whole story

HullWhiteAnalytic reprices today's curve exactly with no separate theta(t) solve, then prices a European swaption by Jamshidian decomposition: a bisection root-find for the critical short rate r*, then a sum of zero-bond puts at that rate, each needing a cumulative normal. HullWhiteCalibration nests its own private, hand-specialized 2D Nelder-Mead — distinct from 7.3's general one — with feasibility enforced by the objective itself returning a large penalty. The real run: 6 co-terminal swaptions fit to a=0.1846, sigma=133.7bp/yr, RMSE 2.695e-4, converged in 131 iterations and 57ms; the same code on a clean, self-consistent grid recovers the generating (a,sigma) to the printed digit.

Did you know?

HullWhiteCalibration's own doc comment states the reason plainly: "the analytic swaption is not recordable (a root-find and N(x)), so this is the numerical rather than the adjoint calibration route; the adjoint route runs against the HullWhite1F Monte-Carlo swaption instead." HullWhite1F is a real, separate "Seam 5" step block — the same composable pattern 7.2's HestonModel used — built specifically to be tape-friendly by trading the full term structure for a flat mean-reversion level b, so dV/da and dV/dsigma come from one adjoint sweep when that simplification is acceptable. This page's model doesn't make that trade, so it doesn't get that sweep.

A curve fit with no solve, a swaption price with one

HullWhiteAnalytic.bondReconstitution reprices today's curve exactly by construction — A(t,T) is built directly from the input curve, so there's no separate theta(t) calibration step at all. Pricing a European swaption is a different story. Jamshidian's trick turns a swaption into a portfolio of zero-bond options, but only once you know the short rate r* at which the underlying coupon bond is worth exactly par — and that has no closed form:

private double solveCriticalRate(double expiry, double[] payTimes, double[] coupon) {
  java.util.function.DoubleUnaryOperator couponBond = r -> {
    double v = 0.0;
    for (int i = 0; i < payTimes.length; i++) {
      v += coupon[i] * bondReconstitution(expiry, payTimes[i], r);
    }
    return v - 1.0;
  };
  // bisection: widen the bracket if needed, then up to 200 halvings

A bisection search — widen the bracket while the sign doesn't change, then up to 200 halvings — is a loop whose length depends on the data, not something a tape (a fixed sequence of nodes, replayed identically every time) can express. Every zero-bond call and put on top of that root also needs Normal.cdf, a cumulative normal with no elementary closed form either. Both are ordinary host-side double code here, and neither one could be recorded even if the engine wanted to try.

A second Nelder-Mead, hand-built for two parameters

HullWhiteCalibration doesn't reuse 7.3's general transform.NelderMead. It nests its own private, two-parameter-specialized simplex — three points instead of n+1, (a, sigma) hardcoded instead of a double[]:

static Result minimise(java.util.function.DoubleBinaryOperator f, double s0, double s1,
                       double step0, double step1, int maxIter, double tol) {
  double[][] p = { {s0, s1}, {s0 + step0, s1}, {s0, s1 + step1} };
  // reflect / expand / contract / shrink, same four moves as 7.3 — written twice

Each quoted ATM normal vol becomes a target price through Bachelier (feature F2), and the objective is the sum of squared residuals against HullWhiteAnalytic.payerSwaption. Feasibility is enforced differently here than in 7.3, though: the objective itself returns a sentinel for an invalid point —

if (a <= 0.0 || sigma <= 0.0) {
  return 1e18;
}
Did you know?

7.3's NelderMead.java class doc claims out-of-range points get "a large penalty," but its actual code just clamps them onto the box boundary — a real doc/code mismatch, confirmed by reading NelderMead.minimise directly. This file is the other half of that story: HullWhiteCalibration's own private simplex has no clamping logic anywhere, and instead does exactly what 7.3's docstring described — a large sentinel value (1e18) returned from inside the objective function itself whenever a or sigma strays non-positive. Two independent Nelder-Mead implementations in the same codebase, solving the same shape of problem, enforcing feasibility two different ways — and the one whose own doc comment promises a penalty is the one that doesn't deliver it.

The real run

HullWhiteCalibrationShowcase fits (a, sigma) to 6 co-terminal ATM swaptions (expiries 1y7y, tenors 9y down to 3y) on an upward curve (zeros 2.6%3.5% over 1y12y), where each quote carries a deliberate idiosyncratic bump so the grid isn't perfectly one-factor consistent:

expirytenortarget pricemodel price
190.0192350.018993
280.0227150.023027
370.0243220.023985
460.0233900.023254
550.0210040.021388
730.0151080.015135

131 iterations, converged=true, 57 ms, fitted a = 0.1846, sigma = 133.7 bp/yr, price RMSE 2.695×10⁻⁴. The fitted curve reprices the input exactly (P(0,10) model 0.71631421 against curve 0.71631421, to 8 decimals) — that part is never approximate, it's the swaption fit that absorbs the grid's inconsistency. Run the same code against a clean grid generated from a known (a*, sigma*) = (0.09, 0.0095) with no idiosyncratic bump instead (the pinned test's own scenario) and it recovers both parameters to the printed digit, RMSE 3.8×10⁻¹⁷, converged=true — worth contrasting directly with 7.3's near-machine- precision Nelder-Mead fit, which hit its iteration cap without ever being certified: this simplex is two-dimensional, not five, and collapses far enough to satisfy its own tolerance well before running out of iterations.

Try it yourself

The showcase's reference model uses sigma = 0.0095 (95bp/yr) to generate the idiosyncratically-bumped quotes. Change it to 0.019 (190bp/yr) and predict the fitted (a, sigma) before you run it. (a barely moves — 0.1847 against 0.1846 — but sigma comes back at 267.5 bp/yr, almost exactly double, tracking the doubled input. The fit costs more to find, though: iters=400 (the hard cap) and converged=false, RMSE roughly doubling too, to 5.386×10⁻⁴.)

▶️ Run it

mvn -o -q -pl nablatensor-examples exec:java \
  -Dexec.mainClass=com.nablatensor.examples.HullWhiteCalibrationShowcase

No engine string anywhere in this command — nothing on this page ever touches AadRecorder, cpu, or cpu-jit.

⚠️ What this doesn't do

The 6-quote grid this page's headline numbers come from is deliberately not fully one-factor consistent (per-node bumps of up to 3%), so the fitted (a, sigma) is a best compromise across the grid, not a "true" recovery the way the clean-grid contrast above is — read the RMSE, not just the parameter values, when judging a real fit. A single-factor short-rate model can't fit a volatility skew across strikes at one expiry (everything here is ATM); Jamshidian's bisection implicitly assumes the coupon bond's value is monotonic in r, true for Hull-White but not a universal short-rate-model property; and nothing here touches a shifted or negative-rate variant, though bFactor's and theta's own a → 0 handling shows the codebase already cares about numerically delicate limits elsewhere in this same class.

What's next

→ Deeper: Term-structure Hull-White: caplets, Jamshidian swaptions, and calibration has the full API (caplet, cap, receiverSwaption) and the pinned test tolerances this page's numbers are checked against. → Next: GARCH and maximum likelihood: a tape too big to JIT — Module 7.6 closes out the calibration module with the one example that isn't an option-pricing model at all.


Questions or corrections? open an issue