import QuantLib as ql
import pandas as pd
= ql.Date(23, ql.January, 2024)
today = today
ql.Settings.instance().evaluationDate ql.IborCoupon.createIndexedCoupons()
The effect of today’s fixing on bootstrapped interest rates
(Based on a question by Steve Hsieh on the QuantLib mailing list and an issue by Marcin Rybacki on GitHub. Thanks!)
The purpose of this notebook is to highlight an effect that might not be obvious.
Setting up
Let’s say we have the usual dual-curve setup for pricing fixed vs floater swaps. I’ll gloss over the mechanics of creating the discount and forecast curves; it’s described elsewhere.
For brevity, I’ll use just a handful of OIS to create a sample discount curve.
= [
helpers
ql.OISRateHelper(2,
tenor,/ 100.0)),
ql.QuoteHandle(ql.SimpleQuote(quote
ql.Estr(),=ql.Annual,
paymentFrequency
)for tenor, quote in [
"1y"), 3.995),
(ql.Period("5y"), 4.135),
(ql.Period("10y"), 4.372),
(ql.Period("20y"), 4.798),
(ql.Period(
]
]
= ql.PiecewiseLogCubicDiscount(
discount_curve 0, ql.TARGET(), helpers, ql.Actual360()
)
= ql.YieldTermStructureHandle(discount_curve) discount_handle
Next, we create the forecast curve for the floating index; in this case, 6-months Euribor.
= [
quoted_swap_data "1y"), 3.96),
(ql.Period("2y"), 4.001),
(ql.Period("3y"), 4.055),
(ql.Period("5y"), 4.175),
(ql.Period("7y"), 4.304),
(ql.Period("10y"), 4.499),
(ql.Period("12y"), 4.611),
(ql.Period("15y"), 4.741),
(ql.Period("20y"), 4.846),
(ql.Period( ]
= ql.Euribor(ql.Period(6, ql.Months))
index = 2
settlement_days = ql.TARGET()
calendar = ql.Annual
fixed_frequency = ql.Unadjusted
fixed_convention = ql.Thirty360(ql.Thirty360.BondBasis)
fixed_day_count
= [
helpers
ql.SwapRateHelper(/ 100.0)),
ql.QuoteHandle(ql.SimpleQuote(quote
tenor,
calendar,
fixed_frequency,
fixed_convention,
fixed_day_count,
index,
ql.QuoteHandle(),0, ql.Days),
ql.Period(
discount_handle,
)for tenor, quote in quoted_swap_data
]
= ql.PiecewiseFlatForward(
euribor_curve 0, ql.TARGET(), helpers, ql.Actual360()
)
Here are the resulting forward rates:
= pd.DataFrame(euribor_curve.nodes(), columns=["date", "rate"])
df format({"rate": "{:.6%}"}) df.style.
date | rate | |
---|---|---|
0 | January 23rd, 2024 | 3.797969% |
1 | January 27th, 2025 | 3.797969% |
2 | January 26th, 2026 | 3.919519% |
3 | January 27th, 2027 | 4.039856% |
4 | January 25th, 2029 | 4.218121% |
5 | January 27th, 2031 | 4.499489% |
6 | January 25th, 2034 | 4.884324% |
7 | January 25th, 2036 | 5.149876% |
8 | January 26th, 2039 | 5.275125% |
9 | January 27th, 2044 | 5.163086% |
We’re now able to create an instance of Euribor6M
that can forecast future fixings—or today’s fixing, if we don’t have already stored it in the library.
= ql.YieldTermStructureHandle(euribor_curve)
euribor_handle
= ql.Euribor6M(euribor_handle) euribor
euribor.fixing(today)
0.03834665129363748
Pricing a sample swap
Now I’ll create a sample swap and price it using the discount and forecast curves I created. I’ll have it start in the past, so I’ll have to store the fixing for the current coupon (which was in the past and can’t be read off the forecast curve.)
= today - ql.Period(21, ql.Months)
start_date = start_date + ql.Period(15, ql.Years)
end_date
= ql.Schedule(
fixed_schedule
start_date,
end_date,
ql.Period(fixed_frequency),
calendar,
fixed_convention,
fixed_convention,
ql.DateGeneration.Forward,False,
)= ql.Schedule(
float_schedule
start_date,
end_date,
euribor.tenor(),
calendar,
euribor.businessDayConvention(),
euribor.businessDayConvention(),
ql.DateGeneration.Forward,False,
)= ql.VanillaSwap(
swap
ql.Swap.Payer,1_000_000,
fixed_schedule,0.04,
fixed_day_count,
float_schedule,
euribor,0.0,
euribor.dayCounter(),
)
swap.setPricingEngine(ql.DiscountingSwapEngine(discount_handle))
19, 10, 2023), 0.0413)
euribor.addFixing(ql.Date(
swap.NPV()
47643.03343425237
A surprising effect
Now, if we’re pricing a number of swaps with different schedules, it’s not very convenient to figure out what past fixings we need to store. It’s easier to store the whole history of the index for the past year or so—and if we already have it in our systems, we’ll probably add today’s fixing as well.
0.0394) euribor.addFixing(today,
At this point, if we ask the index for today’s fixing, it will return the stored value.
euribor.fixing(today)
0.0394
(A note: the full signature of the fixing
method is
Real fixing(const Date& fixingDate, bool forecastTodaysFixing = false)
so we can still read the corresponding rate off the curve, if we need it for comparison.)
True) euribor.fixing(today,
0.03729528572216041
Our sample swap, though, doesn’t use today’s fixing to determine its coupons, so its price shouldn’t change—right?
swap.NPV()
46857.318298378086
What happened?
True, the swap doesn’t use today’s fixing directly. But storing it causes the forecast curve to recalculate, because it is used by the quoted swaps over which we’re bootstrapping it. Their first coupon is now determined, and the rest of the curve has to change so that their fair rate still corresponds to the quoted one.
To check this, we can ask the curve for its nodes again and compare the result with what we have already stored in the data frame:
"new rate"] = [r for n, r in euribor_curve.nodes()]
df[format({"rate": "{:.6%}", "new rate": "{:.6%}"}) df.style.
date | rate | new rate | |
---|---|---|---|
0 | January 23rd, 2024 | 3.797969% | 3.694805% |
1 | January 27th, 2025 | 3.797969% | 3.694805% |
2 | January 26th, 2026 | 3.919519% | 3.919519% |
3 | January 27th, 2027 | 4.039856% | 4.039856% |
4 | January 25th, 2029 | 4.218121% | 4.218121% |
5 | January 27th, 2031 | 4.499489% | 4.499489% |
6 | January 25th, 2034 | 4.884324% | 4.884324% |
7 | January 25th, 2036 | 5.149876% | 5.149876% |
8 | January 26th, 2039 | 5.275125% | 5.275125% |
9 | January 27th, 2044 | 5.163086% | 5.163086% |
We can see the difference in the first year of the curve. The rates from the second year onwards are not modified; intuitively, that’s because both the old and the new curve, by construction, give the same value to the first two floating coupons (those corresponding to a 1-year swap) so the rest of the curve doesn’t need to change.
However, this change is enough to modify our forecast of the next coupon of our sample swap, and therefore its total NPV.
Is this desirable?
Well, it might be unexpected or confusing at first, but I don’t see a use case for not including the fixing effect. On the one hand, once it’s available, we can assume that the quoted swap rates make use of the information, and therefore we need it as well; and on the other hand, ignoring the fixing during bootstrapping and including it when pricing would cause quoted swaps to no longer price at 0—which is obviously undesirable. All in all, I think including the effect is the right thing to do.