Coupons with multiple resets
Welcome back.
This time, another notebook inspired by a question on the QuantLib mailing list. (Thanks, Jack.) Let’s just say that it didn’t go the way I expected.
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.
Coupons with multiple resets
Some bonds and swaps have coupons whose reset frequency is different than the payment frequency; for instance, a coupon might pay quarterly but its rate would be the combination of three 1-month fixings occurring during its life.
There is no specific instrument in QuantLib to instantiate such a bond;
but as we’ve seen in other cases, it’s possible to use the constructor
of the base Bond
or Swap
class once we have built the coupons. We
can do this by means of the SubPeriodsCoupon
class and the
SubPeriodsLeg
utility (a class in C++ or a function in Python.)
However, we’ll see that we can run into a snag.
Let’s take some initialization out of the way.
import QuantLib as ql
import pandas as pd
import numpy as np
today = ql.Date(9, ql.September, 2024)
ql.Settings.instance().evaluationDate = today
The underlying index
As an example, I’ll use a an Euribor1M index. To avoid constant fixings, I’ll use a mock curve with zero rates increasing linearly over the next 20 years.
forecast_curve = ql.ZeroCurve(
[today, today + ql.Period(20, ql.Years)],
[0.035, 0.06],
ql.Actual365Fixed(),
)
forecast_handle = ql.YieldTermStructureHandle(forecast_curve)
index = ql.Euribor(ql.Period(1, ql.Months), forecast_handle)
And as usual, depending on the coupon schedule we might have to add some past fixings to the index.
index.addFixing(ql.Date(30, ql.July, 2024), 0.03611)
index.addFixing(ql.Date(29, ql.August, 2024), 0.03602)
Sub-periods coupons
As I mentioned, we can use the SubPeriodsLeg
function to build a
sequence of coupons for a bond; and as usual, we need to build the
corresponding schedule first. Other parameters may include a payment lag
(2 days in this example), the method used to combine the underlying
index fixings (compounding or averaging, defaulting to the former), and
other not shown here such as ex-coupon information, a multiplier or an
additional spread.
schedule = ql.MakeSchedule(
effectiveDate=ql.Date(1, ql.August, 2024),
terminationDate=ql.Date(1, ql.August, 2025),
frequency=ql.Quarterly,
calendar=ql.TARGET(),
)
nominal = 100.0
coupons = ql.SubPeriodsLeg(
[nominal],
schedule,
index,
paymentLag=2,
averagingMethod=ql.RateAveraging.Compound,
)
settlement_days = 3
calendar = ql.TARGET()
bond = ql.Bond(settlement_days, calendar, schedule[0], coupons)
First of all, I’ll use a constant-rate discount curve to check that everything works:
discount_curve = ql.FlatForward(today, 0.03, ql.Actual365Fixed())
discount_handle = ql.YieldTermStructureHandle(discount_curve)
bond.setPricingEngine(ql.DiscountingBondEngine(discount_handle))
bond.cleanPrice()
100.51567258547013
And now, emboldened by this success, let’s look under the hood.
Cash-flow analysis
As in previous notebooks, we can examine the coupons we created to
retrieve various bits of information. Calling bond.cashflows()
returns
the coupons we created, plus the redemption inferred by the Bond
constructor. Since we’re calling from the underlying C++ library, the
coupons are returned as instances of the base CashFlow
class and need
to go through a dynamic_pointer_cast
to give them their original type
and access the corresponding methods. In Python, the C++ cast is
exported as the as_sub_periods_coupon
function; if the cast fails (as
for the redemption) it returns None
.
Once we have the coupon, we can ask (for instance) for its multiple fixings dates or its final rate besides its payment date and amount. For the redemption, payment date and amount are all we can get.
Pardon all the None
s I’m adding to the info and my formatting code;
it’s to get a decent-looking table in the end.
def coupon_info(c):
info = []
for d in c.fixingDates():
info.append((d, index.fixing(d), None, None, None))
info.append((None, None, c.rate(), c.date(), c.amount()))
return info
def redemption_info(cf):
return [(None, None, None, cf.date(), cf.amount())]
data = []
for cf in bond.cashflows():
c = ql.as_sub_periods_coupon(cf)
if c is not None:
data += coupon_info(c)
else:
data += redemption_info(cf)
df = pd.DataFrame(
data,
columns=[
"fixing date",
"fixing",
"coupon rate",
"payment date",
"amount",
],
)
df.style.format(
{
"fixing date": lambda d: "" if d is None else str(d),
"fixing": lambda f: "" if np.isnan(f) else f"{f:.2%}",
"coupon rate": lambda r: "" if np.isnan(r) else f"{r:.2%}",
"payment date": lambda d: str(d) if d is not None else "",
"amount": lambda x: "" if np.isnan(x) else f"{x:.4}",
}
).hide(axis="index")
fixing date | fixing | rate | payment date | amount |
---|---|---|---|---|
Jul 30th, 2024 | 3.61% | |||
Aug 29th, 2024 | 3.60% | |||
Sep 27th, 2024 | 3.48% | |||
3.58% | Nov 5th, 2024 | 0.9138 | ||
Oct 30th, 2024 | 3.50% | |||
Oct 31st, 2024 | 3.51% | |||
Nov 29th, 2024 | 3.53% | |||
Dec 31st, 2024 | 3.55% | |||
3.54% | Feb 5th, 2025 | 0.9234 | ||
Jan 30th, 2025 | 3.57% | |||
Feb 27th, 2025 | 3.59% | |||
Mar 31st, 2025 | 3.61% | |||
3.60% | May 6th, 2025 | 0.8792 | ||
Apr 29th, 2025 | 3.63% | |||
May 29th, 2025 | 3.65% | |||
Jun 27th, 2025 | 3.67% | |||
3.66% | Aug 5th, 2025 | 0.9248 | ||
Aug 5th, 2025 | 100.0 |
An unexpected bug
Well, it was unexpected for me as I wrote this notebook, not so much for you since I’ve already been hinting at something rotten. In any case, did you notice the second coupon above? Yes, four fixing dates instead of the expected three. That’s obviously wrong.
Why is this happening? It turns out that we’re doing the construction of the coupons in the wrong way. We built a schedule for the coupons, not the underlying fixings, and here it is:
pd.DataFrame(list(schedule), columns=["coupon dates"])
coupon dates | |
---|---|
0 | August 1st, 2024 |
1 | November 1st, 2024 |
2 | February 3rd, 2025 |
3 | May 2nd, 2025 |
4 | August 1st, 2025 |
As usual, the first date is the start of the first coupon, the second date is the end of the first coupon and the start of the second coupon, and so on.
What happens next is that we create coupons based on this schedule and we leave it to each of them to figure out the schedule of the underlying fixings, given their start and end dates and the 1-month index we also passed them. With no other information, the coupons have little choice except creating a schedule with a monthly frequency from the start date to the end date. In the current implementation, it happens to do it backwards, so this is what the second coupon creates:
fixing_schedule = ql.MakeSchedule(
effectiveDate=schedule[1],
terminationDate=schedule[2],
tenor=index.tenor(),
calendar=index.fixingCalendar(),
convention=index.businessDayConvention(),
rule=ql.DateGeneration.Backward,
)
pd.DataFrame(list(fixing_schedule), columns=["sub-coupon dates"])
sub-coupon dates | |
---|---|
0 | November 1st, 2024 |
1 | November 4th, 2024 |
2 | December 3rd, 2024 |
3 | January 3rd, 2025 |
4 | February 3rd, 2025 |
We get three 1-month periods plus a three-days leftover (the fixing dates in the cash-flow analysis are those that result from subtracting two fixing days.)
How to fix it?
This issue comes from the combination of a calendar adjustment that makes the coupon a bit longer than three months, and of generating dates backward. In this particular case, going forward would give us the expected three periods, but this is not a generic solution. We can look at the third coupon (from February 3rd to May 2nd, 2025) to see a case in which both forward and backward generation would give us three fixing periods, but neither case would be correct; forward generation would reset on the 3rd of each month (plus adjustments) and backward generation on the 2nd, but the original schedule resets on the 1st instead.
This suggests the actual solution. Instead of having each coupon figure out its fixing schedule, we should create the global fixing schedule externally and then aggregate fixing periods (three of them, in this case) to create the coupon schedule. In this case, the fixing schedule would be:
fixing_schedule = ql.MakeSchedule(
effectiveDate=ql.Date(1, ql.August, 2024),
terminationDate=ql.Date(1, ql.August, 2025),
tenor=index.tenor(),
calendar=index.fixingCalendar(),
convention=index.businessDayConvention(),
rule=ql.DateGeneration.Forward,
)
pd.DataFrame(list(fixing_schedule), columns=["sub-coupon dates"])
sub-coupon dates | |
---|---|
0 | August 1st, 2024 |
1 | September 2nd, 2024 |
2 | October 1st, 2024 |
3 | November 1st, 2024 |
4 | December 2nd, 2024 |
5 | January 2nd, 2025 |
6 | February 3rd, 2025 |
7 | March 3rd, 2025 |
8 | April 1st, 2025 |
9 | May 2nd, 2025 |
10 | June 2nd, 2025 |
11 | July 1st, 2025 |
12 | August 1st, 2025 |
Taking the start date and every third date afterwards gives us the same schedule as before for the coupons:
pd.DataFrame(list(fixing_schedule)[0::3], columns=["coupon dates"])
coupon dates | |
---|---|
0 | August 1st, 2024 |
1 | November 1st, 2024 |
2 | February 3rd, 2025 |
3 | May 2nd, 2025 |
4 | August 1st, 2025 |
The catch is that the updated implementation still won’t be available when you read this post. I’ll also have to figure out how to make the change within the bounds of our usual deprecation policy, so that it doesn’t break existing code. All things considered, the new code might be in release 1.36 or 1.37. I hate to close with a downer but, well, stay tuned for further updates. Bye for now!