7. Duration of floating-rate bonds

(Based on a question by Antonio Savoldi on the QuantLib mailing list. Thanks!)

In [1]: import QuantLib as ql
        from pandas import DataFrame

In [2]: today = ql.Date(8,ql.October,2014)
        ql.Settings.instance().evaluationDate = today
The problem

We want to calculate the modified duration of a floating-rate bond. First, we need an interest-rate curve to forecast its coupon rates: for illustration’s sake, let’s take a flat curve with a 0.2% rate.

In [3]: forecast_curve = ql.RelinkableYieldTermStructureHandle()
        forecast_curve.linkTo(ql.FlatForward(today, 0.002, ql.Actual360(),
                                             ql.Compounded, ql.Semiannual))

Then, we instantiate the index to be used. The bond has semiannual coupons, so we create a Euribor6M instance and we pass it the forecast curve. Also, we set a past fixing for the current coupon (which, having fixed in the past, can’t be forecast).

In [4]: index = ql.Euribor6M(forecast_curve)
        index.addFixing(ql.Date(6,ql.August,2014), 0.002)

The bond was issued a couple of months before the evaluation date and will run for 5 years with semiannual coupons.

In [5]: issueDate = ql.Date(8,ql.August,2014)
        maturityDate = ql.Date(8,ql.August,2019)
        
        schedule = ql.Schedule(issueDate, maturityDate,
                               ql.Period(ql.Semiannual), ql.TARGET(),
                               ql.Following, ql.Following,
                               ql.DateGeneration.Backward, False)
        
        bond = ql.FloatingRateBond(settlementDays = 3,
                                   faceAmount = 100,
                                   schedule = schedule,
                                   index = index,
                                   paymentDayCounter = ql.Actual360())

The cash flows are calculated based on the forecast curve. Here they are, together with their dates. As expected, they each pay around 0.1% of the notional.

In [6]: dates = [ c.date() for c in bond.cashflows() ]
        cfs = [ c.amount() for c in bond.cashflows() ]
        DataFrame(list(zip(dates, cfs)),
                  columns = ('date','amount'),
                  index = range(1,len(dates)+1))

Out[6]: 
  date amount
1 February 9th, 2015 0.102778
2 August 10th, 2015 0.101112
3 February 8th, 2016 0.101112
4 August 8th, 2016 0.101112
5 February 8th, 2017 0.102223
6 August 8th, 2017 0.100556
7 February 8th, 2018 0.102223
8 August 8th, 2018 0.100556
9 February 8th, 2019 0.102223
10 August 8th, 2019 0.100556
11 August 8th, 2019 100.000000

If we try to use the function provided for calculating bond durations, though, we run into a problem. When we pass it the bond and a 0.2% semiannual yield, the result we get is:

In [7]: y = ql.InterestRate(0.002, ql.Actual360(), ql.Compounded, ql.Semiannual)
        print(ql.BondFunctions.duration(bond, y, ql.Duration.Modified))

Out[7]: 4.8609591731332165

which is about the time to maturity. Shouldn’t we get the time to next coupon instead?

What happened?

The function above is too generic. It calculates the modified duration as \(\displaystyle{-\frac{1}{P}\frac{dP}{dy}}\); however, it doesn’t know what kind of bond it has been passed and what kind of cash flows are paid, so it can only consider the yield for discounting and not for forecasting. If you looked into the C++ code, you’d see that the bond price \(P\) above is calculated as the sum of the discounted cash flows, as in the following:

In [8]: y = ql.SimpleQuote(0.002)
        yield_curve = ql.FlatForward(bond.settlementDate(), ql.QuoteHandle(y),
                                     ql.Actual360(), ql.Compounded, ql.Semiannual)
        
        dates = [ c.date() for c in bond.cashflows() ]
        cfs = [ c.amount() for c in bond.cashflows() ]
        discounts = [ yield_curve.discount(d) for d in dates ]
        P = sum(cf*b for cf,b in zip(cfs,discounts))
        
        print(P)

Out[8]: 100.03665363580889

