From 54ff95537253a3277f9ee02c6bad203173afd906 Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:03:33 +0000 Subject: [PATCH 1/7] Bermudan swaption example: vegas through the calibration with AAD Uses the implicit function theorem at the calibrated parameters instead of differentiating the optimizer. --- Python/examples/bermudan-swaption.py | 126 +++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/Python/examples/bermudan-swaption.py b/Python/examples/bermudan-swaption.py index 7ef2c7ac..7620cf15 100644 --- a/Python/examples/bermudan-swaption.py +++ b/Python/examples/bermudan-swaption.py @@ -249,3 +249,129 @@ def calibrate(model, helpers, l, name): # - print(pd.DataFrame(data, columns=["model", "in-the-money", "at-the-money", "out-of-the-money"])) + +# ### Sensitivities with AAD +# +# We want the vegas of the in-the-money Bermudan swaption with respect to the +# five swaption volatility quotes, with the Hull-White parameters moving as the +# calibration would move them. As in the QuantLibAAD C++ sample, the model is +# calibrated with Jamshidian's formula and Levenberg-Marquardt, and the +# Bermudan is priced with a finite-difference Hull-White engine. +# +# Recording the calibration itself on the tape would differentiate the +# optimizer's iterations: how its last steps happen to depend on the quotes, +# with its own internal approximations, not how the optimum does. Those are not +# the same thing, and the difference doesn't vanish with a tighter tolerance. +# Instead we use the implicit function theorem. At the calibrated parameters +# $p$ the gradient of the calibration objective $F(p, v)$ is zero for any +# volatilities $v$, so +# +# $$\frac{dV}{dv} = -\frac{\partial V}{\partial p} \, H_{pp}^{-1} H_{pv},$$ +# +# with the Hessian blocks $H_{pp}$ and $H_{pv}$ of $F$ taken from central +# differences of its AAD gradient in the two parameters. The optimizer is never +# differentiated, and the price itself doesn't depend on the quotes directly. + +import numpy as np +from xad.adj_1st import Tape + +marketVols = [vol for _, _, vol in swaptionVols] +vegaNames = ["vol %sx%s" % (maturity, length) for maturity, length, _ in swaptionVols] + + +def calibrationHelpers(vols, model): + result = [] + for (maturity, length, _), vol in zip(swaptionVols, vols): + helper = ql.SwaptionHelper( + maturity, + length, + ql.QuoteHandle(ql.SimpleQuote(vol)), + index, + index.tenor(), + index.dayCounter(), + index.dayCounter(), + termStructure, + ) + helper.setPricingEngine(ql.JamshidianSwaptionEngine(model)) + result.append(helper) + return result + + +def calibratedParameters(vols): + model = ql.HullWhite(termStructure) + model.calibrate(calibrationHelpers(vols, model), ql.LevenbergMarquardt(), + ql.EndCriteria(400, 100, 1e-8, 1e-8, 1e-8)) + return [float(x) for x in model.params()] + + +def bermudanNPV(model): + exerciseDates = [ql.as_coupon(c).accrualStartDate() for c in dummy.fixedLeg()] + swaption = ql.Swaption(itmSwap, ql.BermudanExercise(exerciseDates)) + swaption.setPricingEngine(ql.FdHullWhiteSwaptionEngine(model)) + return swaption.NPV() + + +# The objective is the sum of squared calibration errors, which `calibrate` +# minimizes; one recording gives its gradient in the parameters and the +# volatilities. + +def objectiveGradient(params, vols): + with Tape() as tape: + p = [ql.Real(x) for x in params] + v = [ql.Real(x) for x in vols] + tape.registerInputs(p) + tape.registerInputs(v) + tape.newRecording() + + model = ql.HullWhite(termStructure, p[0], p[1]) + f = 0.0 + for helper in calibrationHelpers(v, model): + error = helper.calibrationError() + f = f + error * error + + tape.registerOutput(f) + f.derivative = 1.0 + tape.computeAdjoints() + return np.array([x.derivative for x in p]), np.array([x.derivative for x in v]) + + +def npvAndParameterSensitivities(params): + with Tape() as tape: + p = [ql.Real(x) for x in params] + tape.registerInputs(p) + tape.newRecording() + + npv = bermudanNPV(ql.HullWhite(termStructure, p[0], p[1])) + + tape.registerOutput(npv) + npv.derivative = 1.0 + tape.computeAdjoints() + return float(npv), np.array([x.derivative for x in p]) + + +def aad_sensitivities(): + ql.Settings.instance().evaluationDate = todaysDate + params = calibratedParameters(marketVols) + npv, dVdp = npvAndParameterSensitivities(params) + + Hpp = np.zeros((len(params), len(params))) + Hpv = np.zeros((len(params), len(marketVols))) + for k, value in enumerate(params): + h = 1e-6 * max(abs(value), 1e-3) + up, down = list(params), list(params) + up[k] += h + down[k] -= h + gpUp, gvUp = objectiveGradient(up, marketVols) + gpDown, gvDown = objectiveGradient(down, marketVols) + Hpp[:, k] = (gpUp - gpDown) / (2 * h) + Hpv[k, :] = (gvUp - gvDown) / (2 * h) + + vegas = -dVdp @ np.linalg.solve(Hpp, Hpv) + return npv, dict(zip(vegaNames, (float(x) for x in vegas))) + + +npv, vegas = aad_sensitivities() +print("\nIn-the-money Bermudan (Hull-White, finite differences) = {:.6f}".format(npv)) +print("Vegas through the calibration, per unit change of volatility:") +for name, vega in vegas.items(): + print(" {:<10} {:>12.6f}".format(name, vega)) From fee3955831bdb3a30896cddc52d196d507078de2 Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:08:25 +0000 Subject: [PATCH 2/7] CDS example: sensitivities through the hazard bootstrap with AAD --- Python/examples/cds.py | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Python/examples/cds.py b/Python/examples/cds.py index 3203dff8..de4d5f0b 100644 --- a/Python/examples/cds.py +++ b/Python/examples/cds.py @@ -125,3 +125,93 @@ print(" default leg: {:.7g}".format(cds.defaultLegNPV())) print(" coupon leg: {:.7g}".format(cds.couponLegNPV())) print("") + +# ### Sensitivities with AAD +# +# QuantLibAAD records every operation on a tape, including the bootstrap of +# the hazard curve, so one backward pass gives the sensitivities of the 2Y CDS +# to all its inputs at once: the quoted spreads (through the bootstrap), the +# risk-free rate and recovery rate (used by both the bootstrap and the +# engine), the CDS's own running coupon and its nominal. +# +# The inputs are `ql.Real` values registered on the tape before the curves, +# helpers and CDS are built from them. As a check, we compare with central +# differences, re-bootstrapping the curve for each bump. + +from xad.adj_1st import Tape + +marketInputs = {"spread %s" % name: s + for name, s in zip(["3M", "6M", "1Y", "2Y"], quoted_spreads)} +marketInputs.update({"risk-free rate": 0.01, "recovery rate": recovery_rate, + "coupon": quoted_spreads[-1], "nominal": nominal}) + + +def cdsNPV(x): + dayCounter = ql.Actual365Fixed() + riskFree = ql.YieldTermStructureHandle(ql.FlatForward(todaysDate, x["risk-free rate"], dayCounter)) + helpers = [ + ql.SpreadCdsHelper( + ql.QuoteHandle(ql.SimpleQuote(x["spread %s" % name])), + tenor, + 0, + calendar, + ql.Quarterly, + ql.Following, + ql.DateGeneration.TwentiethIMM, + dayCounter, + x["recovery rate"], + riskFree, + ) + for name, tenor in zip(["3M", "6M", "1Y", "2Y"], tenors) + ] + hazard = ql.DefaultProbabilityTermStructureHandle( + ql.PiecewiseFlatHazardRate(todaysDate, helpers, dayCounter)) + schedule = ql.Schedule( + todaysDate, + maturities[-1], + ql.Period(ql.Quarterly), + calendar, + ql.Following, + ql.Unadjusted, + ql.DateGeneration.TwentiethIMM, + False, + ) + cds = ql.CreditDefaultSwap(ql.Protection.Seller, x["nominal"], x["coupon"], schedule, + ql.Following, dayCounter) + cds.setPricingEngine(ql.MidPointCdsEngine(hazard, x["recovery rate"], riskFree)) + return cds.NPV() + + +def aad_sensitivities(): + ql.Settings.instance().evaluationDate = todaysDate + with Tape() as tape: + inputs = {name: ql.Real(value) for name, value in marketInputs.items()} + tape.registerInputs(inputs.values()) + tape.newRecording() + + npv = cdsNPV(inputs) + + tape.registerOutput(npv) + npv.derivative = 1.0 + tape.computeAdjoints() + return float(npv), {name: x.derivative for name, x in inputs.items()} + + +def bumped_sensitivities(relativeBump=1e-4): + sensitivities = {} + for name, value in marketInputs.items(): + h = relativeBump * max(1.0, abs(value)) + up, down = dict(marketInputs), dict(marketInputs) + up[name] += h + down[name] -= h + sensitivities[name] = (float(cdsNPV(up)) - float(cdsNPV(down))) / (2 * h) + return sensitivities + + +npv, aad = aad_sensitivities() +bumped = bumped_sensitivities() + +print("2Y CDS NPV = {:.6f}".format(npv)) +print("{:<16}{:>18}{:>18}".format("Input", "AAD", "Central diff.")) +for name in marketInputs: + print("{:<16}{:>18.6f}{:>18.6f}".format(name, aad[name], bumped[name])) From a79139ab975b80d4cd98f8e19f7fd37ba99ccdad Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:30:26 +0000 Subject: [PATCH 3/7] Bermudan swaption example: calibrate to the optimum for the vegas --- Python/examples/bermudan-swaption.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Python/examples/bermudan-swaption.py b/Python/examples/bermudan-swaption.py index 7620cf15..f4245025 100644 --- a/Python/examples/bermudan-swaption.py +++ b/Python/examples/bermudan-swaption.py @@ -271,6 +271,9 @@ def calibrate(model, helpers, l, name): # with the Hessian blocks $H_{pp}$ and $H_{pv}$ of $F$ taken from central # differences of its AAD gradient in the two parameters. The optimizer is never # differentiated, and the price itself doesn't depend on the quotes directly. +# +# The theorem assumes the calibration has reached the optimum, so we calibrate +# with much tighter tolerances than the C++ sample does. import numpy as np from xad.adj_1st import Tape @@ -297,10 +300,15 @@ def calibrationHelpers(vols, model): return result +calibrationTolerance = 1e-12 + + def calibratedParameters(vols): model = ql.HullWhite(termStructure) - model.calibrate(calibrationHelpers(vols, model), ql.LevenbergMarquardt(), - ql.EndCriteria(400, 100, 1e-8, 1e-8, 1e-8)) + tol = calibrationTolerance + # the first argument, the finite-difference step of the Jacobian, keeps its default + model.calibrate(calibrationHelpers(vols, model), ql.LevenbergMarquardt(1e-8, tol, tol), + ql.EndCriteria(4000, 1000, tol, tol, tol)) return [float(x) for x in model.params()] From 6480cf32b2cc5e6bf02e17bf15c41758fd588611 Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:32:01 +0000 Subject: [PATCH 4/7] Changelog: AAD sections in the Bermudan swaption and CDS examples --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9cecaa..7a636570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Colab notebooks in `notebooks/`: option Greeks and curve risk in one adjoint sweep +- The Bermudan swaption and CDS examples compute their sensitivities with AAD ## [1.44.0] - 2026-11-12 From 3113f9b312ab22fc18e4c44f13cf9511efba875c Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:48:26 +0000 Subject: [PATCH 5/7] CDS example: reuse the script's rate and tenors in the AAD section, snake_case names --- Python/examples/cds.py | 47 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Python/examples/cds.py b/Python/examples/cds.py index de4d5f0b..bc047503 100644 --- a/Python/examples/cds.py +++ b/Python/examples/cds.py @@ -54,7 +54,8 @@ todaysDate = ql.Date(15, ql.May, 2007) ql.Settings.instance().evaluationDate = todaysDate -risk_free_rate = ql.YieldTermStructureHandle(ql.FlatForward(todaysDate, 0.01, ql.Actual365Fixed())) +flat_rate = 0.01 +risk_free_rate = ql.YieldTermStructureHandle(ql.FlatForward(todaysDate, flat_rate, ql.Actual365Fixed())) # ### CDS parameters @@ -140,32 +141,32 @@ from xad.adj_1st import Tape -marketInputs = {"spread %s" % name: s - for name, s in zip(["3M", "6M", "1Y", "2Y"], quoted_spreads)} -marketInputs.update({"risk-free rate": 0.01, "recovery rate": recovery_rate, - "coupon": quoted_spreads[-1], "nominal": nominal}) +tenor_labels = [str(tenor) for tenor in tenors] +market_inputs = {"spread %s" % label: s for label, s in zip(tenor_labels, quoted_spreads)} +market_inputs.update({"risk-free rate": flat_rate, "recovery rate": recovery_rate, + "coupon": quoted_spreads[-1], "nominal": nominal}) -def cdsNPV(x): - dayCounter = ql.Actual365Fixed() - riskFree = ql.YieldTermStructureHandle(ql.FlatForward(todaysDate, x["risk-free rate"], dayCounter)) +def cds_npv(x): + day_counter = ql.Actual365Fixed() + risk_free = ql.YieldTermStructureHandle(ql.FlatForward(todaysDate, x["risk-free rate"], day_counter)) helpers = [ ql.SpreadCdsHelper( - ql.QuoteHandle(ql.SimpleQuote(x["spread %s" % name])), + ql.QuoteHandle(ql.SimpleQuote(x["spread %s" % label])), tenor, 0, calendar, ql.Quarterly, ql.Following, ql.DateGeneration.TwentiethIMM, - dayCounter, + day_counter, x["recovery rate"], - riskFree, + risk_free, ) - for name, tenor in zip(["3M", "6M", "1Y", "2Y"], tenors) + for label, tenor in zip(tenor_labels, tenors) ] hazard = ql.DefaultProbabilityTermStructureHandle( - ql.PiecewiseFlatHazardRate(todaysDate, helpers, dayCounter)) + ql.PiecewiseFlatHazardRate(todaysDate, helpers, day_counter)) schedule = ql.Schedule( todaysDate, maturities[-1], @@ -177,19 +178,19 @@ def cdsNPV(x): False, ) cds = ql.CreditDefaultSwap(ql.Protection.Seller, x["nominal"], x["coupon"], schedule, - ql.Following, dayCounter) - cds.setPricingEngine(ql.MidPointCdsEngine(hazard, x["recovery rate"], riskFree)) + ql.Following, day_counter) + cds.setPricingEngine(ql.MidPointCdsEngine(hazard, x["recovery rate"], risk_free)) return cds.NPV() def aad_sensitivities(): ql.Settings.instance().evaluationDate = todaysDate with Tape() as tape: - inputs = {name: ql.Real(value) for name, value in marketInputs.items()} + inputs = {name: ql.Real(value) for name, value in market_inputs.items()} tape.registerInputs(inputs.values()) tape.newRecording() - npv = cdsNPV(inputs) + npv = cds_npv(inputs) tape.registerOutput(npv) npv.derivative = 1.0 @@ -197,14 +198,14 @@ def aad_sensitivities(): return float(npv), {name: x.derivative for name, x in inputs.items()} -def bumped_sensitivities(relativeBump=1e-4): +def bumped_sensitivities(relative_bump=1e-4): sensitivities = {} - for name, value in marketInputs.items(): - h = relativeBump * max(1.0, abs(value)) - up, down = dict(marketInputs), dict(marketInputs) + for name, value in market_inputs.items(): + h = relative_bump * max(1.0, abs(value)) + up, down = dict(market_inputs), dict(market_inputs) up[name] += h down[name] -= h - sensitivities[name] = (float(cdsNPV(up)) - float(cdsNPV(down))) / (2 * h) + sensitivities[name] = (float(cds_npv(up)) - float(cds_npv(down))) / (2 * h) return sensitivities @@ -213,5 +214,5 @@ def bumped_sensitivities(relativeBump=1e-4): print("2Y CDS NPV = {:.6f}".format(npv)) print("{:<16}{:>18}{:>18}".format("Input", "AAD", "Central diff.")) -for name in marketInputs: +for name in market_inputs: print("{:<16}{:>18.6f}{:>18.6f}".format(name, aad[name], bumped[name])) From 0a7d8ec796ae9b7db1ef648205719c7c8d412e30 Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:48:26 +0000 Subject: [PATCH 6/7] Bermudan swaption example: clarify the engine label and the optimizer note --- Python/examples/bermudan-swaption.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Python/examples/bermudan-swaption.py b/Python/examples/bermudan-swaption.py index f4245025..d3d137e7 100644 --- a/Python/examples/bermudan-swaption.py +++ b/Python/examples/bermudan-swaption.py @@ -261,7 +261,8 @@ def calibrate(model, helpers, l, name): # Recording the calibration itself on the tape would differentiate the # optimizer's iterations: how its last steps happen to depend on the quotes, # with its own internal approximations, not how the optimum does. Those are not -# the same thing, and the difference doesn't vanish with a tighter tolerance. +# the same thing, and in practice the result stays off even with much tighter +# end criteria. # Instead we use the implicit function theorem. At the calibrated parameters # $p$ the gradient of the calibration objective $F(p, v)$ is zero for any # volatilities $v$, so @@ -379,7 +380,7 @@ def aad_sensitivities(): npv, vegas = aad_sensitivities() -print("\nIn-the-money Bermudan (Hull-White, finite differences) = {:.6f}".format(npv)) +print("\nIn-the-money Bermudan (Hull-White, finite-difference engine) = {:.6f}".format(npv)) print("Vegas through the calibration, per unit change of volatility:") for name, vega in vegas.items(): print(" {:<10} {:>12.6f}".format(name, vega)) From 990bc38faa31903cc1594a52e613ff5d225768e8 Mon Sep 17 00:00:00 2001 From: Auto Differentiation Dev Team <107129969+auto-differentiation-dev@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:54:22 +0000 Subject: [PATCH 7/7] Bermudan swaption example: larger step for the Hessian of the calibration objective --- Python/examples/bermudan-swaption.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Python/examples/bermudan-swaption.py b/Python/examples/bermudan-swaption.py index d3d137e7..8edcd7fb 100644 --- a/Python/examples/bermudan-swaption.py +++ b/Python/examples/bermudan-swaption.py @@ -366,7 +366,8 @@ def aad_sensitivities(): Hpp = np.zeros((len(params), len(params))) Hpv = np.zeros((len(params), len(marketVols))) for k, value in enumerate(params): - h = 1e-6 * max(abs(value), 1e-3) + # smaller steps let the noise in the AAD gradient dominate the difference quotient + h = 1e-5 * max(abs(value), 1e-3) up, down = list(params), list(params) up[k] += h down[k] -= h