LIBOR fallback calculation
Welcome back!
The switch date of the LIBOR reform creeps on us, giving me a good reason to resurrect the blog. Here’s some Python code that might be useful in the coming months. It can be translated to C++ almost instruction by instruction.
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.
LIBOR fallback calculation
Starting from January 2022, LIBOR fixings for most currencies (GBP, EUR, CHF, JPY) will be discontinued; USD LIBOR will be published until June 2023. Coupon rates for existing LIBOR-based bonds (and swaps) will be determined based on the chosen daily risk-free rate for the relevant currency, as the compounded daily rate over the coupon life plus a spread.
After the switch, such floating-rate bonds will effectively have two kinds of coupons; those fixed until 2021, and those fixed afterwards.
The library doesn’t provide a constructor for such bonds, but they can be built manually by creating the correct coupons.
First of all, here’s the modules I’ll use:
import QuantLib as ql
from datetime import date
from pandas import DataFrame
Now, let’s assume we are after the switch date, so GBP LIBOR fixings are no longer available and neither is the corresponding curve.
today = ql.Date(8, ql.February, 2022)
ql.Settings.instance().evaluationDate = today
bps = 1e-4
If we build a floating-rate bond as usual, it will have a number of past coupons that have paid LIBOR; those can stay the same. Unfortunately, at the time of this writing (shortly after the release of QuantLib 1.24), past coupons still need an index with a non-null term structure, even if it won’t be used. Future releases will fix this issue.
mock_curve = ql.FlatForward(0, ql.UnitedKingdom(), 0.0, ql.Actual360())
gbp_libor_6m = ql.GBPLibor(ql.Period(6, ql.Months),
ql.YieldTermStructureHandle(mock_curve))
The bond starts a bit more than two years in the past and lasts 5 years. It pays a margin of 10 bps over the fixing of the 6-months GBP LIBOR.
startDate = today - ql.Period(2, ql.Years) - ql.Period(4, ql.Months)
endDate = startDate + ql.Period(5, ql.Years)
schedule = ql.Schedule(startDate, endDate,
ql.Period(ql.Semiannual), ql.UnitedKingdom(),
ql.Following, ql.Following,
ql.DateGeneration.Backward, False)
bond = ql.FloatingRateBond(settlementDays = 3,
faceAmount = 100,
schedule = schedule,
index = gbp_libor_6m,
paymentDayCounter = gbp_libor_6m.dayCounter(),
spreads = [10 * bps])
Past LIBOR coupons
As usual, we need to store past fixings into the LIBOR index…
gbp_libor_6m.addFixing(ql.Date(8, 10, 2019), 0.0081238)
gbp_libor_6m.addFixing(ql.Date(8, 4, 2020), 0.0073313)
gbp_libor_6m.addFixing(ql.Date(8, 10, 2020), 0.0007488)
gbp_libor_6m.addFixing(ql.Date(8, 4, 2021), 0.0010988)
gbp_libor_6m.addFixing(ql.Date(8, 10, 2021), 0.0019175)
…which gives us working past coupons.
data = []
for cf in bond.cashflows():
c = ql.as_floating_rate_coupon(cf)
if c is not None and c.fixingDate() <= today:
data.append((cf.date(),
c.fixingDate(),
c.indexFixing(),
c.rate(),
cf.amount()))
(DataFrame(data,
columns = ('payment date', 'fixing date', 'index fixing', 'rate', 'amount'))
.style.format({'amount': '{:.4f}', 'index fixing': '{:.2%}', 'rate': '{:.2%}'}))
payment date | fixing date | index fixing | rate | amount | |
---|---|---|---|---|---|
0 | April 8th, 2020 | October 8th, 2019 | 0.81% | 0.91% | 0.4574 |
1 | October 8th, 2020 | April 8th, 2020 | 0.73% | 0.83% | 0.4177 |
2 | April 8th, 2021 | October 8th, 2020 | 0.07% | 0.17% | 0.0872 |
3 | October 8th, 2021 | April 8th, 2021 | 0.11% | 0.21% | 0.1052 |
4 | April 8th, 2022 | October 8th, 2021 | 0.19% | 0.29% | 0.1455 |
Replacing future coupons
We need to replace the LIBOR coupons fixing after January 1st, 2022 with coupons over the same dates and paying a compounded daily index plus an adjustment spread. In our example, GBP LIBOR falls back on Sonia plus 27.66 bps. I’ll make up a fake curve for Sonia:
dates = [ today + ql.Period(i, ql.Years) for i in range(6) ]
rates = [ 4.1*bps, 12.8*bps, 26.7*bps, 49.4*bps, 73.4*bps, 82.8*bps ]
sonia_curve = ql.ZeroCurve(dates, rates, ql.Actual360())
sonia = ql.Sonia(ql.YieldTermStructureHandle(sonia_curve))
According to the fallback rules, the accrual start date is determined starting from the fixing date of the coupon, going first forward a number of spot days (equal to what used to be the fixing days of the LIBOR index) and then back an offset lag of 2 days. The accrual end date is then determined by going forward the tenor of the LIBOR.
In this example, this translates to something like this:
switch_date = ql.Date(1, 1, 2022)
adjustment_spread = 27.66 * bps
calendar = sonia.fixingCalendar()
convention = ql.ModifiedFollowing
def fallback_coupon(c):
fixing_date = c.fixingDate()
spot_days = c.index().fixingDays()
tenor = c.index().tenor()
spot_date = calendar.advance(fixing_date, spot_days, ql.Days)
accrual_start_date = calendar.advance(spot_date, -2, ql.Days)
accrual_end_date = calendar.advance(accrual_start_date, tenor, convention)
return ql.OvernightIndexedCoupon(c.date(),
c.nominal(),
accrual_start_date,
accrual_end_date,
sonia,
c.gearing(),
c.spread() + adjustment_spread,
accrual_start_date,
accrual_end_date,
c.dayCounter())
In a real-world example, you might pass the risk-free index as a parameter.
I’ll use the function above to create another set of cashflows, in
which LIBOR coupons fixing after the switch date are replaced. To
identify them, we use first ql.as_floating_rate_coupon
(which
returns None
if the cash flow is not a coupon, as in the case of the
final redemption) and then the fixingDate
method when the cast
succeeds.
cashflows = []
for cf in bond.cashflows():
c = ql.as_floating_rate_coupon(cf)
if c is None or c.fixingDate() < switch_date:
cashflows.append(cf)
else:
cashflows.append(fallback_coupon(c))
The resulting cashflows can be use to instantiate the final bond:
bond = ql.Bond(3, ql.UnitedKingdom(), 100.0,
endDate, startDate, cashflows)
By examining the cash flows, we can verify which index they’re based on and what resulting rate they pay:
data = []
for cf in bond.cashflows():
c = ql.as_floating_rate_coupon(cf)
data.append((cf.date(),
c.index().name() if c is not None else None,
c.rate() if c is not None else None,
cf.amount()))
(DataFrame(data,
columns = ('payment date', 'index', 'rate', 'amount'))
.style.format({'amount': '{:.4f}', 'rate': '{:.2%}'}))
payment date | index | rate | amount | |
---|---|---|---|---|
0 | April 8th, 2020 | GBPLibor6M Actual/365 (Fixed) | 0.91% | 0.4574 |
1 | October 8th, 2020 | GBPLibor6M Actual/365 (Fixed) | 0.83% | 0.4177 |
2 | April 8th, 2021 | GBPLibor6M Actual/365 (Fixed) | 0.17% | 0.0872 |
3 | October 8th, 2021 | GBPLibor6M Actual/365 (Fixed) | 0.21% | 0.1052 |
4 | April 8th, 2022 | GBPLibor6M Actual/365 (Fixed) | 0.29% | 0.1455 |
5 | October 10th, 2022 | SoniaON Actual/365 (Fixed) | 0.49% | 0.2457 |
6 | April 11th, 2023 | SoniaON Actual/365 (Fixed) | 0.60% | 0.2979 |
7 | October 9th, 2023 | SoniaON Actual/365 (Fixed) | 0.76% | 0.3818 |
8 | April 8th, 2024 | SoniaON Actual/365 (Fixed) | 0.96% | 0.4828 |
9 | October 8th, 2024 | SoniaON Actual/365 (Fixed) | 1.29% | 0.6488 |
10 | October 8th, 2024 | None | nan% | 100.0000 |
The bond can be priced as usual, by providing either a yield or a discount curve.
Warnings
At this time, there are still a couple of caveats.
First, the OvernightIndexedCoupon
class doesn’t calculate accruals
correctly: it calculates them by multiplying the coupon rate by the
accrual time, but this is not correct since the final rate will only
be known at the end of the coupon and can’t be used mid-coupon. This
is being addressed and will probably be fixed in the next release,
QuantLib 1.25.
Second, the accrual calculation doesn’t take into account the offset
lag either. This, also, needs to be addressed. When it’s done, the
constructor of OvernightIndexedCoupon
will probably take the offset
as an additional optional parameter.
In the meantime, a way to calculate the accrual correctly might be to
create an OvernightIndexedCoupon
with the same accrual start date as
the current coupon and an accrual end date equal to the desired
settlement minus the offset lag. This, however, will only be a
concern for a given bond after the start of the first coupon using the
fallback rules.
Thanks for reading so far. See you next time!