From 613e782a7af65ed48ac636b532eb913947682402 Mon Sep 17 00:00:00 2001 From: Tyler Riccardi <247895347+tylrcc@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:30:39 +0000 Subject: [PATCH] Add European option portfolio adjoint example Port of the AdjointEuropeanEquityOption C++ example. Prices a portfolio of European put options (10 underlyings, strikes 10-79) over a zero curve and a black variance curve, and computes the sensitivities of the total value to all 98 market inputs (zero rates, volatilities, strikes, underlyings, dividend yield) in a single adjoint pass. The largest sensitivities are cross-checked against bump-and-reprice finite differences inside the example itself. --- Python/examples/european-option-adjoint.py | 235 +++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 Python/examples/european-option-adjoint.py diff --git a/Python/examples/european-option-adjoint.py b/Python/examples/european-option-adjoint.py new file mode 100644 index 00000000..c0f1d0ca --- /dev/null +++ b/Python/examples/european-option-adjoint.py @@ -0,0 +1,235 @@ +# --- +# 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] +# # European options with sensitivities +# +# Copyright (©) 2004, 2005, 2006, 2007 StatPro Italia srl +# Copyright (©) 2024 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] +# This is a port of the `AdjointEuropeanEquityOption` C++ example. It prices +# a portfolio of European put options (several underlyings, each with a range +# of strikes) over a zero curve and a black variance curve, and computes the +# sensitivities of the total portfolio value to all market inputs (the zero +# rates, the volatilities, the strikes, the underlyings, and the dividend +# yield) in a single adjoint pass. The results are cross-checked against +# bump-and-reprice finite differences. + +# %% +import QuantLibAAD as ql +from xad.adj_1st import Tape +from xad import value +import pandas as pd + +# %% [markdown] +# ### Global parameters + +# %% +calendar = ql.TARGET() +todaysDate = ql.Date(15, ql.May, 1998) +settlementDate = ql.Date(17, ql.May, 1998) +ql.Settings.instance().evaluationDate = todaysDate +dayCounter = ql.Actual365Fixed() +maturity = ql.Date(17, ql.May, 1999) + +# setup and activate the tape +tape = Tape() +tape.activate() + +# %% +interactive = "get_ipython" in globals() + +# %% [markdown] +# ### Market data +# +# The zero curve and the variance curve share the same node dates: the +# settlement date plus the offsets below. The first rate is repeated at the +# first offset date, as in the C++ example. + +# %% +offsets = [13, 41, 75, 165, 256, 345, 524, 703] +curveRates = [0.0357, 0.0349, 0.0341, 0.0355, 0.0359, 0.0368, 0.0386, 0.0401] +curveVols = [0.20, 0.18, 0.178, 0.183, 0.192, 0.203, 0.215, 0.208] + +dates = [settlementDate] + [settlementDate + n for n in offsets] + +rates_in = [ql.Real(curveRates[0])] + [ql.Real(r) for r in curveRates] +vols_in = [ql.Real(v) for v in curveVols] +strikes_in = [ql.Real(float(s)) for s in range(10, 80)] +underlyings_in = [ql.Real(float(s)) for s in range(15, 61, 5)] +dividendYield_in = ql.Real(0.01) + +tape.registerInputs(rates_in) +tape.registerInputs(vols_in) +tape.registerInputs(strikes_in) +tape.registerInputs(underlyings_in) +tape.registerInput(dividendYield_in) + + +# %% [markdown] +# ### Pricing +# +# The whole portfolio is priced inside the recording, so that the curves and +# instruments are built from the registered inputs. + + +# %% +def pricePortfolio(rates, vols, strikes, underlyings, dividendYield): + termStructure = ql.ZeroCurve(dates, rates, dayCounter) + dividendTS = ql.FlatForward(settlementDate, dividendYield, dayCounter) + # the variance curve drops the first (settlement) date + volTS = ql.BlackVarianceCurve(settlementDate, dates[1:], vols, dayCounter) + + exercise = ql.EuropeanExercise(maturity) + value = ql.Real(0.0) + for underlying in underlyings: + process = ql.BlackScholesMertonProcess( + ql.QuoteHandle(ql.SimpleQuote(underlying)), + ql.YieldTermStructureHandle(dividendTS), + ql.YieldTermStructureHandle(termStructure), + ql.BlackVolTermStructureHandle(volTS), + ) + engine = ql.AnalyticEuropeanEngine(process) + for strike in strikes: + option = ql.VanillaOption( + ql.PlainVanillaPayoff(ql.Option.Put, strike), exercise + ) + option.setPricingEngine(engine) + value = value + option.NPV() + return value + + +# %% +tape.newRecording() +portfolioValue = pricePortfolio( + rates_in, vols_in, strikes_in, underlyings_in, dividendYield_in +) + +tape.registerOutput(portfolioValue) +portfolioValue.derivative = 1.0 +tape.computeAdjoints() + +print(f"Portfolio value: {portfolioValue:.5f}") + +# %% [markdown] +# ### Sensitivities + +# %% +results = [] +for d, rate in zip(dates, rates_in): + results.append(("rho", f"rate {d}", rate.derivative)) +for d, vol in zip(dates[1:], vols_in): + results.append(("vega", f"vol {d}", vol.derivative)) +for strike in strikes_in: + results.append(("strike sensitivity", f"strike {value(strike):.0f}", strike.derivative)) +for underlying in underlyings_in: + results.append(("delta", f"underlying {value(underlying):.0f}", underlying.derivative)) +results.append(("dividend rho", "dividend yield", dividendYield_in.derivative)) + +# %% +df = pd.DataFrame(results, columns=["Sensitivity", "Input", "Value"]) +df.style.hide(axis="index") + +# %% +if not interactive: + print(df.to_string(index=False)) + +# %% [markdown] +# ### Cross-check by bumping +# +# Each input is bumped in turn and the portfolio repriced without the tape; +# the finite differences should match the adjoints above. Only the largest +# sensitivities are shown. + +# %% +eps = 1e-5 + + +def bumpedValue(which, index): + rates = [value(r) for r in rates_in] + vols = [value(v) for v in vols_in] + strikes = [value(s) for s in strikes_in] + underlyings = [value(u) for u in underlyings_in] + dividendYield = value(dividendYield_in) + if which == "rate": + rates[index] += eps + elif which == "vol": + vols[index] += eps + elif which == "strike": + strikes[index] += eps + elif which == "underlying": + underlyings[index] += eps + else: + dividendYield += eps + return pricePortfolio( + [ql.Real(r) for r in rates], + [ql.Real(v) for v in vols], + [ql.Real(s) for s in strikes], + [ql.Real(u) for u in underlyings], + ql.Real(dividendYield), + ) + + +# %% +checks = [] +named = ( + [("rate", i, f"rate {dates[i]}", r.derivative) for i, r in enumerate(rates_in)] + + [("vol", i, f"vol {dates[i + 1]}", v.derivative) for i, v in enumerate(vols_in)] + + [("strike", i, f"strike {value(s):.0f}", s.derivative) for i, s in enumerate(strikes_in)] + + [("underlying", i, f"underlying {value(u):.0f}", u.derivative) for i, u in enumerate(underlyings_in)] + + [("dividend", 0, "dividend yield", dividendYield_in.derivative)] +) +for which, index, name, adjoint in sorted(named, key=lambda t: -abs(t[3]))[:8]: + fd = (bumpedValue(which, index) - value(portfolioValue)) / eps + checks.append((name, adjoint, fd)) + +# %% +checkDf = pd.DataFrame(checks, columns=["Input", "Adjoint", "Bump and reprice"]) +checkDf.style.hide(axis="index") + +# %% +if not interactive: + print(checkDf.to_string(index=False)) + +# %% +tape.deactivate()