import QuantLib as ql
import pandas as pd
= ql.Date(27, ql.April, 2025)
today = today ql.Settings.instance().evaluationDate
Cross-currency curve bootstrapping
Cross-currency curves can be bootstrapped over a set of quotes for the relevant instruments; mostly, cross-currency swaps.
In this case, the bootstrap process also requires a few other interest-rate curves to be available. For the purposes of this notebook, we’ll use pre-built curves; the data frame below holds made-up zero rates from a few different curves at a number of nodes…
= pd.DataFrame(
sample_rates
[27, 10, 2021), 2.0229, 1.4510, 1.5131, 2.6893),
(ql.Date(27, 1, 2022), 2.0645, 1.4416, 1.4943, 2.6919),
(ql.Date(27, 4, 2022), 2.0414, 1.4520, 1.4764, 2.7306),
(ql.Date(27, 10, 2022), 2.1630, 1.4344, 1.4969, 2.8107),
(ql.Date(27, 10, 2023), 2.4638, 1.5635, 1.6532, 3.1354),
(ql.Date(27, 10, 2024), 2.7187, 1.6500, 1.7510, 3.3850),
(ql.Date(27, 10, 2025), 2.9055, 1.6959, 1.8410, 3.5358),
(ql.Date(27, 10, 2026), 3.0673, 1.7660, 1.9268, 3.6438),
(ql.Date(27, 10, 2027), 3.1615, 1.8310, 1.9669, 3.7127),
(ql.Date(27, 10, 2028), 3.2325, 1.8959, 2.0346, 3.7374),
(ql.Date(27, 10, 2029), 3.3049, 1.9930, 2.1263, 3.7842),
(ql.Date(27, 10, 2030), 3.3584, 2.0272, 2.1832, 3.7844),
(ql.Date(27, 10, 2031), 3.4023, 2.0744, 2.2599, 3.7927),
(ql.Date(27, 10, 2036), 3.5657, 2.3011, 2.4406, 3.8902),
(ql.Date(27, 10, 2041), 3.6191, 2.3882, 2.5331, 3.9043),
(ql.Date(27, 10, 2046), 3.6199, 2.3762, 2.5225, 3.8677),
(ql.Date(27, 10, 2051), 3.6208, 2.3401, 2.4926, 3.8204),
(ql.Date(
],=[
columns"date",
"SOFR",
"ESTR",
"Euribor3M",
"CORRA",
], )
…and this helper function uses them to create corresponding interest-rate curves. Depending on the context, these curves will be used for forecasting, discounting or both.
def sample_curve(tag):
= ql.ZeroCurve(
curve "date"], sample_rates[tag] / 100, ql.Actual365Fixed()
sample_rates[
)return ql.YieldTermStructureHandle(curve)
= sample_curve("SOFR")
sofr_curve = sample_curve("ESTR")
estr_curve = sample_curve("Euribor3M")
euribor_curve = sample_curve("CORRA") corra_curve
A common case
As for other curves we have already seen, the process consists of creating a set of bootstrap helpers, each modeling one of the quoted instruments, and passing them to the constructor of one of the available piecewise curves.
We’ll bootstrap over quoted swaps paying compounded SOFR and receiving 3-months Euribor. Thus, we need to create instances of the corresponding indexes and associate them with their respective forecast curves.
= ql.Sofr(sofr_curve)
sofr = ql.Euribor3M(euribor_curve) euribor
The swaps are quoted as a basis over Euribor: here is a made-up set of such quotes for different maturities.
= [
basis_quotes 1, ql.Years), -14.5),
(ql.Period(18, ql.Months), -18.5),
(ql.Period(2, ql.Years), -20.5),
(ql.Period(3, ql.Years), -23.75),
(ql.Period(4, ql.Years), -25.5),
(ql.Period(5, ql.Years), -26.5),
(ql.Period(7, ql.Years), -26.75),
(ql.Period(10, ql.Years), -26.25),
(ql.Period(15, ql.Years), -24.75),
(ql.Period(20, ql.Years), -23.25),
(ql.Period(25, ql.Years), -20.50),
(ql.Period( ]
Building the curve requires us to specify some familiar conventions…
= 2
fixing_days = ql.TARGET()
calendar = ql.Following
convention = True end_of_month
…as well as some less common ones which are specific to cross-currency swap helpers.
To begin with, we define one of the indexes (in this example, SOFR) as the “base” index; the other conventions are specified depending on this choice. In this case, we want to bootstrap a curve for discounting EUR cash flows when the collateral is in USD. This means we already know the collateral curve used for discounting USD cash flows, namely, the SOFR curve. The other parameters we’re passing to the helpers specify that the currency of the collateral is the one that corresponds to the base index, that the quoted basis is not applied to the base index but to the other, and that the base-index leg is not the one that resets its notionals.
= sofr
base_index = euribor
other_index = sofr_curve
collateral_curve = True
base_is_collateral = False
basis_on_base = False
base_resets = ql.Quarterly
frequency
= [
helpers
ql.MtMCrossCurrencyBasisSwapRateHelper(/ 10000),
ql.makeQuoteHandle(basis
tenor,
fixing_days,
calendar,
convention,
end_of_month,
base_index,
other_index,
collateral_curve,
base_is_collateral,
basis_on_base,
base_resets,
frequency,
)for tenor, basis in basis_quotes
]
Now we can finally create the curve and inspect its nodes:
= ql.PiecewiseLinearZero(
discount_curve
today, helpers, ql.Actual365Fixed() )
pd.DataFrame(=["date", "rate"]
discount_curve.nodes(), columnsformat({"rate": "{:.4%}"}) ).style.
date | rate | |
---|---|---|
0 | April 27th, 2025 | 2.0462% |
1 | April 30th, 2026 | 2.0462% |
2 | October 30th, 2026 | 2.0446% |
3 | April 30th, 2027 | 2.0033% |
4 | May 2nd, 2028 | 1.9990% |
5 | April 30th, 2029 | 2.0702% |
6 | April 30th, 2030 | 2.1363% |
7 | April 30th, 2032 | 2.2478% |
8 | April 30th, 2035 | 2.3280% |
9 | April 30th, 2040 | 2.4262% |
10 | May 2nd, 2045 | 2.4299% |
11 | May 2nd, 2050 | 2.4168% |
What? No FX rate?
You might have noticed that the current EUR/USD rate was not among the inputs of the calculation. Shouldn’t it matter?
Well, yes, if you’re calculating the value of the swaps. But during the bootstrap, what we’re calculating and matching for each swap is the fair basis between the floating rates. If you work out the math, you’ll see that the current FX rate cancels out.
An alternate view
The same swap can be seen from the other side if we swap the role of the indexes and choose Euribor as the base index. Note that the collateral remains in USD, and therefore the collateral curve remains the same and the boolean parameters (referring now to Euribor) are inverted; the base index is not in the collateral currency, the basis is on the base index, and the base-index leg resets.
= euribor
base_index = sofr
other_index = sofr_curve
collateral_curve = False
base_is_collateral = True
basis_on_base = True
base_resets = ql.Quarterly
frequency
= [
helpers
ql.MtMCrossCurrencyBasisSwapRateHelper(/ 10000),
ql.makeQuoteHandle(basis
tenor,
fixing_days,
calendar,
convention,
end_of_month,
base_index,
other_index,
collateral_curve,
base_is_collateral,
basis_on_base,
base_resets,
frequency,
)for tenor, basis in basis_quotes
]
The resulting curve, as expected, is the same.
= ql.PiecewiseLinearZero(
discount_curve
today, helpers, ql.Actual365Fixed() )
pd.DataFrame(=["date", "rate"]
discount_curve.nodes(), columnsformat({"rate": "{:.4%}"}) ).style.
date | rate | |
---|---|---|
0 | April 27th, 2025 | 2.0462% |
1 | April 30th, 2026 | 2.0462% |
2 | October 30th, 2026 | 2.0446% |
3 | April 30th, 2027 | 2.0033% |
4 | May 2nd, 2028 | 1.9990% |
5 | April 30th, 2029 | 2.0702% |
6 | April 30th, 2030 | 2.1363% |
7 | April 30th, 2032 | 2.2478% |
8 | April 30th, 2035 | 2.3280% |
9 | April 30th, 2040 | 2.4262% |
10 | May 2nd, 2045 | 2.4299% |
11 | May 2nd, 2050 | 2.4168% |
A slightly more complex case
The previous case involved two known curves (namely, the forecast curves for SOFR and 3-months Euribor) as well as the curve being bootstrapped. The SOFR curve was also used for discounting USD cash flows.
Let’s now say that we want to bootstrap over EUR/CAD cross-currency swaps paying 3-months Euribor vs CORRA, with the collateral being in EUR. Besides the curve being bootstrapped, this is going to require three different known curves: the forecast curves for Euribor and CORRA, and the ESTR curve used for discounting EUR cash flows.
= ql.Euribor3M(euribor_curve)
euribor = ql.Corra(corra_curve) corra
We’ll choose Euribor as the base index and set the other parameters accordingly; as I mentioned, the collateral curve will be the ESTR curve. For brevity I’ll use the same set of basis quotes as before, and I’ll assume they’re added on Tibor.
= euribor
base_index = corra
other_index = estr_curve
collateral_curve = True
base_is_collateral = False
basis_on_base = False
base_resets = ql.Quarterly
frequency
= [
helpers
ql.MtMCrossCurrencyBasisSwapRateHelper(/ 10000),
ql.makeQuoteHandle(basis
tenor,
fixing_days,
calendar,
convention,
end_of_month,
base_index,
other_index,
collateral_curve,
base_is_collateral,
basis_on_base,
base_resets,
frequency,
)for tenor, basis in basis_quotes
]
Here is the resulting curve:
= ql.PiecewiseLinearZero(
discount_curve
today, helpers, ql.Actual365Fixed() )
pd.DataFrame(=["date", "rate"]
discount_curve.nodes(), columnsformat({"rate": "{:.4%}"}) ).style.
date | rate | |
---|---|---|
0 | April 27th, 2025 | 3.6426% |
1 | April 30th, 2026 | 3.6426% |
2 | October 30th, 2026 | 3.6407% |
3 | April 30th, 2027 | 3.6645% |
4 | May 2nd, 2028 | 3.6399% |
5 | April 30th, 2029 | 3.6201% |
6 | April 30th, 2030 | 3.5851% |
7 | April 30th, 2032 | 3.4957% |
8 | April 30th, 2035 | 3.5733% |
9 | April 30th, 2040 | 3.6095% |
10 | May 2nd, 2045 | 3.5750% |
11 | May 2nd, 2050 | 3.5402% |