diff --git a/CHANGELOG.md b/CHANGELOG.md index 9878c1cd..c94fd5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ the underlying QuantLib-SWIG changelog. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Colab notebooks in `notebooks/`: option Greeks and curve risk in one adjoint sweep + ## [1.44.0] - 2026-11-12 First release as QuantLibAAD. @@ -59,6 +65,7 @@ First release as QuantLibAAD. Initial release, matching QuantLib Python bindings version 1.33 +[Unreleased]: https://github.com/auto-differentiation/QuantLibAAD-Py/compare/v1.44.0...HEAD [1.44.0]: https://github.com/auto-differentiation/QuantLibAAD-Py/compare/v1.33.3...v1.44.0 [1.33.3]: https://github.com/auto-differentiation/QuantLib-Risks-Py/compare/v1.33.2...v1.33.3 diff --git a/README.md b/README.md index 38096a65..f3ca1370 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ is a deprecated alias that installs `QuantLibAAD` and keeps `import QuantLib_Ris with a `DeprecationWarning`. Please switch to `pip install QuantLibAAD` and `import QuantLibAAD`. Anyone pinned to `QuantLib-Risks==1.33.3` must also pin `xad<2`. +## Notebooks + +Run these notebooks on Google Colab, with nothing to install: + +| Notebook | What it shows | +|---|---| +| [Quickstart](notebooks/01_quickstart.ipynb): a European option's Greeks in one adjoint sweep, checked against QuantLib's analytic Greeks and bumping | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/01_quickstart.ipynb) | +| [Curve risk](notebooks/02_curve_risk.ipynb): a swap book's sensitivities to every quote of a bootstrapped curve in one sweep | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/02_curve_risk.ipynb) | + ## Getting Help For documentation and other resources, see https://auto-differentiation.github.io/quantlib-aad/python/ . diff --git a/notebooks/01_quickstart.ipynb b/notebooks/01_quickstart.ipynb new file mode 100644 index 00000000..cfaac767 --- /dev/null +++ b/notebooks/01_quickstart.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# QuantLibAAD quickstart: option Greeks in one adjoint sweep\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/01_quickstart.ipynb)\n", + "\n", + "[QuantLibAAD](https://github.com/auto-differentiation/QuantLibAAD-Py) is QuantLib for Python built with\n", + "[XAD](https://github.com/auto-differentiation/xad), a fast automatic differentiation (AD) library. You write\n", + "ordinary QuantLib code; when its inputs are XAD numbers, XAD records the calculation and can give the derivatives\n", + "of the result with respect to every input.\n", + "\n", + "This notebook prices a European call with QuantLib and gets all its first-order Greeks:\n", + "\n", + "1. **Adjoint mode**: all sensitivities from one recording and one backward sweep.\n", + "2. **Check** against QuantLib's own analytic Greeks.\n", + "3. **Check** against bumping the inputs." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q QuantLibAAD" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## A QuantLib pricer\n", + "\n", + "A one-year call struck at 8 on a stock at 7, priced with the Black-Scholes analytic engine. Spot, dividend\n", + "yield, risk-free rate and volatility each enter through a `SimpleQuote`, the inputs we want sensitivities to.\n", + "The same function works with plain floats and with XAD numbers." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "price = 0.0303344207\n" + ] + } + ], + "source": [ + "import QuantLibAAD as ql\n", + "from xad.adj_1st import Tape\n", + "\n", + "today = ql.Date(15, ql.May, 1998)\n", + "ql.Settings.instance().evaluationDate = today\n", + "day_counter = ql.Actual365Fixed()\n", + "\n", + "inputs = {\"spot\": 7.0, \"dividend\": 0.05, \"rate\": 0.05, \"vol\": 0.10}\n", + "\n", + "\n", + "def make_option(spot, dividend, rate, vol):\n", + " def flat_curve(r):\n", + " return ql.YieldTermStructureHandle(ql.FlatForward(today, ql.QuoteHandle(ql.SimpleQuote(r)), day_counter))\n", + "\n", + " process = ql.BlackScholesMertonProcess(\n", + " ql.QuoteHandle(ql.SimpleQuote(spot)),\n", + " flat_curve(dividend),\n", + " flat_curve(rate),\n", + " ql.BlackVolTermStructureHandle(\n", + " ql.BlackConstantVol(today, ql.TARGET(), ql.QuoteHandle(ql.SimpleQuote(vol)), day_counter)),\n", + " )\n", + " option = ql.VanillaOption(ql.PlainVanillaPayoff(ql.Option.Call, 8.0),\n", + " ql.EuropeanExercise(ql.Date(17, ql.May, 1999)))\n", + " option.setPricingEngine(ql.AnalyticEuropeanEngine(process))\n", + " return option\n", + "\n", + "\n", + "def price_option(spot, dividend, rate, vol):\n", + " return make_option(spot, dividend, rate, vol).NPV()\n", + "\n", + "\n", + "print(f\"price = {price_option(**inputs):.10f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. All Greeks in one adjoint sweep\n", + "\n", + "Register the inputs on a tape, run the unchanged pricer, then seed the price and sweep the tape backwards once.\n", + "Every input's derivative comes out of that one sweep, however many inputs there are." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "price = 0.0303344207\n", + "d price / d spot = +0.0950998668\n", + "d price / d dividend = -0.6693467337\n", + "d price / d rate = +0.6388460970\n", + "d price / d vol = +1.1714772740\n" + ] + } + ], + "source": [ + "def price_and_greeks(inputs):\n", + " args = {name: ql.Real(value) for name, value in inputs.items()}\n", + " with Tape() as tape:\n", + " tape.registerInputs(list(args.values()))\n", + " tape.newRecording()\n", + " npv = price_option(**args)\n", + " tape.registerOutput(npv)\n", + " npv.derivative = 1.0\n", + " tape.computeAdjoints()\n", + " return npv.value, {name: arg.derivative for name, arg in args.items()}\n", + "\n", + "\n", + "price, greeks = price_and_greeks(inputs)\n", + "\n", + "print(f\"price = {price:.10f}\")\n", + "for name, greek in greeks.items():\n", + " print(f\"d price / d {name:8} = {greek:+.10f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Check against QuantLib's analytic Greeks\n", + "\n", + "The analytic engine also returns closed-form Greeks: delta, dividend rho, rho and vega." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "d price / d XAD adjoint analytic abs diff\n", + "spot 0.0950998668 0.0950998668 6.4e-16\n", + "dividend -0.6693467337 -0.6693467337 5.3e-15\n", + "rate 0.6388460970 0.6388460970 5.3e-15\n", + "vol 1.1714772740 1.1714772740 4.7e-15\n" + ] + } + ], + "source": [ + "option = make_option(**inputs)\n", + "analytic = {\"spot\": option.delta(), \"dividend\": option.dividendRho(), \"rate\": option.rho(), \"vol\": option.vega()}\n", + "\n", + "print(f\"{'d price / d':12} {'XAD adjoint':>14} {'analytic':>14} {'abs diff':>10}\")\n", + "for name in inputs:\n", + " print(f\"{name:12} {greeks[name]:14.10f} {analytic[name]:14.10f} {abs(greeks[name] - analytic[name]):10.1e}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Check against bumping\n", + "\n", + "Central differences reprice twice per input and are accurate only to the bump size squared." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "d price / d XAD adjoint bumping abs diff\n", + "spot 0.0950998668 0.0950998671 2.7e-10\n", + "dividend -0.6693467337 -0.6693467338 9.0e-11\n", + "rate 0.6388460970 0.6388460971 8.3e-11\n", + "vol 1.1714772740 1.1714772741 4.5e-11\n" + ] + } + ], + "source": [ + "h = 1e-6\n", + "bumped = {}\n", + "for name in inputs:\n", + " up, down = dict(inputs), dict(inputs)\n", + " up[name] += h\n", + " down[name] -= h\n", + " bumped[name] = (price_option(**up) - price_option(**down)) / (2 * h)\n", + "\n", + "print(f\"{'d price / d':12} {'XAD adjoint':>14} {'bumping':>14} {'abs diff':>10}\")\n", + "for name in inputs:\n", + " print(f\"{name:12} {greeks[name]:14.10f} {bumped[name]:14.10f} {abs(greeks[name] - bumped[name]):10.1e}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The adjoint Greeks match QuantLib's closed forms to machine precision, and bumping to its truncation and\n", + "rounding error. Bumping needs two extra pricings per input; the adjoint sweep gives them all at once, which\n", + "matters when a price depends on whole curves and surfaces.\n", + "\n", + "## Next steps\n", + "\n", + "- [Curve risk](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/02_curve_risk.ipynb): a swap book's sensitivities to every quote of a bootstrapped curve in one sweep.\n", + "- [Python examples](https://github.com/auto-differentiation/QuantLibAAD-Py/tree/main/Python/examples) and the [QuantLibAAD documentation](https://auto-differentiation.github.io/quantlib-aad/python/).\n", + "- [XAD for Python](https://github.com/auto-differentiation/xad-py): automatic differentiation of your own Python code." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/02_curve_risk.ipynb b/notebooks/02_curve_risk.ipynb new file mode 100644 index 00000000..031522d4 --- /dev/null +++ b/notebooks/02_curve_risk.ipynb @@ -0,0 +1,284 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Curve risk: a swap book's sensitivities to every quote\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/02_curve_risk.ipynb)\n", + "\n", + "A yield curve is bootstrapped from market quotes, and a book of swaps is priced off it. Its interest-rate risk is\n", + "the sensitivity of the book's value to each quote. Bumping needs two bootstraps and book revaluations per quote (central differences).\n", + "With QuantLibAAD, the bootstrap and the pricing are recorded once and **one adjoint sweep gives the sensitivity\n", + "to every quote**.\n", + "\n", + "The market data and the book are those of the C++\n", + "[AdjointTradeRisk example](https://github.com/auto-differentiation/QuantLibAAD/tree/main/Examples/AdjointTradeRisk):\n", + "45 quotes and 200 swaps." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q QuantLibAAD" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Market quotes\n", + "\n", + "Ten deposits (overnight to 6 months), five FRAs (1x7 to 5x11) and thirty swaps (1 to 30 years) against\n", + "6-month Euribor." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import QuantLibAAD as ql\n", + "from xad.adj_1st import Tape\n", + "\n", + "today = ql.Date(2, ql.January, 2015)\n", + "ql.Settings.instance().evaluationDate = today\n", + "calendar = ql.TARGET()\n", + "start = calendar.advance(today, 2, ql.Days)\n", + "\n", + "deposit_tenors = [ql.Period(1, ql.Days)] * 3 + [ql.Period(1, ql.Weeks)] + [ql.Period(m, ql.Months) for m in range(1, 7)]\n", + "names = ([\"deposit ON\", \"deposit TN\", \"deposit SN\", \"deposit 1W\"] + [f\"deposit {m}M\" for m in range(1, 7)]\n", + " + [f\"FRA {m}x{m + 6}\" for m in range(1, 6)] + [f\"swap {y}Y\" for y in range(1, 31)])\n", + "quotes = ([0.0010 + i * 0.0002 for i in range(10)] + [0.0030 + i * 0.0005 for i in range(5)]\n", + " + [0.0060 + i * 0.0001 for i in range(30)])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The bootstrap\n", + "\n", + "A curve linear in zero rates through one node per quote. AAD differentiates the bootstrap's solver iterations,\n", + "so its sensitivities are only as accurate as the solver: we tighten the solver accuracy from its default." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def bootstrap(quotes):\n", + " quote = [ql.QuoteHandle(ql.SimpleQuote(q)) for q in quotes]\n", + " helpers = [\n", + " ql.DepositRateHelper(quote[i], tenor, min(i, 2), calendar, ql.ModifiedFollowing, False, ql.Actual360())\n", + " for i, tenor in enumerate(deposit_tenors)\n", + " ]\n", + " helpers += [\n", + " ql.FraRateHelper(quote[10 + i], i + 1, i + 7, 2, calendar, ql.ModifiedFollowing, False, ql.Actual360())\n", + " for i in range(5)\n", + " ]\n", + " helpers += [\n", + " ql.SwapRateHelper(quote[15 + i], ql.Period(i + 1, ql.Years), calendar, ql.Annual, ql.ModifiedFollowing,\n", + " ql.Thirty360(ql.Thirty360.European), ql.Euribor6M())\n", + " for i in range(30)\n", + " ]\n", + " curve = ql.PiecewiseLinearZero(today, helpers, ql.Actual365Fixed(), ql.IterativeBootstrap(accuracy=1e-15))\n", + " curve.enableExtrapolation()\n", + " return ql.YieldTermStructureHandle(curve)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The swap book\n", + "\n", + "200 payer and receiver swaps with random nominals, fixed rates and maturities up to 30 years, from a seeded\n", + "random number generator. The book's value is the sum of their NPVs." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "book value = -531,879.60\n" + ] + } + ], + "source": [ + "rng = ql.MersenneTwisterUniformRsg(1, 42)\n", + "\n", + "\n", + "def uniform():\n", + " return rng.nextSequence().value()[0].value\n", + "\n", + "\n", + "book = []\n", + "for _ in range(200):\n", + " swap_type = ql.Swap.Payer if uniform() < 0.5 else ql.Swap.Receiver\n", + " nominal = 1e6 * (1 + int(10 * uniform()))\n", + " fixed_rate = 0.002 + 0.008 * uniform()\n", + " years = 1 + int(29.999 * uniform())\n", + " book.append((swap_type, nominal, fixed_rate, years))\n", + "\n", + "\n", + "def price_swap(trade, curve):\n", + " swap_type, nominal, fixed_rate, years = trade\n", + " end = calendar.advance(start, years, ql.Years)\n", + "\n", + " def schedule(tenor):\n", + " return ql.Schedule(start, end, tenor, calendar, ql.ModifiedFollowing, ql.ModifiedFollowing,\n", + " ql.DateGeneration.Backward, False)\n", + "\n", + " swap = ql.VanillaSwap(swap_type, nominal, schedule(ql.Period(1, ql.Years)), fixed_rate,\n", + " ql.Thirty360(ql.Thirty360.European), schedule(ql.Period(6, ql.Months)),\n", + " ql.Euribor6M(curve), 0.0, ql.Actual360())\n", + " swap.setPricingEngine(ql.DiscountingSwapEngine(curve))\n", + " return swap.NPV()\n", + "\n", + "\n", + "def price_book(quotes):\n", + " curve = bootstrap(quotes)\n", + " return sum(price_swap(trade, curve) for trade in book)\n", + "\n", + "\n", + "print(f\"book value = {price_book(quotes):,.2f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Book risk in one adjoint sweep\n", + "\n", + "Register the 45 quotes as inputs, record the bootstrap and the pricing of all 200 swaps, and sweep back once from\n", + "the book value." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "book value = -531,879.60\n", + "\n", + "largest sensitivities, per 1bp move of the quote:\n", + " swap 14Y -45,152.25\n", + " swap 21Y -38,636.67\n", + " swap 28Y -29,911.59\n", + " swap 22Y -28,274.69\n", + " swap 25Y 27,279.16\n", + " swap 30Y 26,541.94\n", + " swap 17Y 25,610.10\n", + " swap 7Y 19,043.85\n", + " swap 29Y -15,346.74\n", + " swap 23Y -12,606.83\n" + ] + } + ], + "source": [ + "def book_value_and_risk(quotes):\n", + " inputs = [ql.Real(q) for q in quotes]\n", + " with Tape() as tape:\n", + " tape.registerInputs(inputs)\n", + " tape.newRecording()\n", + " value = price_book(inputs)\n", + " tape.registerOutput(value)\n", + " value.derivative = 1.0\n", + " tape.computeAdjoints()\n", + " return value.value, [q.derivative for q in inputs]\n", + "\n", + "\n", + "value, risk = book_value_and_risk(quotes)\n", + "\n", + "print(f\"book value = {value:,.2f}\\n\")\n", + "print(\"largest sensitivities, per 1bp move of the quote:\")\n", + "for i in sorted(range(len(risk)), key=lambda i: -abs(risk[i]))[:10]:\n", + " print(f\" {names[i]:12} {risk[i] * 1e-4:14,.2f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Check against bumping\n", + "\n", + "Central differences for the five largest sensitivities: two bootstraps and book revaluations per quote." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "quote XAD adjoint bumping rel diff\n", + "swap 14Y -451,522,471.99 -451,522,472.01 4.9e-11\n", + "swap 21Y -386,366,675.21 -386,366,675.25 9.7e-11\n", + "swap 28Y -299,115,874.32 -299,115,874.10 7.4e-10\n", + "swap 22Y -282,746,872.95 -282,746,872.90 1.8e-10\n", + "swap 25Y 272,791,566.37 272,791,565.78 2.1e-09\n" + ] + } + ], + "source": [ + "h = 1e-5\n", + "print(f\"{'quote':12} {'XAD adjoint':>16} {'bumping':>16} {'rel diff':>10}\")\n", + "for i in sorted(range(len(risk)), key=lambda i: -abs(risk[i]))[:5]:\n", + " up, down = list(quotes), list(quotes)\n", + " up[i] += h\n", + " down[i] -= h\n", + " bumped = (price_book(up) - price_book(down)) / (2 * h)\n", + " print(f\"{names[i]:12} {risk[i]:16,.2f} {bumped:16,.2f} {abs(risk[i] - bumped) / abs(bumped):10.1e}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The adjoint sensitivities agree with bumping to its truncation and rounding error. Bumping all 45 quotes would\n", + "take 90 bootstraps and book revaluations; the adjoint sweep gives every sensitivity from one recording.\n", + "\n", + "One sweep gives the derivatives of one output. For the risk of each swap separately, register every NPV as an\n", + "output and sweep once per swap on the same recording; the C++ example shows a faster way that records the\n", + "curve once and each swap separately.\n", + "\n", + "## Next steps\n", + "\n", + "- [Quickstart](https://colab.research.google.com/github/auto-differentiation/QuantLibAAD-Py/blob/main/notebooks/01_quickstart.ipynb): option Greeks in one adjoint sweep.\n", + "- [Python examples](https://github.com/auto-differentiation/QuantLibAAD-Py/tree/main/Python/examples) and the [QuantLibAAD documentation](https://auto-differentiation.github.io/quantlib-aad/python/)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}