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.