Hello again.

Today’s post is based on a question by Steve Hsieh on the QuantLib mailing list and an issue by Marcin Rybacki on GitHub. Thanks to both!

The effect of today’s fixing on bootstrapping

The purpose of this notebook is to highlight an effect that might not be obvious.

import QuantLib as ql
import pandas as pd

today = ql.Date(23, ql.January, 2024)
ql.Settings.instance().evaluationDate = today
ql.IborCoupon.createIndexedCoupons()

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,
        ql.QuoteHandle(ql.SimpleQuote(quote / 100.0)),
        ql.Estr(),
        paymentFrequency=ql.Annual,
    )
    for tenor, quote in [
        (ql.Period("1y"), 3.995),
        (ql.Period("5y"), 4.135),
        (ql.Period("10y"), 4.372),
        (ql.Period("20y"), 4.798),
    ]
]

discount_curve = ql.PiecewiseLogCubicDiscount(
    0, ql.TARGET(), helpers, ql.Actual360()
)

discount_handle = ql.YieldTermStructureHandle(discount_curve)

Next, we create the forecast curve for the floating index; in this case, 6-months Euribor.

quoted_swap_data = [
    (ql.Period("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),
]
index = ql.Euribor(ql.Period(6, ql.Months))
settlement_days = 2
calendar = ql.TARGET()
fixed_frequency = ql.Annual
fixed_convention = ql.Unadjusted
fixed_day_count = ql.Thirty360(ql.Thirty360.BondBasis)

helpers = [
    ql.SwapRateHelper(
        ql.QuoteHandle(ql.SimpleQuote(quote / 100.0)),
        tenor,
        calendar,
        fixed_frequency,
        fixed_convention,
        fixed_day_count,
        index,
        ql.QuoteHandle(),
        ql.Period(0, ql.Days),
        discount_handle,
    )
    for tenor, quote in quoted_swap_data
]

euribor_curve = ql.PiecewiseFlatForward(0, ql.TARGET(), helpers, ql.Actual360())

Here are the resulting forward rates:

df = pd.DataFrame(euribor_curve.nodes(), columns=["date", "rate"])
df.style.format({"rate": "{:.6%}"})
  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.

euribor_handle = ql.YieldTermStructureHandle(euribor_curve)

euribor = ql.Euribor6M(euribor_handle)
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.)

start_date = today - ql.Period(21, ql.Months)
end_date = start_date + ql.Period(15, ql.Years)

fixed_schedule = ql.Schedule(
    start_date,
    end_date,
    ql.Period(fixed_frequency),
    calendar,
    fixed_convention,
    fixed_convention,
    ql.DateGeneration.Forward,
    False,
)
float_schedule = ql.Schedule(
    start_date,
    end_date,
    euribor.tenor(),
    calendar,
    euribor.businessDayConvention(),
    euribor.businessDayConvention(),
    ql.DateGeneration.Forward,
    False,
)
swap = ql.VanillaSwap(
    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))

euribor.addFixing(ql.Date(19, 10, 2023), 0.0413)

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.

euribor.addFixing(today, 0.0394)

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.)

euribor.fixing(today, True)
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:

df["new rate"] = [r for n, r in euribor_curve.nodes()]
df.style.format({"rate": "{:.6%}", "new rate": "{:.6%}"})
  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.