Building FX discount curve with QuantLib
This version: 2024-03-13
First version: 2023-02-18
This article explains how to build FX discount curve(s) using QuantLib. This is a critical component for valuation of trades in collateralized pools as the currency of the collateral does impact the discount curves.
1 The discounting problem
In the current multi-curve world, the standard way of valuing collateralized derivatives is by discounting them using the collateral rate curve such as OIS-based curve.
This is straightforward if we assume a single-currency trade portfolio collateralized in the same currency. For example, if all trades in EUR-collateralized pool are EUR-denominated, we would only need to build a single EUR discounting curve from the overnight deposit and OIS rates (referencing €STR).
However, things get more complicated when we are in a multi–currency world. For example, consider EUR-collateralized pool with USD-denominated trades. Then discounting USD cash flow using EUR curve boostrapped in the paragraph above is not right due to an obvious currency mismatch but at the same time the seemingly better option of discounting this USD cash flow using USD risk-free curve (bootstrapped e.g. from USD OIS rates) and converting to EUR using EURUSD spot FX rate is also not right.
To explain why even the ’better’ option isn’t right, consider again EUR-collateralized pool containing just one trade having a single USD cash flow to be received at \(T\). To value this EUR-collateralized USD cash flow we should in theory be indifferent between:
-
1. (discount first, FX convert later method): discount this USD cash flow using USD SOFR-based curve and convert it to EUR using FX spot, and,
-
2. (FX convert first, discount later method): convert USD to the collateral currency EUR using FX forward and then discount using EUR-based curve.
In an ideal world with no cross-currency spreads, both approaches would lead to identical valuations. However, since in the real market cross-currency (xccy) spreads exist, there is in fact a difference between the two methods. This is because the market incorporates the xccy spread in the FX forward quote, which is correctly used in (2.). In the case (1.) the xccy spread is nowhere taken into account and hence it is, generally, an incorrect way of valuing the cash-flow.
So now that we know that the approach (2.) is right, why don’t we just use it? First of all, it isn’t convenient, as we would need to change valuation formulas, secondly the FX forward quotes usually span just shorter horizons but won’t extend to, say, 10 years – where xccy spreads are contained in quotes of xccy swaps.
The market has thus developed a third method which gives results similar to (2.) but doesn’t have the ’inconvenient’ elements: to value USD cash-flow using USD-collateralized-in-EUR curve. We can best imagine this curve as a modified USD curve that – when used for discounting in the (1.) method (discount first, FX convert later) instead of the USD SOFR curve – gives the same value as the correct method (2.). This modified USD curve already incorporates the xccy spreads and will value the trade consistently with the IR&FX markets.
Below we demonstrate how this modified USD curve can be bootstrapped in QuantLib from FX forward and xccy swaps.
2 Worked-out example in QuantLib
Setup
We assume the following setup. Our portfolio of interest is a EUR-collateralized pool which contains USD trades.
The inputs known are:
-
• flat 1% EUR curve (based on €STR),
-
• flat 1.5% USD curve (based on SOFR),
-
• FX spot EURUSD 1.2,
-
• set of EURUSD FX forward quotes,
-
• set of quotes of xccy swaps which exchange (€STR + EUR spread) for SOFR.
And we are searching for such a USD-collateralized-in-EUR curve that renders the forwards and xccy swaps fair, should the USD legs of these trades be discounted by this curve. This curve is then the right curve to discount the USD cash flow in EUR collateralized
pool.
QuantLib steps
To extract the USD-collateralized-in-EUR curve using QuantLib, we need to take the following steps:
-
• define observed curves, FX fwd and xccy quotes,
-
• construct QuantLib objects called helpers for FX fwd and xccy quotes. QuantLib can only extract curves from helpers,
-
• bootstrap USD-collateralized-in-EUR from the helpers.
Here, the second step with the helpers is the core component. Helpers in QuantLib are trade objects that contain known trade info (FX fwd quotes / xccy spreads, EUR discount curve, forecasting curve), and the only element that we leave undefined is the USD discounting curve (which in our case is the USD-collateralized-in-EUR).
In the third step we the ask QuantLib to find such a USD-collateralized-in-EUR curve that when used to discount the USD legs of the helper trades, renders these fair, i.e. zero-valued.
Step 1: input load
# basic imports import QuantLib as ql # I'm using QuantLib 1.29 in this example import pandas as pd import numpy as np import matplotlib.pyplot as plt date = ql.Date(18,8,2022) # set the eval date to 2022-08-18 ql.Settings.instance().evaluationDate = date eur_rate = 0.01 # flat zero continuously compounded rate obtained from €STR OIS curve usd_rate = 0.015 # flat zero continuously compounded rate obtained from SOFR OIS curve spot_fx = 1.2 # EURUSD (base currency = EUR) fwd_quotes = pd.DataFrame({'expiry': ['1W', '1M', '3M', '6M', '9M', '12M'], 'fx_fwd': [1.200187, 1.2008, 1.202402, 1.20481, 1.207222, 1.209639]}) xccy_quotes = pd.DataFrame({'expiry': ['2Y', '5Y', '10Y', '15Y', '20Y', '30Y'], 'EUR_spread':[-0.003014,-0.002981,-0.002926,-0.002871,-0.002819,-0.002721]}) print('Fwd quotes:') display(fwd_quotes) print('xccy quotes:') display(xccy_quotes)
This gives us the following quotes tables
Fwd quotes: | expiry | fx_fwd | |--------|----------| | 1W | 1.200187 | | 1M | 1.200800 | | 3M | 1.202402 | | 6M | 1.204810 | | 9M | 1.207222 | | 12M | 1.209639 | xccy quotes: | expiry | EUR_spread | |--------|------------| | 2Y | -0.0030140 | | 5Y | -0.0029810 | | 10Y | -0.0029260 | | 15Y | -0.0028710 | | 20Y | -0.0028190 | | 30Y | -0.0027210 |
The next step is purely technical and QuantLib-specific, as we convert input rates to ’flat curves’. Object of type ql.YieldTermStructureHandle is understood by QuantLib a true ’curve’.
# convert scalar rates to 'flat curves'
eur_curve = ql.YieldTermStructureHandle(
ql.FlatForward(date, ql.QuoteHandle(ql.SimpleQuote(eur_rate)),ql.SimpleDayCounter()))
usd_curve = ql.YieldTermStructureHandle(
ql.FlatForward(date, ql.QuoteHandle(ql.SimpleQuote(usd_rate)),ql.SimpleDayCounter()))
Step 2: set up the helpers
To extract the USD-collateralized-in-EUR curve for various maturities, we need helpers for both FX forwards (spanning short-end of the curve), and xccy swaps (spanning the mid- and long- end of the curve). The idea is to set up the helpers in a way that we provide all info that we know (market quotes FX fwd and xccy, EUR curve), and the only ’unknown’ remains the USD-collateralized-in-EUR curve – which if used to reprice the USD legs of the helper trades, would render these trades fair.
Note that QuantLib actually uses ql.FxSwapRateHelper as a helper for the FX forward quotes. The quotes are defined as forward points, each representing the difference between the forward and spot FX quote.
# build helpers to store fx forward trade quotes helpers_fwd = [] for tenor, fwd in fwd_quotes[['expiry', 'fx_fwd']].values: fwd_points = fwd - spot_fx helper = ql.FxSwapRateHelper(ql.QuoteHandle(ql.SimpleQuote(fwd_points)), ql.QuoteHandle(ql.SimpleQuote(spot_fx)), ql.Period(tenor), 0, ql.NullCalendar(), ql.Following, False, True, eur_curve) helpers_fwd.append(helper)
See that QuantLib in the FX domain often uses two terms:
-
• base currency; which in a pair EURUSD is EUR,
-
• quote currency; which in a pair EURUSD is USD.
The last boolean in ql.FxSwapRateHelper is for parameter isFxBaseCurrencyCollateralCurrency. In our case we set this boolean to True because the base currency EUR is also the collateral currency. The last parameter is the curve that is ’known’, in our case the EUR curve.
Very similar approach is used to set up the xccy helpers, where the ’quote’ is the xccy spread and we need to pay attention to instructing QuantLib about how to use it.
# build helpers to store xccy trade quotes baseCurrencyIndex = ql.Estr(eur_curve) # for forecasting EUR-leg cash-flow quoteCurrencyIndex = ql.Sofr(usd_curve) # for forecasting USD-leg cash-flow helpers_xccy = [] for tenor, eur_spread in xccy_quotes.values: basis = ql.QuoteHandle(ql.SimpleQuote(eur_spread)) helper = ql.ConstNotionalCrossCurrencyBasisSwapRateHelper(basis, ql.Period(tenor), 0, ql.NullCalendar(), ql.ModifiedFollowing, True, baseCurrencyIndex, quoteCurrencyIndex, eur_curve, True, True) helpers_xccy.append(helper) # Note: # in older versions of QuantLib there is ql.CrossCurrencyBasisSwapRateHelper # instead of ql.ConstNotionalCrossCurrencyBasisSwapRateHelper. This is a newer object as QuantLib # now also knows other type of xccy helper: ql.MtMCrossCurrencyBasisSwapRateHelper # which is for xccy with resettable notional
Note that the two last booleans in the ql.ConstNotionalCrossCurrencyBasisSwapRateHelper object are crucial. They stand for
-
• isFxBaseCurrencyCollateralCurrency = whether the base currency is the currency of the collateral (in our case base currency = EUR, and collateral = EUR, therefore True),
-
• isBasisOnFxBaseCurrencyLeg = this controls whether the xccy basis (margin / spread) is on the basis currency leg, or on the other leg. In our case, again, the basis is on EUR, which is our base currency, therefore we choose True.
Also see the two indices baseCurrencyIndex, quoteCurrencyIndex have been passed in to forecast the floating coupons in the xccy helper trade, as well as the eur_curve to discount the base currency (=EUR) cash-flow. Hence the only ’unknown’ left is the USD discount curve, i.e. USD-collateralized-in-EUR. Note that the forecasting curve for USD coupons isn’t impacted by our search for USD-collateralized-in-EUR curve, which is purely a discounting curve, and not used for forecasting cash-flow.
Step 3: curve bootstrap
Now that the helpers are well-defined we will combine them and pass them to a ql.Piecewise object which is used to bootstrap curve objects from helpers.
# bootstrap USD-collateralized-in-EUR curve from FX forward helpers and xccy helpers mod_usd_curve = ql.PiecewiseLogCubicDiscount(0,ql.NullCalendar(), helpers_fwd + helpers_xccy, ql.SimpleDayCounter()) # Note: here we used ql.PiecewiseLogCubicDiscount but is not the only # ql.Piecewise- object. Other options are possible.
Once the USD-collateralized-in-EUR has been bootstrapped, we can display it.
# plotting times = np.linspace(0.01, 30, 1000) usd_rates = [usd_curve.zeroRate(t, ql.Continuous).rate()*100 for t in times] eur_rates = [eur_curve.zeroRate(t, ql.Continuous).rate()*100 for t in times] mod_usd_rates = [mod_usd_curve.zeroRate(t, ql.Continuous).rate()*100 for t in times] fig, ax = plt.subplots(figsize = (5,3)) ax.plot(times, mod_usd_rates, label = 'USD-collateralized-in-EUR curve') ax.plot(times, eur_rates, label = 'EUR curve') ax.plot(times, usd_rates, label = 'USD curve') ax.grid(linestyle = ':') ax.legend(); ax.set_ylim([0.5, 2.5]); ax.set_xlabel('maturity (years)'); ax.set_ylabel('rate (%)'); plt.show();
This looks like a flat 1.8% USD-collateralized-in-EUR discounting curve. Indeed, that’s the case as for the simplicity of this exercise I created the input FX forward and xccy quotes assuming the USD-collateralized-in-EUR is a flat 1.8% curve.