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!