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.

import QuantLib as ql
import pandas as pd
import numpy as np

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.

today = ql.Date(17, 3, 2022)
ql.Settings.instance().evaluationDate = today
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.

calendar = ql.UnitedStates(ql.UnitedStates.GovernmentBond)
settlement_days = 1
face_amount = 100.0
day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
helpers = []
for t, y in zip(tenors, yields):
    schedule = ql.Schedule(
        today,
        today + ql.Period(t),
        ql.Period(ql.Semiannual),
        calendar,
        ql.Unadjusted,
        ql.Unadjusted,
        ql.DateGeneration.Backward,
        False,
    )
    helpers.append(
        ql.FixedRateBondHelper(
            ql.QuoteHandle(ql.SimpleQuote(100)),
            settlement_days,
            face_amount,
            schedule,
            [y / 100.0],
            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.

curve = ql.PiecewiseLinearZero(today, helpers, ql.Actual360())
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 = [
    (ql.Date(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),
]

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.

discount_handle = ql.RelinkableYieldTermStructureHandle()
engine = ql.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.

bonds = []
for start, end, coupon in data:
    schedule = ql.Schedule(
        start,
        end,
        ql.Period(ql.Semiannual),
        calendar,
        ql.Unadjusted,
        ql.Unadjusted,
        ql.DateGeneration.Backward,
        False,
    )
    bond = ql.FixedRateBond(
        settlement_days,
        face_amount,
        schedule,
        [coupon / 100.0],
        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:
    m = b.maturityDate()
    p = b.cleanPrice()
    results.append((m, p))
results = pd.DataFrame(results, columns=["maturity", "price"])
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:
    y = b.bondYield(day_counter, ql.Compounded, ql.Semiannual)
    prices.append(
        b.cleanPrice(y + 0.0001, day_counter, ql.Compounded, ql.Semiannual)
    )
results["sensitivity"] = np.array(prices) - results["price"]
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),
        ql.QuoteHandle(ql.SimpleQuote(0.0001)),
    )
)
results["sensitivity (alt.)"] = (
    np.array([b.cleanPrice() for b in bonds]) - results["price"]
)
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.

short_term_spread = ql.SimpleQuote(0.002)
long_term_spread = ql.SimpleQuote(-0.002)

dates = [today, today + ql.Period(60, ql.Years)]
spreads = [
    ql.QuoteHandle(short_term_spread),
    ql.QuoteHandle(long_term_spread),
]

tilted_curve = ql.PiecewiseZeroSpreadedTermStructure(
    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 = results[["maturity", "price"]].copy()

discount_handle.linkTo(tilted_curve)

results["flattening"] = (
    np.array([b.cleanPrice() for b in bonds]) - results["price"]
)
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.

short_term_spread.setValue(-0.002)
long_term_spread.setValue(0.002)

results["steepening"] = (
    np.array([b.cleanPrice() for b in bonds]) - results["price"]
)
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):
    schedule = ql.Schedule(
        today,
        today + ql.Period(t),
        ql.Period(ql.Semiannual),
        ql.UnitedStates(ql.UnitedStates.GovernmentBond),
        ql.Unadjusted,
        ql.Unadjusted,
        ql.DateGeneration.Backward,
        False,
    )
    helpers.append(
        ql.FixedRateBondHelper(
            ql.QuoteHandle(ql.SimpleQuote(100)),
            1,
            100.0,
            schedule,
            [y / 100.0],
            ql.Thirty360(ql.Thirty360.BondBasis),
        )
    )
new_curve = ql.PiecewiseLinearZero(today, helpers, ql.Actual360())
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 = results[["maturity", "price"]].copy()

discount_handle.linkTo(new_curve)

results["precog"] = (
    np.array([b.cleanPrice() for b in bonds]) - results["price"]
)
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.