import QuantLib as ql
= ql.Date(25, ql.October, 2021)
today = today
ql.Settings.instance().evaluationDate
= 1e-4 bps
Curve bootstrapping
The process of bootstrapping, unlike fitting, uses an iterative algorithm to create a curve that reprices a number of instruments exactly. Each instrument, starting with the one with the shortest maturity, adds a node to the curve keeping the previous ones unchanged until the full curve is built.
When instruments need different curves, we can build them one by one, usually starting from the curve corresponding to the overnight index, e.g., SOFR in a US setting or ESTR in the Euro zone; this is because overnight-based instrument (usually futures and overnight-indexed swaps) only need that one curve, while other instruments need the overnight curve for discounting as well as other curves for forecasting.
In this notebook, I will provide a few short examples. However, there are lots of details that can be added; for a review of those (including, for instance, the possible addition of end-of-year jumps and synthetic instruments) you can read Ametrano and Bianchetti, Everything You Always Wanted to Know About Multiple Interest Rate Curve Bootstrapping but Were Afraid to Ask. A much longer notebook reproducing in Python the calculations in that paper is available on my blog.
Finally, Implementing QuantLib describes in detail the implementation of the algorithm, including the template machinery that makes it possible in C++ code to choose whether to interpolate discount factors, zero rates or instantaneous forwards, as well as to choose the kind of interpolation to be used, by declaring curves such as PiecewiseYieldCurve<Discount, LogLinear>
, PiecewiseYieldCurve<ZeroYield, Linear>
or PiecewiseYieldCurve<ForwardRate, BackwardFlat>
.
And now, the examples.
Risk-free curves
These curves are used to forecast risk-free rates such as SOFR, SONIA or ESTR, as well as for risk-free discounting. In this case, I’ll use SOFR; of course, we have a class for that.
= ql.Sofr() index
As I mentioned, the curve is built based on different kinds of instruments. The library defines helper classes corresponding to each of them, each instance acting as an objective function for the solver underlying the bootstrapping process.
For futures, the quote to reproduce is a price…
= [
futures_data 11, 2021, ql.Monthly), 99.934),
((12, 2021, ql.Monthly), 99.922),
((1, 2022, ql.Monthly), 99.914),
((2, 2022, ql.Monthly), 99.919),
((3, 2022, ql.Quarterly), 99.876),
((6, 2022, ql.Quarterly), 99.799),
((9, 2022, ql.Quarterly), 99.626),
((12, 2022, ql.Quarterly), 99.443),
(( ]
…while for OIS, the quote is their fixed rate. In the list below, that shows quotes as they might come from some provider, the rates are in percent unit (that is, the 10-years rate is 1.359%); the library will require them in decimal units (i.e., 0.01359 for the same rate.)
= [
ois_data "1Y", 0.137),
("2Y", 0.409),
("3Y", 0.674),
("5Y", 1.004),
("8Y", 1.258),
("10Y", 1.359),
("12Y", 1.420),
("15Y", 1.509),
("20Y", 1.574),
("25Y", 1.586),
("30Y", 1.579),
("35Y", 1.559),
("40Y", 1.514),
("45Y", 1.446),
("50Y", 1.425),
( ]
Quotes are usually wrapped in SimpleQuote
instances, so that they can be changed later; when this happens, the curve will then be notified and recalculate when needed. Once wrapped, they are passed to the helpers.
= []
futures_helpers
for (month, year, frequency), price in futures_data:
= ql.SimpleQuote(price)
q
futures_helpers.append(
ql.SofrFutureRateHelper(ql.QuoteHandle(q), month, year, frequency) )
The above uses a specific helper, SofrFutureRateHelper
, which knows how to calculate the relevant dates of SOFR futures given their month, year and frequency. It also knows that SOFR fixings are compounded in quarterly futures and averaged in monthly ones. For other indexes, for which no specific helper is available, it’s possible to use the OvernightIndexFutureRateHelper
class instead; its constructor requires you to do a bit more work and pass it the start and end date of the underlying fixing period, as well as the averaging method to use.
The helper to use for OIS quotes is OISRateHelper
; note that, as mentioned, the OIS rate is passed in decimal units, that is, 0.05 if the rate is 5%. In this case, I’m also saving the quotes in a map besides passing them to the helpers, so that I can access them more easily later on.
= 2
settlement_days
= {}
ois_quotes = []
ois_helpers
for tenor, quote in ois_data:
= ql.SimpleQuote(quote / 100.0)
q = q
ois_quotes[tenor]
ois_helpers.append(
ql.OISRateHelper(
settlement_days,
ql.Period(tenor),
ql.QuoteHandle(q),
index,=ql.Annual,
paymentFrequency
) )
Another thing to note is that the ranges of futures and OIS can overlap; for instance, in this case, the maturity of the last futures is later than the maturity of the first OIS.
-1].latestDate() futures_helpers[
Date(15,3,2023)
0].latestDate() ois_helpers[
Date(27,10,2022)
In a real set of data, you would also have OIS quotes for maturities below the year. Using all the available helpers might cause collisions between maturities (which, in turn, would cause the bootstrap to fail); and even if that doesn’t happen, having nodes too close might result in unrealistic forwards between them. You’ll have to decide what to do: one possibility is to use only futures in the short range (since they’re usually more liquid) and start using swaps after the maturity of the last futures, possibly with a bit of buffer space, as in the cell below:
= futures_helpers[-1].latestDate() + ql.Period(1, ql.Months)
cutoff_date = [h for h in ois_helpers if h.latestDate() > cutoff_date] ois_helpers
Once the helpers are built and chosen, creating the curve is straightforward. There are a number of Python classes available (such as PiecewiseLogCubicDiscount
, PiecewiseFlatForward
, PiecewiseLinearZero
and others), all wrapping the same underlying C++ class template with different arguments.
Creating the class doesn’t trigger bootstrapping, so the fact the the constructor succeeds doesn’t guarantee that the curve will work. The boostrapping code runs as soon as we call one of the methods of the curve to retrieve its results.
= ql.PiecewiseLogCubicDiscount(
sofr_curve 0,
ql.UnitedStates(ql.UnitedStates.GovernmentBond),+ ois_helpers,
futures_helpers
ql.Actual360(), )
Depending on the chosen class, the nodes between which the curve interpolates will contain discount factors (as in this case), zero rates, or instantaneous forward rates.
sofr_curve.nodes()
((Date(25,10,2021), 1.0),
(Date(1,12,2021), 0.9999328324822494),
(Date(1,1,2022), 0.9998656835848434),
(Date(1,2,2022), 0.9997916300517078),
(Date(1,3,2022), 0.9997286453100818),
(Date(15,6,2022), 0.9993777845072249),
(Date(21,9,2022), 0.998831257337585),
(Date(21,12,2022), 0.9978878652374324),
(Date(15,3,2023), 0.9965926270198491),
(Date(27,10,2023), 0.9917433028872656),
(Date(28,10,2024), 0.9796645292460902),
(Date(27,10,2026), 0.9502361425832937),
(Date(29,10,2029), 0.9024046595432171),
(Date(27,10,2031), 0.8704312474038445),
(Date(27,10,2033), 0.8401724001713865),
(Date(27,10,2036), 0.7930022596406051),
(Date(28,10,2041), 0.724132683846159),
(Date(29,10,2046), 0.666582385788118),
(Date(27,10,2051), 0.6171451236230561),
(Date(27,10,2056), 0.5752114363865589),
(Date(27,10,2061), 0.5447980541075726),
(Date(27,10,2066), 0.5260997308046376),
(Date(27,10,2071), 0.4961968121847855))
And just for kicks, we can visualize the curve we just created:
from matplotlib import pyplot as plt
from matplotlib.ticker import FuncFormatter
import numpy as np
= plt.figure(figsize=(9, 4)).add_subplot(1, 1, 1)
ax lambda r, pos: f"{r:.2%}"))
ax.yaxis.set_major_formatter(FuncFormatter(= np.linspace(0.0, sofr_curve.maxTime(), 2500)
times = [sofr_curve.zeroRate(t, ql.Continuous).rate() for t in times]
rates ; ax.plot(times, rates)
If you kept hold of the quotes like I did above, it’s also possible to modify any or all of them when market quotes change, or when you want to see the effect of a given change (more on this in another notebook.)
= ois_quotes["5Y"].value()
current_5y_value "5Y"].setValue(current_5y_value + 0.0010)
ois_quotes[
= [sofr_curve.zeroRate(t, ql.Continuous).rate() for t in times]
new_rates
"5Y"].setValue(current_5y_value) ois_quotes[
Here is the modified curve superimposed to the old one:
= plt.figure(figsize=(9, 4)).add_subplot(1, 1, 1)
ax lambda r, pos: f"{r:.2%}"))
ax.yaxis.set_major_formatter(FuncFormatter(; ax.plot(times, rates, times, new_rates)
LIBOR curves
Actual LIBOR indexes were discountinued a while ago, but similar indexes such as Euribor remains; IBOR indexes, in library lingo. IBOR curves can be bootstrapped using quoted instruments based on the corresponding index; for instance, vanilla fixed-vs-floater swap.
= [
swap_data "1y"), 0.417),
(ql.Period("18m"), 0.662),
(ql.Period("2y"), 0.863),
(ql.Period("3y"), 1.185),
(ql.Period("5y"), 1.443),
(ql.Period("7y"), 1.606),
(ql.Period("10y"), 1.711),
(ql.Period("12y"), 1.742),
(ql.Period("15y"), 1.804),
(ql.Period("20y"), 1.856),
(ql.Period("25y"), 1.896),
(ql.Period("30y"), 1.932),
(ql.Period("50y"), 1.924),
(ql.Period( ]
The vanilla-swap helpers use the curve being bootstrapped to forecast IBOR fixings, but according to current practice they take an external discount curve; here, in the interest of brevity, we’ll pretend that the SOFR curve we built above was really an ESTR curve. Of course, we should instead create a new one with the correct index, calendar and whatnot.
= sofr_curve estr_curve
Onwards with the helpers. Vanilla swaps use the 6-months Euribor, so that’s the forecast curve we will bootstrap.
= 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
= ql.YieldTermStructureHandle(estr_curve)
discount_handle
= [
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 swap_data
]
The curve is built in the same way as the previous one:
= ql.PiecewiseLinearZero(
euribor_6m_curve 0, ql.TARGET(), helpers, ql.Actual360()
)
Again, we can look at its nodes (which, this time, contain zero rates…)
euribor_6m_curve.nodes()
((Date(25,10,2021), 0.004106452138928857),
(Date(27,10,2022), 0.004106452138928857),
(Date(27,4,2023), 0.006512076444139096),
(Date(27,10,2023), 0.00847857348305559),
(Date(28,10,2024), 0.01162596400683961),
(Date(27,10,2026), 0.014192245098651954),
(Date(27,10,2028), 0.01581512271598231),
(Date(27,10,2031), 0.016867161885317523),
(Date(27,10,2033), 0.017169256900638388),
(Date(27,10,2036), 0.017809443151813677),
(Date(28,10,2041), 0.01834390600999102),
(Date(29,10,2046), 0.018769304479864753),
(Date(27,10,2051), 0.01917190117115746),
(Date(27,10,2071), 0.018960704667728895))
…and plot it, together with the ESTR curve:
= plt.figure(figsize=(9, 4)).add_subplot(1, 1, 1)
ax lambda r, pos: f"{r:.2%}"))
ax.yaxis.set_major_formatter(FuncFormatter(= [
euribor_rates for t in times
euribor_6m_curve.zeroRate(t, ql.Continuous).rate()
]; ax.plot(times, rates, times, euribor_rates)
Quoted basis swaps
It is also possible to bootstrap a curve over quoted instruments such as OIS-vs-LIBOR swaps. For instance, we might have a set of quotes like this, in which we’re given the fair basis between the ESTR and Euribor legs in swaps with different maturities:
= [
basis_data "1y"), 0.223),
(ql.Period("18m"), 0.237),
(ql.Period("2y"), 0.249),
(ql.Period("3y"), 0.266),
(ql.Period("5y"), 0.271),
(ql.Period("7y"), 0.290),
(ql.Period("10y"), 0.308),
(ql.Period("12y"), 0.309),
(ql.Period("15y"), 0.315),
(ql.Period("20y"), 0.321),
(ql.Period("25y"), 0.334),
(ql.Period("30y"), 0.341),
(ql.Period("50y"), 0.355),
(ql.Period( ]
In this case, we need to pass to the helpers (among other parameters) the ESTR index for the first leg, which needs to contain a forecast handle set to the ESTR curve…
= ql.Estr(ql.YieldTermStructureHandle(estr_curve)) estr
…and the Euribor index whose curve we want to bootstrap:
= ql.Euribor(ql.Period(3, ql.Months)) euribor3m
As before, we’ll build a helper for each quote. Some of the parameters are inferred from the passed indexes; for instance, the frequency of the payments (taken to be the same as the tenor of the LIBOR) and the day-count conventions, also taken for each coupon from the corresponding index. It is also possible to pass a discount curve, but if not, it is assumed to be the curve set to the overnight index.
= 2
settlement_days = ql.TARGET()
calendar = ql.ModifiedFollowing
convention = False
end_of_month
= [
helpers
ql.OvernightIborBasisSwapRateHelper(/ 100.0)),
ql.QuoteHandle(ql.SimpleQuote(quote
tenor,
settlement_days,
calendar,
convention,
end_of_month,
estr,
euribor3m,
)for tenor, quote in basis_data
]
The curve is built as before:
= ql.PiecewiseLogCubicDiscount(
euribor_3m_curve 0, ql.TARGET(), helpers, ql.Actual360()
)
= plt.figure(figsize=(9, 4)).add_subplot(1, 1, 1)
ax lambda r, pos: f"{r:.2%}"))
ax.yaxis.set_major_formatter(FuncFormatter(= [
euribor_rates for t in times
euribor_3m_curve.zeroRate(t, ql.Continuous).rate()
]; ax.plot(times, rates, times, euribor_rates)
It is also possible to bootstrap a curve over a mix of vanilla and basis swaps, provided that they’re based on the same IBOR index.
Swap pricing
Once the relevant curves are built, it’s possible to price swaps; for instance, a fixed-vs-Euribor swap. First, we build the Euribor index and pass it the corresponding forecast curve:
= ql.YieldTermStructureHandle(euribor_6m_curve)
forecast_handle
= ql.Euribor(ql.Period(3, ql.Months), forecast_handle) euribor6m
Then we build the swap, passing the conventions and terms it needs:
= ql.Date(18, ql.November, 2021)
start_date = start_date + ql.Period(30, ql.Years)
end_date = ql.Period(ql.Annual)
fixed_coupon_tenor = ql.TARGET()
calendar = ql.DateGeneration.Forward
rule = ql.ModifiedFollowing
float_convention = False
end_of_month = 150 * bps
fixed_rate = 10 * bps
floater_spread
= ql.Schedule(
fixed_schedule
start_date,
end_date,
fixed_coupon_tenor,
calendar,
fixed_convention,
fixed_convention,
rule,
end_of_month,
)= ql.Schedule(
float_schedule
start_date,
end_date,
euribor6m.tenor(),
calendar,
float_convention,
float_convention,
rule,
end_of_month,
)= ql.VanillaSwap(
swap
ql.Swap.Payer,1_000_000,
fixed_schedule,
fixed_rate,
fixed_day_count,
float_schedule,
euribor6m,
floater_spread,
euribor6m.dayCounter(), )
Finally, we tell its pricing engine to use the ESTR curve for discounting:
swap.setPricingEngine(ql.DiscountingSwapEngine(discount_handle))
We can now ask the swap for its value, or other figures:
swap.NPV()
128295.50732521049
swap.fairRate()
0.02037361224452942