(Incidentally, we can see that this matches the calculation in the dirtyPrice method of the Bond class.)

In [9]: bond.setPricingEngine(
            ql.DiscountingBondEngine(ql.YieldTermStructureHandle(yield_curve)))
        print(bond.dirtyPrice())

Out[9]: 100.03665363580889

Finally, the derivative \(\displaystyle{\frac{dP}{dy}}\) in the duration formula in approximated as \(\displaystyle{\frac{P(y+dy)-P(y-dy)}{2 dy}}\), so that we get:

In [10]: dy = 1e-5
         
         y.setValue(0.002 + dy)
         cfs_p = [ c.amount() for c in bond.cashflows() ]
         discounts_p = [ yield_curve.discount(d) for d in dates ]
         P_p = sum(cf*b for cf,b in zip(cfs_p,discounts_p))
         print(P_p)
         
         y.setValue(0.002 - dy)
         cfs_m = [ c.amount() for c in bond.cashflows() ]
         discounts_m = [ yield_curve.discount(d) for d in dates ]
         P_m = sum(cf*b for cf,b in zip(cfs_m,discounts_m))
         print(P_m)
         
         y.setValue(0.002)

Out[10]: 100.03179102561501
         100.0415165074028

In [11]: print(-(1/P)*(P_p - P_m)/(2*dy))

Out[11]: 4.8609591756253225

which is the same figure returned by BondFunctions.duration.

The problem is that the above doesn’t use the yield curve for forecasting, so it’s not really considering the bond as a floating-rate bond. It’s using it as a fixed-rate bond, whose coupon rates happen to equal the current forecasts for the Euribor 6M fixings. This is clear if we look at the coupon amounts and discounts we stored during the calculation:

In [12]: DataFrame(list(zip(dates, cfs, discounts,
                            cfs_p, discounts_p, cfs_m, discounts_m)),
                   columns = ('date','amount','discounts',
                              'amount (+)','discounts (+)',
                              'amount (-)','discounts (-)',),
                   index = range(1,len(dates)+1))

Out[12]: 
  date amount discounts amount (+) discounts (+) amount (-) discounts (-)
1 February 9th, 2015 0.102778 0.999339 0.102778 0.999336 0.102778 0.999343
2 August 10th, 2015 0.101112 0.998330 0.101112 0.998322 0.101112 0.998338
3 February 8th, 2016 0.101112 0.997322 0.101112 0.997308 0.101112 0.997335
4 August 8th, 2016 0.101112 0.996314 0.101112 0.996296 0.101112 0.996333
5 February 8th, 2017 0.102223 0.995297 0.102223 0.995273 0.102223 0.995320
6 August 8th, 2017 0.100556 0.994297 0.100556 0.994269 0.100556 0.994325
7 February 8th, 2018 0.102223 0.993282 0.102223 0.993248 0.102223 0.993315
8 August 8th, 2018 0.100556 0.992284 0.100556 0.992245 0.100556 0.992322
9 February 8th, 2019 0.102223 0.991270 0.102223 0.991227 0.102223 0.991314
10 August 8th, 2019 0.100556 0.990275 0.100556 0.990226 0.100556 0.990323
11 August 8th, 2019 100.000000 0.990275 100.000000 0.990226 100.000000 0.990323

where you can see how the discount factors changed when the yield was modified, but the coupon amounts stayed the same.

The solution

Unfortunately, there’s no easy way to fix the BondFunctions.duration method so that it does the right thing. What we can do, instead, is to repeat the calculation above while setting up the bond and the curves so that the yield is used correctly. In particular, we have to link the forecast curve to the flat yield curve being modified…

In [13]: forecast_curve.linkTo(yield_curve)

…so that changing the yield will also affect the forecast rate of the coupons.

In [14]: y.setValue(0.002 + dy)
         P_p = bond.dirtyPrice()
         cfs_p = [ c.amount() for c in bond.cashflows() ]
         discounts_p = [ yield_curve.discount(d) for d in dates ]
         print(P_p)
         
         y.setValue(0.002 - dy)
         P_m = bond.dirtyPrice()
         cfs_m = [ c.amount() for c in bond.cashflows() ]
         discounts_m = [ yield_curve.discount(d) for d in dates ]
         print(P_m)
         
         y.setValue(0.002)

