diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9cecaa..67c1a635 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 +- Example `trade-risk.py`: sensitivities of every swap in a book to every curve quote with AAD ## [1.44.0] - 2026-11-12 diff --git a/Python/examples/trade-risk.py b/Python/examples/trade-risk.py new file mode 100644 index 00000000..b9f24160 --- /dev/null +++ b/Python/examples/trade-risk.py @@ -0,0 +1,344 @@ +# --- +# 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] +# # Trade-level risk of a swap book +# +# 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] +# We compute the sensitivities of every swap in a book of 200 to every one of +# the 45 quotes of the curve they are priced on, with adjoint algorithmic +# differentiation (AAD). +# +# The curve is recorded once: its node zero rates and their Jacobian to the 45 +# quotes come from one recording of the bootstrap and one adjoint sweep per +# node. Each swap is then priced off a zero curve through those nodes, which +# reproduces the bootstrapped curve, with a small recording of its own; the +# chain rule maps its node sensitivities to the quotes. +# +# The results are checked against a recording of the whole book together with +# the bootstrap, and against central differences, and the run time is compared +# with bumping each quote. + +# %% +import math +import time +from collections import namedtuple + +import QuantLibAAD as ql +from xad.adj_1st import Tape + +# %% [markdown] +# ### Market data +# +# Deposits (ON, TN, SN, 1W, 1M to 6M), FRAs (1x7 to 5x11) and swaps (1Y to 30Y). + +# %% +DEPOSITS, FRAS, SWAPS = 10, 5, 30 +today = ql.Date(2, ql.January, 2015) +calendar = ql.TARGET() + + +def market_quotes(): + return ([0.0010 + i * 0.0002 for i in range(DEPOSITS)] + + [0.0030 + i * 0.0005 for i in range(FRAS)] + + [0.0060 + i * 0.0001 for i in range(SWAPS)]) + + +def quote_names(): + names = ["deposit ON", "deposit TN", "deposit SN", "deposit 1W"] + names += [f"deposit {i}M" for i in range(1, DEPOSITS - 3)] + names += [f"FRA {i}x{i + 6}" for i in range(1, FRAS + 1)] + names += [f"swap {i}Y" for i in range(1, SWAPS + 1)] + return names + + +# %% [markdown] +# ### The curve +# +# A `PiecewiseLinearZero` curve bootstrapped on the quotes. AAD differentiates +# the bootstrap's solver iterations, so its sensitivities are only as accurate +# as the solver: we tighten the accuracy well below the default. + +# %% +def bootstrap(quotes): + helpers = [] + for i in range(DEPOSITS): + tenor = ql.Period(1, ql.Days) if i < 3 else ( + ql.Period(1, ql.Weeks) if i == 3 else ql.Period(i - 3, ql.Months)) + fixing_days = i if i < 2 else 2 + helpers.append(ql.DepositRateHelper( + ql.QuoteHandle(quotes[i]), tenor, fixing_days, calendar, + ql.ModifiedFollowing, False, ql.Actual360())) + for i in range(FRAS): + helpers.append(ql.FraRateHelper( + ql.QuoteHandle(quotes[DEPOSITS + i]), i + 1, i + 7, 2, calendar, + ql.ModifiedFollowing, False, ql.Actual360())) + euribor6m = ql.Euribor6M() + for i in range(SWAPS): + helpers.append(ql.SwapRateHelper( + ql.QuoteHandle(quotes[DEPOSITS + FRAS + i]), ql.Period(i + 1, ql.Years), + calendar, ql.Annual, ql.ModifiedFollowing, + ql.Thirty360(ql.Thirty360.European), euribor6m)) + curve = ql.PiecewiseLinearZero(today, helpers, ql.Actual365Fixed(), + ql.IterativeBootstrap(1e-15)) + curve.enableExtrapolation() + return curve + + +# %% [markdown] +# ### The book +# +# 200 spot-starting swaps with random side, nominal, fixed rate and maturity. +# The draws come from a Mersenne Twister with seed 42, four per trade. + +# %% +Trade = namedtuple("Trade", "type nominal fixed_rate years") + + +def make_book(size): + rng = ql.UniformRandomSequenceGenerator(4 * size, ql.UniformRandomGenerator(42)) + draws = iter([float(x) for x in rng.nextSequence().value()]) + book = [] + for _ in range(size): + swap_type = ql.Swap.Payer if next(draws) < 0.5 else ql.Swap.Receiver + nominal = 1e6 * (1 + int(10 * next(draws))) + fixed_rate = 0.002 + 0.008 * next(draws) + years = 1 + int(29.999 * next(draws)) + book.append(Trade(swap_type, nominal, fixed_rate, years)) + return book + + +def make_swap(trade, start, curve): + index = ql.Euribor6M(curve) + end = calendar.advance(start, trade.years, ql.Years) + fixed = ql.Schedule(start, end, ql.Period(1, ql.Years), calendar, ql.ModifiedFollowing, + ql.ModifiedFollowing, ql.DateGeneration.Backward, False) + floating = ql.Schedule(start, end, ql.Period(6, ql.Months), calendar, ql.ModifiedFollowing, + ql.ModifiedFollowing, ql.DateGeneration.Backward, False) + swap = ql.VanillaSwap(trade.type, trade.nominal, fixed, trade.fixed_rate, + ql.Thirty360(ql.Thirty360.European), floating, index, 0.0, + ql.Actual360()) + swap.setPricingEngine(ql.DiscountingSwapEngine(curve)) + return swap + + +# %% [markdown] +# ### Sensitivities with AAD +# +# First the curve: the quotes are registered as inputs, the bootstrap is +# recorded, and one adjoint sweep per node zero rate gives one row of the +# Jacobian of the nodes to the quotes. + +# %% +def curve_risk(tape, quotes): + tape.clearAll() + q = [ql.Real(x) for x in quotes] + tape.registerInputs(q) + tape.newRecording() + + curve = bootstrap([ql.SimpleQuote(x) for x in q]) + zeros = list(curve.data()) + for z in zeros: + tape.registerOutput(z) + + jacobian = [] + for z in zeros: + tape.clearDerivatives() + z.derivative = 1.0 + tape.computeAdjoints() + jacobian.append([x.derivative for x in q]) + return list(curve.dates()), [float(z) for z in zeros], jacobian + + +# %% [markdown] +# Then each swap, priced off a `ZeroCurve` through the same nodes, with the node +# zero rates as inputs of a small recording. The node sensitivities, times the +# Jacobian, are the sensitivities to the quotes. + +# %% +def trade_risk(tape, start, book, dates, zeros, jacobian): + npvs, risk = [], [] + for trade in book: + tape.clearAll() + z = [ql.Real(x) for x in zeros] + tape.registerInputs(z) + tape.newRecording() + + curve = ql.ZeroCurve(dates, z, ql.Actual365Fixed()) + curve.enableExtrapolation() + npv = make_swap(trade, start, ql.YieldTermStructureHandle(curve)).NPV() + + tape.registerOutput(npv) + npv.derivative = 1.0 + tape.computeAdjoints() + sensitivities = [0.0] * len(jacobian[0]) + for zj, row in zip(z, jacobian): + dz = zj.derivative + if dz != 0.0: + sensitivities = [s + dz * r for s, r in zip(sensitivities, row)] + npvs.append(float(npv)) + risk.append(sensitivities) + return npvs, risk + + +# %% [markdown] +# Put together, with the quote names attached: + +# %% +def aad_sensitivities(book_size=200): + ql.Settings.instance().evaluationDate = today + start = calendar.advance(today, 2, ql.Days) + book = make_book(book_size) + with Tape() as tape: + npvs, risk = trade_risk(tape, start, book, *curve_risk(tape, market_quotes())) + names = quote_names() + return npvs, [dict(zip(names, r)) for r in risk] + + +# %% +ql.Settings.instance().evaluationDate = today +start = calendar.advance(today, 2, ql.Days) +quotes, names, book = market_quotes(), quote_names(), make_book(200) +print(f"Sensitivities of each of the {len(book)} swaps in a book " + f"to each of the {len(quotes)} curve quotes\n") + +t0 = time.perf_counter() +npvs, risk = aad_sensitivities(len(book)) +aad_seconds = time.perf_counter() - t0 +risk = [[r[name] for name in names] for r in risk] +print(f"AAD: {aad_seconds * 1000:.1f} ms, " + "from one recording of the curve and one small recording per swap") + + +# %% [markdown] +# ### Checks +# +# Every swap against a full recording of the bootstrap and the whole book, with +# one sweep per swap: this checks the chain rule through the curve nodes. + +# %% +def through_bootstrap(quotes, book): + with Tape() as tape: + q = [ql.Real(x) for x in quotes] + tape.registerInputs(q) + tape.newRecording() + curve = ql.YieldTermStructureHandle(bootstrap([ql.SimpleQuote(x) for x in q])) + npvs = [make_swap(trade, start, curve).NPV() for trade in book] + for npv in npvs: + tape.registerOutput(npv) + risk = [] + for npv in npvs: + tape.clearDerivatives() + npv.derivative = 1.0 + tape.computeAdjoints() + risk.append([x.derivative for x in q]) + return risk + + +direct = through_bootstrap(quotes, book) +# max() would skip a NaN, so check first that every sensitivity is finite +if not all(math.isfinite(r) for rs in (risk, direct) for trade in rs for r in trade): + raise ValueError("a sensitivity is not finite") +largest = max(abs(r) for trade in risk for r in trade) +difference = max(abs(a - b) for ta, tb in zip(risk, direct) for a, b in zip(ta, tb)) +print(f" all swaps against a full recording of curve and swaps " + f"(largest sensitivity {largest:.1e}): largest difference {difference:.1e}") + + +# %% [markdown] +# The first swap and the longest one against central differences on plain +# pricing, which also checks the recording of the bootstrap itself. The same +# function with forward differences is the bumping alternative to AAD: one +# revaluation of the curve and of the book per quote. + +# %% +def finite_differences(quotes, book, h, central): + simple = [ql.SimpleQuote(x) for x in quotes] + curve = ql.YieldTermStructureHandle(bootstrap(simple)) + swaps = [make_swap(trade, start, curve) for trade in book] + base = [float(s.NPV()) for s in swaps] + risk = [[0.0] * len(quotes) for _ in book] + for i, x in enumerate(quotes): + simple[i].setValue(x + h) + up = [float(s.NPV()) for s in swaps] + if central: + simple[i].setValue(x - h) + base = [float(s.NPV()) for s in swaps] + simple[i].setValue(x) + for k in range(len(book)): + risk[k][i] = (up[k] - base[k]) / ((2 if central else 1) * h) + return risk + + +longest = max(range(len(book)), key=lambda k: book[k].years) +checked = [0, longest] +differences = finite_differences(quotes, [book[k] for k in checked], 1e-5, central=True) +# relative to each sensitivity, or absolute below the floor, where rounding in +# the central differences dominates +floor = 100.0 +for k, d in zip(checked, differences): + relative = max(abs(a - b) / max(abs(b), floor) for a, b in zip(risk[k], d)) + print(f" swap {k + 1} ({book[k].years}y) by central differences: " + f"largest relative difference {relative:.1e}") + +# bumping the whole book with forward differences, timed only +t0 = time.perf_counter() +finite_differences(quotes, book, 1e-6, central=False) +bumping_seconds = time.perf_counter() - t0 +print(f"\nBumping: {bumping_seconds * 1000:.1f} ms, " + f"{len(quotes) + 1} curve and book revaluations, AAD speedup " + f"{bumping_seconds / aad_seconds:.1f}x") + +# %% [markdown] +# ### Results +# +# The largest sensitivities of the first swap, to a unit change of each quote. + +# %% +first = risk[0] +order = sorted(range(len(first)), key=lambda i: -abs(first[i])) +side = "payer" if book[0].type == ql.Swap.Payer else "receiver" +print(f"\nSwap 1 ({side}, {book[0].years}y), largest sensitivities:") +for i in order[:5]: + print(f"{names[i]:>14} | {first[i]:14.0f}")