Assessing duration risk in QuantLib
Welcome back.
Today’s post was originally published as an article in the July 2023 issue of Wilmott Magazine. As usual, the full source file is available on my Tutorial page, together with code from other articles and, when available, either the articles themselves or corresponding blog posts like this one.
Subscribe to my Substack to receive my posts in your inbox, or follow me on Twitter or LinkedIn if you want to be notified of new posts, or subscribe via RSS if you’re the tech type: the buttons for all that are in the footer. Also, I’m available for training, both online and (when possible) on-site: visit my Training page for more information.
Assessing duration risk with QuantLib
As you read this article, the fall of Silicon Valley Bank or Credit Suisse might already be in the rear-view mirror, replaced in the news by the next thing. As I write it, though, it’s still pretty fresh; which prompted me to write about the several ways we can estimate interest-rate risk using QuantLib.
Let’s get the includes out of the way, then we’ll start.
#include <ql/instruments/bonds/fixedratebond.hpp>
#include <ql/math/interpolations/linearinterpolation.hpp>
#include <ql/pricingengines/bond/discountingbondengine.hpp>
#include <ql/quotes/simplequote.hpp>
#include <ql/settings.hpp>
#include <ql/termstructures/yield/bondhelpers.hpp>
#include <ql/termstructures/yield/piecewiseyieldcurve.hpp>
#include <ql/termstructures/yield/piecewisezerospreadedtermstructure.hpp>
#include <ql/termstructures/yield/zerospreadedtermstructure.hpp>
#include <ql/time/calendars/unitedstates.hpp>
#include <ql/time/daycounters/actual360.hpp>
#include <ql/time/daycounters/thirty360.hpp>
#include <iostream>
#include <vector>
Setting up
Let’s imagine for a bit that we’re back to that magic time where
interest rates were low and deposits were plentiful; for instance,
March 17th, 2022. After setting QuantLib’s global evaluation date
accordingly, I’ll define a small struct to hold the par rates
conveniently made available on the Treasury web site. For brevity, I
might have used a std::pair
to hold a tenor and a corresponding par
rate; but a struct (even though I’ll use it only shortly) will give me
names more meaningful than first
and second
to refer to its
elements. The quotes
array contains the quoted rates for our
evaluation date.
int main() {
using namespace QuantLib;
auto valueDate = Date(17, March, 2022);
Settings::instance().evaluationDate() = valueDate;
struct BondQuote {
Period tenor;
Rate yield;
};
BondQuote quotes[] = {
{Period(1, Months), 0.20},
{Period(2, Months), 0.30},
{Period(3, Months), 0.40},
{Period(6, Months), 0.81},
{Period(1, Years), 1.30},
{Period(2, Years), 1.94},
{Period(3, Years), 2.14},
{Period(5, Years), 2.17},
{Period(7, Years), 2.22},
{Period(10, Years), 2.20},
{Period(20, Years), 2.60},
{Period(30, Years), 2.50}
};
Today’s discount curve
Let’s now use those quotes to bootstrap a discount curve. I’ll keep
it simple, but I’ll be very interested in any improvements you might
suggest to the procedure, so please, do reach out to me if you have
comments. Here, I’m going to create an instance of FixedRateBondHelper
for each of the quotes; it’s an object that holds the terms and
conditions of a fixed-rate bond, its quoted price, and has methods for
recalculating said price off a discount curve and reporting the
discrepancy with the quote.
auto calendar =
UnitedStates(UnitedStates::GovernmentBond);
auto settlementDays = 1;
auto faceAmount = 100.0;
auto dayCounter = Thirty360(Thirty360::BondBasis);
std::vector<ext::shared_ptr<RateHelper>> helpers;
for (auto q : quotes) {
auto startDate = valueDate;
auto maturityDate =
calendar.advance(startDate, q.tenor);
Schedule schedule = MakeSchedule()
.from(startDate)
.to(maturityDate)
.withFrequency(Semiannual)
.withCalendar(calendar)
.withConvention(Unadjusted)
.backwards();
std::vector<Rate> coupons = {q.yield / 100};
helpers.push_back(
ext::make_shared<FixedRateBondHelper>(
Handle<Quote>(
ext::make_shared<SimpleQuote>(100.0)),
settlementDays, faceAmount, schedule, coupons,
dayCounter));
}
I have a confession to make, though: this helper wasn’t exactly written for this case. You might have noticed that I mentioned the quoted price of a bond in the previous paragraph; however, what’s actually quoted in this case is the par yield. For lack of a more specialized helper class, we’ll make do by passing a price of 100 as the quote and by setting the yield as the coupon rate. However, that’s getting the logic rather backwards; and, when the yields change, it forces one to create new helpers and a new curve, instead of just setting a new value to the quote.
Of course, it would be possible to create a par-rate helper that models this particular case, takes the yield as a quote, and matches it with the one recalculated off the curve being bootstrapped. Its disadvantage? It would be slower: obtaining a bond price given a curve is a direct calculation, but obtaining its yield involves a root-solving process. We might implement that in the future and let users decide about the trade-off; for the time being, such a helper is not available.
Anyway: for each of the quotes, creating the helper involves first building a coupon schedule based on the quoted tenor, and then passing it to the helper constructor together with the quote for the price and other bond parameters.
Once we have the helpers, we can use them to create our discount
curve; namely, an instance of PiecewiseYieldCurve
which will run the
bootstrapping process. In the interest of keeping my word count
within reasonable limits, I won’t describe it here; you can have a
look at my Implementing
QuantLib book for all the
(many) details.
auto treasury_curve = ext::make_shared<
PiecewiseYieldCurve<ZeroYield, Linear>>(
valueDate, helpers, Actual360());
treasury_curve->enableExtrapolation();
A sample bond portfolio
Now, whose risk should we assess? In the next part of the code, I’ll
create a mock portfolio; that is, a vector of bonds with different
coupon rates and maturities. Again, instead of using a std::tuple
,
I’m defining a small struct to hold the data. Also, for brevity, I’ll
assume that all bonds have the same frequency and conventions, as well
as a common face amount of 100.
struct BondData {
Date startDate;
Date maturityDate;
Rate coupon;
};
BondData bondData[] = {
{ {15, September, 2016}, {15, September, 2022}, 1.45 },
{ {1, November, 2017}, {1, November, 2022}, 5.5 },
{ {1, August, 2021}, {1, August, 2023}, 4.75 },
{ {1, December, 2008}, {1, December, 2024}, 2.5 },
{ {1, June, 2020}, {1, June, 2027}, 2.2 },
{ {1, November, 2009}, {1, November, 2029}, 5.25 },
{ {1, April, 2016}, {1, April, 2030}, 1.35 },
{ {15, September, 2012}, {15, September, 2032}, 1.33 },
{ {1, March, 2012}, {1, March, 2035}, 3.35 },
{ {1, February, 2017}, {1, February, 2037}, 4.0 },
{ {1, August, 2007}, {1, August, 2039}, 3.0 },
{ {15, September, 2006}, {15, September, 2041}, 2.97 },
{ {1, September, 2018}, {1, September, 2044}, 3.75 },
{ {1, March, 2013}, {1, March, 2048}, 3.45 },
{ {1, September, 2012}, {1, September, 2049}, 3.85 },
{ {1, September, 2007}, {1, September, 2051}, 1.7 },
{ {1, March, 2016}, {1, March, 2067}, 2.8 },
{ {1, March, 2020}, {1, March, 2072}, 2.15 }
};
Since we’re going to price all these bonds off the same curve, the
code first creates a relinkable handle to hold the curve (remember my
article in the March issue?)
and then uses the handle to create a pricing engine. During the
calculation, the DiscountingBondEngine
class does what you would
expect: it adds the discounted values of the bond cash flows.
RelinkableHandle<YieldTermStructure> discount_handle;
auto engine = ext::make_shared<DiscountingBondEngine>(
discount_handle);
The next step is to create the actual bonds. The code is pretty
similar to the loop that created the curve helpers earlier, except
that it’s creating instances of the Bond
class now. The same engine
can be set to every bond; there’s no need for separate engines as long
as we don’t think about multithreading, and that’s a bad idea in
QuantLib anyway.
std::vector<ext::shared_ptr<Bond>> bonds;
for (auto d : bondData) {
Schedule schedule = MakeSchedule()
.from(d.startDate)
.to(d.maturityDate)
.withFrequency(Semiannual)
.withCalendar(calendar)
.withConvention(Unadjusted)
.backwards();
std::vector<Rate> coupons = {d.coupon / 100.0};
auto bond = ext::make_shared<FixedRateBond>(
settlementDays, faceAmount, schedule, coupons,
dayCounter);
bond->setPricingEngine(engine);
bonds.push_back(bond);
}
Reference prices
Once the bonds are created and the engine is set, we can finally calculate their prices. By linking our treasury curve to the discount handle, we make it available to the pricing engine. All that remains is to loop over the bonds and ask each of them for its price. For convenience, I’ll show you the results together with later ones in table 1, instead of reporting the output of the program verbatim. The current results are in the “price” column.
In the code, I’m also storing the prices in a vector for future
reference; bondPrices[i]
will hold the price of the i-th bond.
discount_handle.linkTo(treasury_curve);
std::vector<Real> bondPrices(bonds.size());
std::cout << "=== Prices ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Real price = bondPrices[i] = bonds[i]->cleanPrice();
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << price << std::endl;
}
Table 1: Some results from the sample code
maturity | price | sensitivity | alternative |
---|---|---|---|
2022-09-15 | 100.322 | -0.004913 | -0.005044 |
2022-11-01 | 102.817 | -0.006332 | -0.006504 |
2023-08-01 | 104.340 | -0.013912 | -0.014248 |
2024-12-01 | 101.084 | -0.026348 | -0.027057 |
2027-06-01 | 100.118 | -0.048966 | -0.050229 |
2029-11-01 | 121.221 | -0.078066 | -0.080071 |
2030-04-01 | 93.657 | -0.070611 | -0.072441 |
2032-09-15 | 91.674 | -0.088716 | -0.091026 |
2035-03-01 | 111.512 | -0.118571 | -0.121531 |
2037-02-01 | 120.086 | -0.138868 | -0.142264 |
2039-08-01 | 107.116 | -0.146021 | -0.149422 |
2041-09-15 | 106.032 | -0.157869 | -0.161292 |
2044-09-01 | 120.218 | -0.190589 | -0.194415 |
2048-03-01 | 117.341 | -0.209386 | -0.213705 |
2049-09-01 | 126.275 | -0.229310 | -0.234055 |
2051-09-01 | 83.216 | -0.185209 | -0.189610 |
2067-03-01 | 110.991 | -0.294029 | -0.302859 |
2072-03-01 | 93.533 | -0.278172 | -0.287288 |
Price sensitivity
And now, we can start talking about assessing risk. How can we calculate, for instance, the change in price corresponding to a change of one basis point in yield? We ask each bond for its yield, and then for its price given the perturbed yield. (We could also average the changes for a positive and negative yield movement, but I’ll keep it simple here.) The difference between the perturbed prices and the reference prices gives us the “sensitivity” column in table 1.
std::cout << "\n=== Sensitivity ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Rate yield = bonds[i]->yield(dayCounter, Compounded,
Semiannual);
Real price1 =
bonds[i]->cleanPrice(yield + 0.0001, dayCounter,
Compounded, Semiannual);
Real dP = price1 - bondPrices[i];
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << bondPrices[i]
<< std::setw(14) << dP << std::endl;
}
An alternative method
Another method for calculating the same sensitivity is to modify the
discount curve and then recalculate bonds prices; this can be useful,
say, for instruments such as swaps that are not priced in terms of a
yield. One way is to use the ZeroSpreadedTermStructure
class, which
takes a reference term structure and adds a parallel spread to its
zero rates (the default, as in this case, is to work on continuous
rates; other conventions can be specified).
Here, we’re passing the reference curve and a shift of one basis point, resulting in a new shifted curve that we can link to the discount handle; after doing that, asking once again each bond for its price will return updated results based on the new curve. The changes with respect to the original prices give us the alternative sensitivity of our bonds, also shown in table 1.
auto shifted_curve =
ext::make_shared<ZeroSpreadedTermStructure>(
Handle<YieldTermStructure>(treasury_curve),
Handle<Quote>(
ext::make_shared<SimpleQuote>(0.0001)));
discount_handle.linkTo(shifted_curve);
std::cout << "\n=== Sensitivity (alt.) ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Real price2 = bonds[i]->cleanPrice();
Real dP = price2 - bondPrices[i];
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << bondPrices[i]
<< std::setw(14) << dP << std::endl;
}
A flattening scenario
The spread that we add to the reference curve doesn’t need to be
constant; the PiecewiseZeroSpreadedTermStructure
class allows us to
specify a set of spreads corresponding to different dates, and then
interpolates between them.
In this case, we’ll create a fairly simple set of spreads: a short-term positive spread of 0.2% at the value date and a long-term negative spread of 0.2% at the 60-years point. Added to our reference curve, the interpolated time-dependent spread creates a flattened curve, with higher short-term rates and lower long-term rates.
Once we have the new curve, the process is the same: we link it to the discount handle, extract the new prices, and take the differences; they are shown in table 2.
auto short_term_spread =
ext::make_shared<SimpleQuote>(0.002);
auto long_term_spread =
ext::make_shared<SimpleQuote>(-0.002);
std::vector<Date> dates = {valueDate,
valueDate + 60 * Years};
std::vector<Handle<Quote>> spreads = {
Handle<Quote>(short_term_spread),
Handle<Quote>(long_term_spread)};
auto tilted_curve =
ext::make_shared<PiecewiseZeroSpreadedTermStructure>(
Handle<YieldTermStructure>(treasury_curve),
spreads, dates);
tilted_curve->enableExtrapolation();
discount_handle.linkTo(tilted_curve);
std::cout << "\n=== flattening ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Real price3 = bonds[i]->cleanPrice();
Real diff = price3 - bondPrices[i];
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << bondPrices[i]
<< std::setw(14) << diff << std::endl;
}
Table 2: More results
maturity | flattening | steepening | clairvoyant |
---|---|---|---|
2022-09-15 | -0.09916 | 0.09926 | -1.8860 |
2022-11-01 | -0.12729 | 0.12745 | -2.2849 |
2023-08-01 | -0.27166 | 0.27238 | -3.4863 |
2024-12-01 | -0.49154 | 0.49398 | -4.2027 |
2027-06-01 | -0.83008 | 0.83722 | -5.9621 |
2029-11-01 | -1.21210 | 1.22536 | -9.2242 |
2030-04-01 | -1.06178 | 1.07429 | -8.1004 |
2032-09-15 | -1.19120 | 1.20742 | -9.8355 |
2035-03-01 | -1.44271 | 1.46313 | -13.1100 |
2037-02-01 | -1.54977 | 1.57187 | -15.2419 |
2039-08-01 | -1.39485 | 1.41461 | -15.7459 |
2041-09-15 | -1.31996 | 1.33776 | -16.8288 |
2044-09-01 | -1.33947 | 1.35575 | -19.8000 |
2048-03-01 | -1.07202 | 1.08343 | -20.9533 |
2049-09-01 | -1.04109 | 1.05213 | -22.6831 |
2051-09-01 | -0.41162 | 0.41608 | -17.5477 |
2067-03-01 | 1.37506 | -1.28584 | -25.2730 |
2072-03-01 | 2.22019 | -2.05171 | -22.9998 |
A steepening scenario
Changing the values of the short-term and long-term spreads to -0.2% and 0.2%, respectively, gives us a steeper curve where the short-term rates decrease and the long-term rates increase. The changes are calculated as usual and shown in table 2.
short_term_spread->setValue(-0.002);
long_term_spread->setValue(0.002);
std::cout << "\n=== steepening ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Real price4 = bonds[i]->cleanPrice();
Real diff = price4 - bondPrices[i];
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << bondPrices[i]
<< std::setw(14) << diff << std::endl;
}
A clairvoyant stress scenario
Finally, instead of modifying the current curve, we can forget about it and replace it with an entirely different one. For instance, back in March 2022 (when we’re still pretending to be, and when our current evaluation date is set), you might have said: “What if the Fed increased the rates by 5% in one year and reversed the curve?” to which some colleague might have answered: “Not gonna happen, but sure, let’s check it out—I’m curious.”
In this case, you would have needed to guess a new set of par rates; for instance, the ones shown in the code and corresponding to the quotes for March 17th, 2023. As I mentioned, in this setup we can’t simply set new values to the existing helpers, so we have to create new helpers and a new curve, in the same way in which we created the reference curve. In real-world code, to avoid repetition, we would of course abstract those lines out in a function and call it with our different sets of quotes.
In any case, once we have our stressed curve, we can link it to the same old discount handle and ask the bonds for their prices again. “Ouch,” you would say to your colleague as the code prints out the results in the last column of table 2.
BondQuote new_quotes[] = {
{Period(1, Months), 4.31},
{Period(2, Months), 4.51},
{Period(3, Months), 4.52},
{Period(6, Months), 4.71},
{Period(1, Years), 4.26},
{Period(2, Years), 3.81},
{Period(3, Years), 3.68},
{Period(5, Years), 3.44},
{Period(7, Years), 3.45},
{Period(10, Years), 3.39},
{Period(20, Years), 3.76},
{Period(30, Years), 3.60}
};
std::vector<ext::shared_ptr<RateHelper>> new_helpers;
for (auto q : new_quotes) {
auto startDate = valueDate;
auto maturityDate =
calendar.adjust(startDate + q.tenor);
Schedule schedule = MakeSchedule()
.from(startDate)
.to(maturityDate)
.withFrequency(Semiannual)
.withCalendar(calendar)
.withConvention(Unadjusted)
.backwards();
std::vector<Rate> coupons = {q.yield / 100};
new_helpers.push_back(
ext::make_shared<FixedRateBondHelper>(
Handle<Quote>(
ext::make_shared<SimpleQuote>(100.0)),
settlementDays, faceAmount, schedule, coupons,
dayCounter));
}
auto stressed_curve = ext::make_shared<
PiecewiseYieldCurve<ZeroYield, Linear>>(
valueDate, new_helpers, Actual360());
stressed_curve->enableExtrapolation();
discount_handle.linkTo(stressed_curve);
std::cout << "\n=== clairvoyant ===" << std::endl;
for (Size i = 0; i < bonds.size(); ++i) {
Real price5 = bonds[i]->cleanPrice();
Real diff = price5 - bondPrices[i];
std::cout << io::iso_date(bondData[i].maturityDate)
<< std::setw(10) << bondPrices[i]
<< std::setw(14) << diff << std::endl;
}
}
Other possibilities
The classes shown here will enable you to create other kind of
scenarios. For instance, PiecewiseZeroSpreadedTermStructure
could
let you generate some kind of historical stress scenario by selecting
some interesting or stressful past period and a set of key rates (say,
the 1-, 2-, 5-, 10-, 20- and 50-years zero rates, or as many or few as
you like). By taking the variations of those key rates over the
period and applying them as spreads, you’d obtain a stressed curve to
use for discounting. Or again, by adding a predetermined spread only
in a specific region of the curve, you could calculate the
corresponding key-rate durations.
To summarize, and to bring this article to a close, QuantLib gives you the building blocks to create your own bespoke solution to the problem of monitoring duration risk. Have fun putting the pieces together.
See you next time!