import QuantLib as ql
import pandas as pd
import numpy as np
Assessing duration risk
(Originally published as an article in Wilmott Magazine, July 2023.)
As I write this, the fall of Silicon Valley Bank or Credit Suisse is still pretty fresh; which prompted me to write about the several ways we can estimate interest-rate risk using QuantLib.
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 list holding the tenors and par rates conveniently made available on the Treasury web site for our evaluation date.
= ql.Date(17, 3, 2022)
today = today ql.Settings.instance().evaluationDate
= [
tenors "1M",
"2M",
"3M",
"6M",
"1Y",
"2Y",
"3Y",
"5Y",
"7Y",
"10Y",
"20Y",
"30Y",
]
= [
yields 0.20,
0.30,
0.40,
0.81,
1.30,
1.94,
2.14,
2.17,
2.22,
2.20,
2.60,
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.
= ql.UnitedStates(ql.UnitedStates.GovernmentBond)
calendar = 1
settlement_days = 100.0
face_amount = ql.Thirty360(ql.Thirty360.BondBasis) day_counter
= []
helpers for t, y in zip(tenors, yields):
= ql.Schedule(
schedule
today,+ ql.Period(t),
today
ql.Period(ql.Semiannual),
calendar,
ql.Unadjusted,
ql.Unadjusted,
ql.DateGeneration.Backward,False,
)
helpers.append(
ql.FixedRateBondHelper(100)),
ql.QuoteHandle(ql.SimpleQuote(
settlement_days,
face_amount,
schedule,/ 100.0],
[y
day_counter,
) )
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.
= ql.PiecewiseLinearZero(today, helpers, ql.Actual360()) curve
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 list of bonds with different coupon rates and maturities. For brevity, I’ll assume that all bonds have the same frequency and conventions, as well as a common face amount of 100.
= [
data 15, 9, 2016), ql.Date(15, 9, 2022), 1.45),
(ql.Date(1, 11, 2017), ql.Date(1, 11, 2022), 5.5),
(ql.Date(1, 8, 2021), ql.Date(1, 8, 2023), 4.75),
(ql.Date(1, 12, 2008), ql.Date(1, 12, 2024), 2.5),
(ql.Date(1, 6, 2020), ql.Date(1, 6, 2027), 2.2),
(ql.Date(1, 11, 2009), ql.Date(1, 11, 2029), 5.25),
(ql.Date(1, 4, 2016), ql.Date(1, 4, 2030), 1.35),
(ql.Date(15, 9, 2012), ql.Date(15, 9, 2032), 1.33),
(ql.Date(1, 3, 2012), ql.Date(1, 3, 2035), 3.35),
(ql.Date(1, 2, 2017), ql.Date(1, 2, 2037), 4.0),
(ql.Date(1, 8, 2007), ql.Date(1, 8, 2039), 3.0),
(ql.Date(15, 9, 2006), ql.Date(15, 9, 2041), 2.97),
(ql.Date(1, 9, 2018), ql.Date(1, 9, 2044), 3.75),
(ql.Date(1, 3, 2013), ql.Date(1, 3, 2048), 3.45),
(ql.Date(1, 9, 2012), ql.Date(1, 9, 2049), 3.85),
(ql.Date(1, 9, 2007), ql.Date(1, 9, 2051), 1.7),
(ql.Date(1, 3, 2016), ql.Date(1, 3, 2067), 2.8),
(ql.Date(1, 3, 2020), ql.Date(1, 3, 2072), 2.15),
(ql.Date( ]
Since we’re going to price all these bonds off the same curve, the code first creates a relinkable handle to hold the curve 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.
= ql.RelinkableYieldTermStructureHandle()
discount_handle = ql.DiscountingBondEngine(discount_handle) engine
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.
= []
bonds for start, end, coupon in data:
= ql.Schedule(
schedule
start,
end,
ql.Period(ql.Semiannual),
calendar,
ql.Unadjusted,
ql.Unadjusted,
ql.DateGeneration.Backward,False,
)= ql.FixedRateBond(
bond
settlement_days,
face_amount,
schedule,/ 100.0],
[coupon
day_counter,
)
bond.setPricingEngine(engine) bonds.append(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. The results are stored in a Pandas data frame, in the “price” column.
discount_handle.linkTo(curve)
= []
results for b in bonds:
= b.maturityDate()
m = b.cleanPrice()
p
results.append((m, p))= pd.DataFrame(results, columns=["maturity", "price"])
results results
maturity | price | |
---|---|---|
0 | September 15th, 2022 | 100.325987 |
1 | November 1st, 2022 | 102.821079 |
2 | August 1st, 2023 | 104.341431 |
3 | December 1st, 2024 | 101.086154 |
4 | June 1st, 2027 | 100.118562 |
5 | November 1st, 2029 | 121.230541 |
6 | April 1st, 2030 | 93.664362 |
7 | September 15th, 2032 | 91.673963 |
8 | March 1st, 2035 | 111.512720 |
9 | February 1st, 2037 | 120.086115 |
10 | August 1st, 2039 | 107.116189 |
11 | September 15th, 2041 | 106.031770 |
12 | September 1st, 2044 | 120.219158 |
13 | March 1st, 2048 | 117.343050 |
14 | September 1st, 2049 | 126.277776 |
15 | September 1st, 2051 | 83.218728 |
16 | March 1st, 2067 | 111.000187 |
17 | March 1st, 2072 | 93.543566 |
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.
= []
prices for b in bonds:
= b.bondYield(day_counter, ql.Compounded, ql.Semiannual)
y
prices.append(+ 0.0001, day_counter, ql.Compounded, ql.Semiannual)
b.cleanPrice(y
)"sensitivity"] = np.array(prices) - results["price"]
results[ results
maturity | price | sensitivity | |
---|---|---|---|
0 | September 15th, 2022 | 100.325987 | -0.004914 |
1 | November 1st, 2022 | 102.821079 | -0.006332 |
2 | August 1st, 2023 | 104.341431 | -0.013913 |
3 | December 1st, 2024 | 101.086154 | -0.026352 |
4 | June 1st, 2027 | 100.118562 | -0.048967 |
5 | November 1st, 2029 | 121.230541 | -0.078068 |
6 | April 1st, 2030 | 93.664362 | -0.070618 |
7 | September 15th, 2032 | 91.673963 | -0.088716 |
8 | March 1st, 2035 | 111.512720 | -0.118587 |
9 | February 1st, 2037 | 120.086115 | -0.138884 |
10 | August 1st, 2039 | 107.116189 | -0.146022 |
11 | September 15th, 2041 | 106.031770 | -0.157851 |
12 | September 1st, 2044 | 120.219158 | -0.190570 |
13 | March 1st, 2048 | 117.343050 | -0.209395 |
14 | September 1st, 2049 | 126.277776 | -0.229316 |
15 | September 1st, 2051 | 83.218728 | -0.185209 |
16 | March 1st, 2067 | 111.000187 | -0.294056 |
17 | March 1st, 2072 | 93.543566 | -0.278230 |
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.
discount_handle.linkTo(
ql.ZeroSpreadedTermStructure(
ql.YieldTermStructureHandle(curve),0.0001)),
ql.QuoteHandle(ql.SimpleQuote(
) )
"sensitivity (alt.)"] = (
results[for b in bonds]) - results["price"]
np.array([b.cleanPrice()
) results
maturity | price | sensitivity | sensitivity (alt.) | |
---|---|---|---|---|
0 | September 15th, 2022 | 100.325987 | -0.004914 | -0.005045 |
1 | November 1st, 2022 | 102.821079 | -0.006332 | -0.006505 |
2 | August 1st, 2023 | 104.341431 | -0.013913 | -0.014248 |
3 | December 1st, 2024 | 101.086154 | -0.026352 | -0.027058 |
4 | June 1st, 2027 | 100.118562 | -0.048967 | -0.050229 |
5 | November 1st, 2029 | 121.230541 | -0.078068 | -0.080078 |
6 | April 1st, 2030 | 93.664362 | -0.070618 | -0.072447 |
7 | September 15th, 2032 | 91.673963 | -0.088716 | -0.091026 |
8 | March 1st, 2035 | 111.512720 | -0.118587 | -0.121531 |
9 | February 1st, 2037 | 120.086115 | -0.138884 | -0.142264 |
10 | August 1st, 2039 | 107.116189 | -0.146022 | -0.149421 |
11 | September 15th, 2041 | 106.031770 | -0.157851 | -0.161291 |
12 | September 1st, 2044 | 120.219158 | -0.190570 | -0.194415 |
13 | March 1st, 2048 | 117.343050 | -0.209395 | -0.213709 |
14 | September 1st, 2049 | 126.277776 | -0.229316 | -0.234061 |
15 | September 1st, 2051 | 83.218728 | -0.185209 | -0.189618 |
16 | March 1st, 2067 | 111.000187 | -0.294056 | -0.302897 |
17 | March 1st, 2072 | 93.543566 | -0.278230 | -0.287335 |
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.
= ql.SimpleQuote(0.002)
short_term_spread = ql.SimpleQuote(-0.002)
long_term_spread
= [today, today + ql.Period(60, ql.Years)]
dates = [
spreads
ql.QuoteHandle(short_term_spread),
ql.QuoteHandle(long_term_spread),
]
= ql.PiecewiseZeroSpreadedTermStructure(
tilted_curve
ql.YieldTermStructureHandle(curve), spreads, dates
) tilted_curve.enableExtrapolation()
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.
= results[["maturity", "price"]].copy()
results
discount_handle.linkTo(tilted_curve)
"flattening"] = (
results[for b in bonds]) - results["price"]
np.array([b.cleanPrice()
) results
maturity | price | flattening | |
---|---|---|---|
0 | September 15th, 2022 | 100.325987 | -0.099161 |
1 | November 1st, 2022 | 102.821079 | -0.127298 |
2 | August 1st, 2023 | 104.341431 | -0.271664 |
3 | December 1st, 2024 | 101.086154 | -0.491554 |
4 | June 1st, 2027 | 100.118562 | -0.830087 |
5 | November 1st, 2029 | 121.230541 | -1.212212 |
6 | April 1st, 2030 | 93.664362 | -1.061867 |
7 | September 15th, 2032 | 91.673963 | -1.191191 |
8 | March 1st, 2035 | 111.512720 | -1.442707 |
9 | February 1st, 2037 | 120.086115 | -1.549767 |
10 | August 1st, 2039 | 107.116189 | -1.394847 |
11 | September 15th, 2041 | 106.031770 | -1.319961 |
12 | September 1st, 2044 | 120.219158 | -1.339476 |
13 | March 1st, 2048 | 117.343050 | -1.072036 |
14 | September 1st, 2049 | 126.277776 | -1.041110 |
15 | September 1st, 2051 | 83.218728 | -0.411631 |
16 | March 1st, 2067 | 111.000187 | 1.375404 |
17 | March 1st, 2072 | 93.543566 | 2.220772 |
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.
-0.002)
short_term_spread.setValue(0.002)
long_term_spread.setValue(
"steepening"] = (
results[for b in bonds]) - results["price"]
np.array([b.cleanPrice()
) results
maturity | price | flattening | steepening | |
---|---|---|---|---|
0 | September 15th, 2022 | 100.325987 | -0.099161 | 0.099260 |
1 | November 1st, 2022 | 102.821079 | -0.127298 | 0.127456 |
2 | August 1st, 2023 | 104.341431 | -0.271664 | 0.272379 |
3 | December 1st, 2024 | 101.086154 | -0.491554 | 0.493991 |
4 | June 1st, 2027 | 100.118562 | -0.830087 | 0.837225 |
5 | November 1st, 2029 | 121.230541 | -1.212212 | 1.225468 |
6 | April 1st, 2030 | 93.664362 | -1.061867 | 1.074386 |
7 | September 15th, 2032 | 91.673963 | -1.191191 | 1.207413 |
8 | March 1st, 2035 | 111.512720 | -1.442707 | 1.463129 |
9 | February 1st, 2037 | 120.086115 | -1.549767 | 1.571870 |
10 | August 1st, 2039 | 107.116189 | -1.394847 | 1.414609 |
11 | September 15th, 2041 | 106.031770 | -1.319961 | 1.337761 |
12 | September 1st, 2044 | 120.219158 | -1.339476 | 1.355756 |
13 | March 1st, 2048 | 117.343050 | -1.072036 | 1.083442 |
14 | September 1st, 2049 | 126.277776 | -1.041110 | 1.052151 |
15 | September 1st, 2051 | 83.218728 | -0.411631 | 0.416085 |
16 | March 1st, 2067 | 111.000187 | 1.375404 | -1.286171 |
17 | March 1st, 2072 | 93.543566 | 2.220772 | -2.052258 |
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.
= [
new_yields 4.31,
4.51,
4.52,
4.71,
4.26,
3.81,
3.68,
3.44,
3.45,
3.39,
3.76,
3.60,
]
= []
helpers for t, y in zip(tenors, new_yields):
= ql.Schedule(
schedule
today,+ ql.Period(t),
today
ql.Period(ql.Semiannual),
ql.UnitedStates(ql.UnitedStates.GovernmentBond),
ql.Unadjusted,
ql.Unadjusted,
ql.DateGeneration.Backward,False,
)
helpers.append(
ql.FixedRateBondHelper(100)),
ql.QuoteHandle(ql.SimpleQuote(1,
100.0,
schedule,/ 100.0],
[y
ql.Thirty360(ql.Thirty360.BondBasis),
) )
= ql.PiecewiseLinearZero(today, helpers, ql.Actual360())
new_curve new_curve.enableExtrapolation()
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.
= results[["maturity", "price"]].copy()
results
discount_handle.linkTo(new_curve)
"precog"] = (
results[for b in bonds]) - results["price"]
np.array([b.cleanPrice()
) results
maturity | price | precog | |
---|---|---|---|
0 | September 15th, 2022 | 100.325987 | -1.866123 |
1 | November 1st, 2022 | 102.821079 | -2.264614 |
2 | August 1st, 2023 | 104.341431 | -3.485236 |
3 | December 1st, 2024 | 101.086154 | -4.201212 |
4 | June 1st, 2027 | 100.118562 | -5.962030 |
5 | November 1st, 2029 | 121.230541 | -9.219825 |
6 | April 1st, 2030 | 93.664362 | -8.097490 |
7 | September 15th, 2032 | 91.673963 | -9.836337 |
8 | March 1st, 2035 | 111.512720 | -13.110537 |
9 | February 1st, 2037 | 120.086115 | -15.242371 |
10 | August 1st, 2039 | 107.116189 | -15.746457 |
11 | September 15th, 2041 | 106.031770 | -16.829242 |
12 | September 1st, 2044 | 120.219158 | -19.800276 |
13 | March 1st, 2048 | 117.343050 | -20.953544 |
14 | September 1st, 2049 | 126.277776 | -22.683295 |
15 | September 1st, 2051 | 83.218728 | -17.548003 |
16 | March 1st, 2067 | 111.000187 | -25.273680 |
17 | March 1st, 2072 | 93.543566 | -23.000832 |
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.
In short: you can use the building blocks provided by QuantLib to create your own bespoke solution to the problem of monitoring duration risk. Have fun putting the pieces together.