Out[14]: 100.03632329080955
         100.03698398354918

Now the coupon amounts change with the yield (except, of course, the first coupon, whose amount was already fixed)…

In [15]: DataFrame(list(zip(dates, cfs, discounts, cfs_p,
                            discounts_p, cfs_m, discounts_m)),
                   columns = ('date','amount','discounts',
                              'amount (+)','discounts (+)',
                              'amount (-)','discounts (-)',),
                   index = range(1,len(dates)+1))

Out[15]: 
  date amount discounts amount (+) discounts (+) amount (-) discounts (-)
1 February 9th, 2015 0.102778 0.999339 0.102778 0.999336 0.102778 0.999343
2 August 10th, 2015 0.101112 0.998330 0.101617 0.998322 0.100606 0.998338
3 February 8th, 2016 0.101112 0.997322 0.101617 0.997308 0.100606 0.997335
4 August 8th, 2016 0.101112 0.996314 0.101617 0.996296 0.100606 0.996333
5 February 8th, 2017 0.102223 0.995297 0.102734 0.995273 0.101712 0.995320
6 August 8th, 2017 0.100556 0.994297 0.101059 0.994269 0.100053 0.994325
7 February 8th, 2018 0.102223 0.993282 0.102734 0.993248 0.101712 0.993315
8 August 8th, 2018 0.100556 0.992284 0.101059 0.992245 0.100053 0.992322
9 February 8th, 2019 0.102223 0.991270 0.102734 0.991227 0.101712 0.991314
10 August 8th, 2019 0.100556 0.990275 0.101059 0.990226 0.100053 0.990323
11 August 8th, 2019 100.000000 0.990275 100.000000 0.990226 100.000000 0.990323

…and the duration is calculated correctly, thus approximating the four months to the next coupon.

In [16]: print(-(1/P)*(P_p - P_m)/(2*dy))

Out[16]: 0.33022533022465994

This also holds if the discounting curve is dependent, but not the same as the forecast curve; e.g., as in the case of an added credit spread:

In [17]: discount_curve = ql.ZeroSpreadedTermStructure(
             forecast_curve,
             ql.QuoteHandle(ql.SimpleQuote(0.001)))
         bond.setPricingEngine(
             ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve)))

This causes the price to decrease due to the increased discount factors…

In [18]: P = bond.dirtyPrice()
         cfs = [ c.amount() for c in bond.cashflows() ]
         discounts = [ discount_curve.discount(d) for d in dates ]
         print(P)

Out[18]: 99.55107926688962

…but the coupon amounts are still the same.

In [19]: DataFrame(list(zip(dates, cfs, discounts)),
                   columns = ('date','amount','discount'),
                   index = range(1,len(dates)+1))

Out[19]: 
  date amount discount
1 February 9th, 2015 0.102778 0.999009
2 August 10th, 2015 0.101112 0.997496
3 February 8th, 2016 0.101112 0.995984
4 August 8th, 2016 0.101112 0.994475
5 February 8th, 2017 0.102223 0.992952
6 August 8th, 2017 0.100556 0.991456
7 February 8th, 2018 0.102223 0.989938
8 August 8th, 2018 0.100556 0.988446
9 February 8th, 2019 0.102223 0.986932
10 August 8th, 2019 0.100556 0.985445
11 August 8th, 2019 100.000000 0.985445

The price derivative is calculated in the same way as above…

In [20]: y.setValue(0.002 + dy)
         P_p = bond.dirtyPrice()
         print(P_p)
         
         y.setValue(0.002 - dy)
         P_m = bond.dirtyPrice()
         print(P_m)
         
         y.setValue(0.002)

Out[20]: 99.55075966035385
         99.55139887578544

In [21]: print(-(1/P)*(P_p - P_m)/(2*dy))

Out[21]: 0.3210489711903113

…and yields a similar result.