← learnModule 7 · Calibration5 min read

Fitting a smile: SABR in two seconds

How one recorded objective and an adjoint gradient turn overnight derivative-free smile calibration into a fit that finishes before you've let go of Enter.

Every page so far has priced a payoff against market parameters you already knew. A trading desk doesn't get that luxury — it sees today's quoted volatilities at a handful of strikes and has to find the SABR parameters that produced them, fast enough to matter before the market moves again. Derivative-free search treats every trial as a black box and needs a full extra valuation per parameter just to estimate a gradient by finite differences. What if the valuation itself could hand back its own gradient, for free, every time?

The whole story

Alpha, rho and nu enter the tape as named inputs; the loop over six strikes builds a sum-of-squared-residuals against the Hagan formula and records it once. Every L-BFGS iteration is one setInput plus one adjoint sweep, producing the value and the full gradient together, regardless of how many parameters there are. The real run: target alpha 0.2840, rho −0.3100, nu 0.5700, recovered to the same four digits, residual 6.355×10⁻²⁶, 56 iterations, converged in 1.6 seconds on one CPU core.

Did you know?

Calibrator's own doc comment states its whole design in one line: "the same machinery calibrates any recorded objective." Swap SabrHagan.blackVol for a Heston characteristic function, a local-vol surface, or a curve, and nothing about the L-BFGS driver changes — it only ever sees a named-input, named-output tape. That reusability is also why Calibrator.java's own class-doc example still writes SDouble, not ADouble — the same leftover name from the SDoubleADouble rename that AadRecorder's own doc comment carried back in 1.1, and that MultiOutput.java's class doc turned out to share too, found while researching 6.2. Three source files now, with the identical stale example — including, this time, the one file whose whole thesis is "swap the model, keep the machinery": the machinery's own usage example is what's out of date.

The objective, recorded once

HestonSabrCalibration records exactly one tape: three named parameters, a fixed beta, and a sum of squared residuals against six market quotes:

Calibrator.Result r = Calibrator.of(rec -> {
      ADouble alpha = rec.input("alpha", 0.20);
      ADouble rho = rec.input("rho", 0.0);
      ADouble nu = rec.input("nu", 0.30);
      ADouble beta = rec.constant(BETA);
      ADouble sse = rec.constant(0.0);
      for (int i = 0; i < STRIKES.length; i++) {
        ADouble d = SabrHagan.blackVol(rec, alpha, beta, rho, nu, F, STRIKES[i], T).sub(target[i]);
        sse = sse.add(d.mul(d));
      }
      rec.output(sse);
    })
    .parameter("alpha", 0.20, 1e-4, 2.0)
    .parameter("rho", 0.0, -0.999, 0.999)
    .parameter("nu", 0.30, 1e-4, 5.0)
    .solve();

SabrHagan.blackVol has two forms: a plain-double version that generates the six target quotes (standing in for real market data here), and this tape-level one, whose alpha, beta, rho and nu are ADouble. Both implement the same 2002 Hagan et al. lognormal-vol approximation; the tape version is the one Calibrator differentiates.

One adjoint sweep per iteration

Calibrator.solveLbfgs drives a box-projected L-BFGS: every iteration is one setInput on the three parameters, one adjoint sweep for the SSE value, and — from that same sweep — the full 3-wide gradient, backtracking line search to keep every trial point inside its declared [lo, hi], and a two-loop recursion over the last 7 (s, y) curvature pairs, a number fixed in the code (private int history = 7) with no public setter to change it. None of that cost scales with how many strikes are in the smile or how many parameters are being fit — it's 2.1's free-Greeks argument again, this time powering an optimizer instead of a risk report: one extra reverse sweep buys the whole gradient, whether there are 3 parameters or 30.

The real run

HestonSabrCalibration generates six target vols from a known (alpha=0.284, rho=−0.31, nu=0.57), starts the fit from (0.20, 0.0, 0.30), and recovers:

parametertargetrecovered
alpha0.28400.2840
rho−0.3100−0.3100
nu0.57000.5700

56 iterations, converged=true, residual SSE 6.355×10⁻²⁶, in 1.6 seconds on one CPU core — cpu-jit, no GPU anywhere in this page. Every one of the six fitted vols matches its target to six printed decimals. That precision comes with an asterisk worth stating plainly: the "market" here is Hagan fitting Hagan — a smile generated by the exact formula being calibrated back against it. A real, noisy market quote won't collapse the residual to 1e-26; it'll stop wherever the model's own approximation error and the market's own noise floor meet.

Did you know?

The plain-double host form of blackVol branches at the-money (if (Math.abs(logFK) < 1e-12) return a * b;) to sidestep a 0/0 in the z / xz term as strike → forward. The tape-level form doesn't attempt that branch — its own doc comment says plainly "requires strike != forward (use a small offset for the ATM point)," and the code backs it up: it throws IllegalArgumentException immediately, during recording, rather than trying to record a conditional path through the singularity. It's the same shape of constraint 6.1's lattice ran into with Math.max — a tape wants one fixed sequence of arithmetic, and a removable singularity that needs a branch to remove doesn't fit that shape any more easily than early exercise did.

Try it yourself

Add 0.05 (equal to F) as a seventh entry in STRIKES and rerun — predict what happens before you do. It isn't a bad fit or a NaN; the tape-level blackVol call inside the recording throws IllegalArgumentException: tape-level blackVol needs strike != forward the moment that strike is recorded, before Calibrator ever gets to run a single L-BFGS iteration.

▶️ Run it

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

Calibrator's engine defaults to cpu-jit and nothing in this example overrides it — there's no engine string to pass.

⚠️ What this doesn't do

beta is fixed at 0.5 throughout, not calibrated — SABR's four parameters are jointly under-identified from a single smile, and picking beta by convention (or from a separate time-series estimate) rather than fitting it alongside alpha, rho and nu is standard practice, not a shortcut this engine invented. This page also doesn't calibrate to real, noisy market quotes, doesn't fit a term structure of smiles (one T at a time here), and doesn't touch the low-strike or negative-rate regime where Hagan's lognormal approximation is known to misbehave and a shifted or normal SABR variant is usually preferred instead.

What's next

→ Deeper: Calibrate SABR with an adjoint gradient has the full recorded objective and output table this page walks through — note its code sample still says SDouble, the same stale rename this page's first sidenote traces. → Next: The Heston model and why calibration needs gradients — same Calibrator, a model with no closed-form vol to fall back on.


Questions or corrections? open an issue