From 9ec20a1ecbece6ffb1cac9247401216e1298423a 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 14:41:30 +0000
Subject: [PATCH] Example: AAD vs bumping cost as the volatility surface grows
---
CHANGELOG.md | 2 +
Python/examples/scaling-adjoint.py | 315 +++++++++++++++++++++++++++++
2 files changed, 317 insertions(+)
create mode 100644 Python/examples/scaling-adjoint.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d9cecaa..ef72c9d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ 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
+- Example `scaling-adjoint.py`: cost of all sensitivities of an option book with AAD and with
+ bumping as the volatility surface grows
## [1.44.0] - 2026-11-12
diff --git a/Python/examples/scaling-adjoint.py b/Python/examples/scaling-adjoint.py
new file mode 100644
index 00000000..1e9ecc7e
--- /dev/null
+++ b/Python/examples/scaling-adjoint.py
@@ -0,0 +1,315 @@
+# ---
+# jupyter:
+# jupytext:
+# formats: py:percent
+# text_representation:
+# extension: .py
+# format_name: percent
+# format_version: '1.3'
+# jupytext_version: 1.4.2
+# kernelspec:
+# display_name: Python 3
+# language: python
+# name: python3
+# ---
+
+# %% [markdown]
+# # Adjoint sensitivities as the number of inputs grows
+#
+# Copyright (©) 2026 Xcelerit Computing Limited.
+#
+# This file is part of QuantLibAAD, a Python wrapper for QuantLib enabled
+# for risk computation using automatic differentiation. It uses XAD,
+# a fast and comprehensive C++ library for automatic differentiation.
+#
+# QuantLibAAD and XAD are free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# QuantLibAAD is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+# QuantLib is free software: you can redistribute it and/or modify it under the
+# terms of the QuantLib license. You should have received a copy of the
+# license along with this program; if not, please email
+# . The license is also available online at
+# .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the license for more details.
+
+# %% [markdown]
+# A fixed book of 250 European options is priced off spot, a dividend yield, a
+# 7-point zero curve and a Black volatility surface that grows from 4 expiries
+# by 5 strikes up to 20 by 25, so from 29 to 509 market inputs.
+#
+# All sensitivities are computed in two ways:
+#
+# * **AAD**: the pricing is recorded once on an XAD tape and one adjoint sweep
+# gives the derivative of the book value to every input;
+# * **bumping**: forward differences, one extra pricing of the book per input.
+#
+# The cost of AAD grows only slightly as the surface grows, while the cost of
+# bumping grows with the number of inputs.
+
+# %%
+import math
+import time
+from dataclasses import dataclass
+
+import QuantLibAAD as ql
+from xad.adj_1st import Tape
+
+# %% [markdown]
+# ### Setup
+#
+# Everything that stays fixed while the market moves: the curve dates, the
+# surface grid and the book. The zero curve runs up to the first node past the
+# book's last maturity, so that every node has a sensitivity. Expiries are
+# spread between 1 month and 5 years, strikes between 60 and 140. The book is
+# drawn from a Mersenne-Twister generator with a fixed seed, so it is the same
+# for every surface size.
+
+# %%
+TODAY = ql.Date(15, ql.May, 2026)
+BOOK_SIZE = 250
+CURVE_MONTHS = [0, 3, 6, 12, 24, 36, 60]
+SURFACES = [(4, 5), (6, 10), (10, 15), (15, 20), (20, 25)] # expiries x strikes
+
+
+@dataclass
+class Setup:
+ curve_dates: list
+ expiries: list
+ strikes: list
+ book: list # (option type, strike, maturity)
+
+
+def make_book(size):
+ # three draws per option: type, strike, maturity
+ rng = ql.UniformRandomSequenceGenerator(3 * size, ql.UniformRandomGenerator(42))
+ draws = iter(float(x) for x in rng.nextSequence().value())
+ book = []
+ for _ in range(size):
+ option_type = ql.Option.Call if next(draws) < 0.5 else ql.Option.Put
+ strike = 70.0 + 60.0 * next(draws)
+ maturity = TODAY + int(30 + next(draws) * 1700)
+ book.append((option_type, strike, maturity))
+ return book
+
+
+def make_setup(n_expiries, n_strikes, book_size=BOOK_SIZE):
+ return Setup(
+ curve_dates=[TODAY + ql.Period(m, ql.Months) for m in CURVE_MONTHS],
+ expiries=[TODAY + ql.Period(1 + j * 59 // (n_expiries - 1), ql.Months)
+ for j in range(n_expiries)],
+ strikes=[60.0 + 80.0 * i / (n_strikes - 1) for i in range(n_strikes)],
+ book=make_book(book_size),
+ )
+
+
+# %% [markdown]
+# ### Market data
+#
+# Every number in the market gets a sensitivity. The market is a dictionary
+# from input name to value; the volatilities follow a smile in log-moneyness
+# with a short-dated bump.
+
+# %%
+def make_market(setup):
+ dc = ql.Actual365Fixed()
+ market = {"spot": 100.0, "dividend yield": 0.015}
+ for i, months in enumerate(CURVE_MONTHS):
+ market[f"zero rate {months}M"] = 0.025 + 0.0015 * i
+ for i, strike in enumerate(setup.strikes):
+ for j, expiry in enumerate(setup.expiries):
+ moneyness = math.log(strike / 100.0)
+ t = dc.yearFraction(TODAY, expiry)
+ market[f"vol strike {i + 1} expiry {j + 1}"] = (
+ 0.18 - 0.08 * moneyness + 0.15 * moneyness**2 + 0.03 * math.exp(-t))
+ return market
+
+
+# %% [markdown]
+# ### Pricing the book
+#
+# The curves and the surface are built from the market data, then each option
+# is priced with the analytic Black-Scholes engine. The same function works on
+# plain floats (bumping) and on XAD values registered on a tape (AAD).
+
+# %%
+def price_book(setup, market):
+ dc = ql.Actual365Fixed()
+ n_strikes, n_expiries = len(setup.strikes), len(setup.expiries)
+ vols = ql.Matrix(n_strikes, n_expiries)
+ for i in range(n_strikes):
+ for j in range(n_expiries):
+ vols[i][j] = market[f"vol strike {i + 1} expiry {j + 1}"]
+ process = ql.BlackScholesMertonProcess(
+ ql.QuoteHandle(ql.SimpleQuote(market["spot"])),
+ ql.YieldTermStructureHandle(ql.FlatForward(TODAY, market["dividend yield"], dc)),
+ ql.YieldTermStructureHandle(ql.ZeroCurve(
+ setup.curve_dates, [market[f"zero rate {m}M"] for m in CURVE_MONTHS], dc)),
+ ql.BlackVolTermStructureHandle(ql.BlackVarianceSurface(
+ TODAY, ql.TARGET(), setup.expiries, setup.strikes, vols, dc,
+ ql.BlackVarianceSurface.ConstantExtrapolation,
+ ql.BlackVarianceSurface.ConstantExtrapolation)))
+ engine = ql.AnalyticEuropeanEngine(process)
+
+ value = 0.0
+ for option_type, strike, maturity in setup.book:
+ option = ql.VanillaOption(ql.PlainVanillaPayoff(option_type, strike),
+ ql.EuropeanExercise(maturity))
+ option.setPricingEngine(engine)
+ value += option.NPV()
+ return value
+
+
+# %% [markdown]
+# ### AAD: one recording and one adjoint sweep
+#
+# The market values become XAD inputs on the tape; after recording the pricing,
+# seeding the output adjoint and one sweep back through the tape give the
+# derivatives to all inputs at once.
+
+# %%
+def price_with_aad(tape, setup, market):
+ tape.clearAll()
+ inputs = {name: ql.Real(x) for name, x in market.items()}
+ tape.registerInputs(inputs.values())
+ tape.newRecording()
+
+ value = price_book(setup, inputs)
+
+ tape.registerOutput(value)
+ value.derivative = 1.0
+ tape.computeAdjoints()
+ return float(value), {name: x.derivative for name, x in inputs.items()}
+
+
+def aad_sensitivities(n_expiries, n_strikes, book_size=BOOK_SIZE):
+ ql.Settings.instance().evaluationDate = TODAY
+ setup = make_setup(n_expiries, n_strikes, book_size)
+ with Tape() as tape:
+ return price_with_aad(tape, setup, make_market(setup))
+
+
+# %% [markdown]
+# ### Finite differences
+#
+# Bumping uses forward differences, one extra pricing per input. Central
+# differences, two pricings per input, check the AAD results.
+
+# %%
+def bumped(market, name, h):
+ shifted = dict(market)
+ shifted[name] += h
+ return shifted
+
+
+def plain_price(setup, market):
+ return float(price_book(setup, market))
+
+
+def bumping_sensitivities(setup, market):
+ value = plain_price(setup, market)
+ sensitivities = {}
+ for name, x in market.items():
+ h = 1e-6 * max(1.0, abs(x))
+ sensitivities[name] = (plain_price(setup, bumped(market, name, h)) - value) / h
+ return value, sensitivities
+
+
+def central_differences(setup, market):
+ sensitivities = {}
+ for name, x in market.items():
+ h = 1e-5 * max(1.0, abs(x))
+ up = plain_price(setup, bumped(market, name, h))
+ down = plain_price(setup, bumped(market, name, -h))
+ sensitivities[name] = (up - down) / (2.0 * h)
+ return sensitivities
+
+
+# %% [markdown]
+# ### Checking AAD against central differences
+#
+# The difference is relative to each sensitivity, or absolute below 1, so that
+# small sensitivities are checked too.
+
+# %%
+def check_against_central_differences():
+ ql.Settings.instance().evaluationDate = TODAY
+ for n_expiries, n_strikes in SURFACES:
+ setup = make_setup(n_expiries, n_strikes)
+ market = make_market(setup)
+ with Tape() as tape:
+ _, aad = price_with_aad(tape, setup, market)
+ fd = central_differences(setup, market)
+ if not all(math.isfinite(s) for s in [*aad.values(), *fd.values()]):
+ raise ValueError("a sensitivity is not finite")
+ errors = [abs(aad[name] - fd[name]) for name in market]
+ relative = max(e / max(abs(fd[name]), 1.0) for e, name in zip(errors, market))
+ print(f"AAD vs central differences, {len(market)} inputs: "
+ f"largest difference {max(errors):.1e} "
+ f"(largest sensitivity {max(abs(s) for s in aad.values()):.1e}), "
+ f"largest relative difference {relative:.3e}")
+
+
+# %% [markdown]
+# ### Timing
+#
+# Each method runs repeatedly and the median wall-clock time is reported. The
+# price column is a single pricing of the book without derivatives.
+
+# %%
+def time_ms(f):
+ times = []
+ while len(times) < 3 or (sum(times) < 500.0 and len(times) < 50):
+ start = time.perf_counter()
+ f()
+ times.append(1000.0 * (time.perf_counter() - start))
+ return sorted(times)[len(times) // 2]
+
+
+def timing_table():
+ ql.Settings.instance().evaluationDate = TODAY
+ print(" inputs | price | bumping | AAD | AAD/price | bumping/AAD | tape memory")
+ print("--------+-------------+-------------+-------------+-----------+-------------+------------")
+ with Tape() as tape:
+ for n_expiries, n_strikes in SURFACES:
+ setup = make_setup(n_expiries, n_strikes)
+ market = make_market(setup)
+ price = time_ms(lambda: plain_price(setup, market))
+ bumping = time_ms(lambda: bumping_sensitivities(setup, market))
+ aad = time_ms(lambda: price_with_aad(tape, setup, market))
+ tape_mb = tape.getMemory() / (1024.0 * 1024.0)
+ print(f"{len(market):7d} | {price:8.1f} ms | {bumping:8.1f} ms | {aad:8.1f} ms "
+ f"| {aad / price:8.1f}x | {bumping / aad:10.1f}x | {tape_mb:7.1f} MB")
+
+
+# %% [markdown]
+# ### Results
+#
+# The check, the timings, and the sensitivities on the smallest surface.
+
+# %%
+if __name__ == "__main__":
+ ql.Settings.instance().evaluationDate = TODAY
+ print(f"Sensitivities of a book of {BOOK_SIZE} European options to spot, dividend yield,")
+ print("a 7-point zero curve and a Black volatility surface of growing size\n")
+ check_against_central_differences()
+ print()
+ timing_table()
+
+ n_expiries, n_strikes = SURFACES[0]
+ _, sensitivities = aad_sensitivities(n_expiries, n_strikes)
+ print(f"\nSensitivities on the {len(sensitivities)}-input surface:")
+ for name, s in sensitivities.items():
+ print(f"{name} | {s:.4f}")