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.

Follow me on Twitter if you want to be notified of new posts, or subscribe via RSS: the buttons for 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!