import QuantLib as ql
import pandas as pd
import numpy as np
= ql.Date(9, ql.September, 2024)
today = today ql.Settings.instance().evaluationDate
Coupons with multiple resets
(Originally based on a question on the QuantLib mailing list; thanks, Jack.)
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 MultipleResetsCoupon
class and the MultipleResetsLeg
utility (a class in C++ or a function in Python.)
Let’s take some initialization out of the way.
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.
= ql.ZeroCurve(
forecast_curve + ql.Period(20, ql.Years)],
[today, today 0.035, 0.06],
[
ql.Actual365Fixed(),
)= ql.YieldTermStructureHandle(forecast_curve) forecast_handle
= ql.Euribor(ql.Period(1, ql.Months), forecast_handle) index
And as usual, depending on the coupon schedule we might have to add some past fixings to the index.
30, ql.July, 2024), 0.03611)
index.addFixing(ql.Date(29, ql.August, 2024), 0.03602) index.addFixing(ql.Date(
Multiple-resets coupons
As I mentioned, we can use the MultipleResetsLeg
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.
However, MultipleResetsLeg
differs in one important respect from other similar functions such as FixedRateLeg
or IborLeg
. The schedule we need to pass is not the schedule of the coupons, but the full schedule of the underlying multiple fixings over the whole leg. In order to derive the coupon schedule, we’ll also pass the number of underlying fixings periods per coupon. In this example, I’ll specify 3 monthly fixings per coupon, resulting in quarterly payments.
= ql.MakeSchedule(
reset_schedule =ql.Date(1, ql.August, 2024),
effectiveDate=ql.Date(1, ql.August, 2025),
terminationDate=ql.Monthly,
frequency=ql.TARGET(),
calendar
)
= 100.0
nominal = 3
resets_per_coupon = ql.MultipleResetsLeg(
coupons
reset_schedule,
index,
resets_per_coupon,
[nominal],=2,
paymentLag=ql.RateAveraging.Compound,
averagingMethod
)
= 3
settlement_days = ql.TARGET()
calendar = ql.Bond(settlement_days, calendar, reset_schedule[0], coupons) bond
First of all, I’ll use a constant-rate discount curve to check that everything works:
= ql.FlatForward(today, 0.03, ql.Actual365Fixed())
discount_curve = ql.YieldTermStructureHandle(discount_curve)
discount_handle
bond.setPricingEngine(ql.DiscountingBondEngine(discount_handle)) bond.cleanPrice()
100.51555336316565
And now, 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():
None, None, None))
info.append((d, index.fixing(d), None, None, c.rate(), c.date(), c.amount()))
info.append((return info
def redemption_info(cf):
return [(None, None, None, cf.date(), cf.amount())]
= []
data for cf in bond.cashflows():
= ql.as_multiple_resets_coupon(cf)
c if c is not None:
+= coupon_info(c)
data else:
+= redemption_info(cf) data
= pd.DataFrame(
df
data,=[
columns"fixing date",
"fixing",
"coupon rate",
"payment date",
"amount",
],
)format(
df.style.
{"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}",
}="index") ).hide(axis
fixing date | fixing | coupon rate | payment date | amount |
---|---|---|---|---|
July 30th, 2024 | 3.61% | |||
August 29th, 2024 | 3.60% | |||
September 27th, 2024 | 3.48% | |||
3.58% | November 5th, 2024 | 0.9138 | ||
October 30th, 2024 | 3.50% | |||
November 28th, 2024 | 3.52% | |||
December 30th, 2024 | 3.55% | |||
3.54% | February 5th, 2025 | 0.9232 | ||
January 30th, 2025 | 3.57% | |||
February 27th, 2025 | 3.59% | |||
March 28th, 2025 | 3.61% | |||
3.60% | May 6th, 2025 | 0.8793 | ||
April 29th, 2025 | 3.63% | |||
May 29th, 2025 | 3.65% | |||
June 27th, 2025 | 3.67% | |||
3.66% | August 5th, 2025 | 0.9248 | ||
August 5th, 2025 | 100.0 |
And by the way, if you see that a SubPeriodsLeg
function is also available (it’s deprecated, and it might be removed by the time you read this), don’t use it. It’s an older version of the above, and sometimes it calculated the wrong schedule.
What did we get?
The table above shows the coupon dates (including the payment lag), the underlying fixings, the resulting compounded coupon rates, and the coupon amounts. What you might still be asking yourselves is, didn’t we specify a reset schedule rolling on the first of the month? Why are all the fixings a few days before that?
The thing is, what I called the reset schedule doesn’t give us the sequence of the fixing dates; instead, it gives us the schedule of the periods underlying each of the fixings. To see if I can make more sense, let’s take the first coupon and ask it for its start and end dates.
= ql.as_multiple_resets_coupon(bond.cashflows()[0])
first print(first.accrualStartDate())
print(first.accrualEndDate())
August 1st, 2024
November 1st, 2024
If this were a floating-rate coupon, it would have a single fixing date. Since the Euribor index has two fixing days…
print(index.fixingDays())
2
…the fixing date would be as follows:
print(
calendar.advance(-index.fixingDays(), ql.Days
first.accrualStartDate(),
) )
July 30th, 2024
that is, the first fixing date in the table above. Since the coupon has multiple resets, its period is divided into three sub-periods specified by the first dates of the reset schedule:
print(f"{reset_schedule[0]} to {reset_schedule[1]}")
print(f"{reset_schedule[1]} to {reset_schedule[2]}")
print(f"{reset_schedule[2]} to {reset_schedule[3]}")
August 1st, 2024 to September 2nd, 2024
September 2nd, 2024 to October 1st, 2024
October 1st, 2024 to November 1st, 2024
These roll on the first of the month, as specified, and when the fixing days are taken into account we find the dates in the table:
print(calendar.advance(reset_schedule[0], -index.fixingDays(), ql.Days))
print(calendar.advance(reset_schedule[1], -index.fixingDays(), ql.Days))
print(calendar.advance(reset_schedule[2], -index.fixingDays(), ql.Days))
July 30th, 2024
August 29th, 2024
September 27th, 2024