Skip to content
All library documents

Cross-Currency Basis Curve Bootstrapping and Reference Dates in QuantLib

Article Quant Q&A · Author: Fei

Summary

The document investigates why a EUR/USD discount curve bootstrapped from a single cross-currency basis swap helper does not make a constructed EUR leg’s net present value zero. The example builds SOFR and €STR curves, creates a three-month basis quote, bootstraps a curve with QuantLib’s constant-notional cross-currency helper, then prices separate USD and EUR legs. The USD leg prices near zero on its own curve, while the EUR leg does not on the bootstrapped curve.

The answer attributes the discrepancy to inconsistent curve reference dates: the forward curves refer to today, whereas the cross-currency curve refers to the settlement date. It illustrates aligning the reference dates in a Libor-based example and notes that overnight-rate indices may also work. This is a practical calibration and curve setup lesson, but the supplied answer is an example rather than a complete diagnosis of every convention in the original overnight-rate setup. The quoted rates, dates, and resulting EUR valuation are specific to that illustration.

Key ideas

  • A cross-currency basis helper can produce unexpected leg valuations when curves use inconsistent reference dates.
  • The example builds separate overnight curves and bootstraps a EUR/USD curve from a basis quote.
  • The answer recommends making the forward and cross-currency curve reference dates consistent.
  • The follow-up example uses Libor indices and presents overnight indices as a possible extension.

Tags

Full text
# ConstNotionalCrossCurrencyBasisSwapRateHelper in QuantLib doesn't give expected result


# ConstNotionalCrossCurrencyBasisSwapRateHelper in QuantLib doesn't give expected result












I'm trying to bootstrap to get discount curve based on cross currency basis swap using ConstNotionalCrossCurrencyBasisSwapRateHelper in QuantLib. As a test, I tried to bootstrap the discount curve from a single cross currency basis swap but notice that the resulted discount curve is different from what I expected.

What I have done are:

- Create a sample SOFR curve as the usd ois curve

- Create a sample Estr curve as the eur ois curve

- Create a cross currency basis swap quote for EUR USD at 3M.

- Bootstrap a eur usd curve using ConstNotionalCrossCurrencyBasisSwapRateHelper

The tests are

- Create a 3 month usd_leg that is based on usd ois curve with notional payments. The spread is zero in this curve.

- Price the usd_leg using usd ois curve. As expected, the value is zero. Everything is good so far.

- Create a 3 month eur_leg that is based on eur ois curve with notional payments. The spread equals to the cross currency basis swap quote

- Price the eur_leg using the eur usd curve. I expect the value is zero. However, the result is not zero.

Can someone help?

I paste my tests code below.

```
# This example is based on https://www.implementingquantlib.com/2023/09/cross-currency-swaps.html
# I simplified the cross currency swaps to demonstrate the question
# I also changed the swaps to OIS based

import QuantLib as ql
import pandas as pd

today = ql.Date(27, ql.October, 2021)
ql.Settings.instance().evaluationDate = today

bps = 1e-4

# Sample data
sample_rates = pd.DataFrame(
    [
        (ql.Date(27, 10, 2021), 0.0229, 0.0893, -0.5490, -0.4869),
        (ql.Date(27, 1, 2022), 0.0645, 0.1059, -0.5584, -0.5057),
        (ql.Date(27, 4, 2022), 0.0414, 0.1602, -0.5480, -0.5236),
        (ql.Date(27, 10, 2022), 0.1630, 0.2601, -0.5656, -0.5030),
        (ql.Date(27, 10, 2023), 0.4639, 0.6281, -0.4365, -0.3468),
        (ql.Date(27, 10, 2024), 0.7187, 0.9270, -0.3500, -0.2490),
        (ql.Date(27, 10, 2025), 0.9056, 1.1257, -0.3041, -0.1590),
        (ql.Date(27, 10, 2026), 1.0673, 1.2821, -0.2340, -0.0732),
        (ql.Date(27, 10, 2027), 1.1615, 1.3978, -0.1690, -0.0331),
        (ql.Date(27, 10, 2028), 1.2326, 1.4643, -0.1041, 0.0346),
        (ql.Date(27, 10, 2029), 1.3050, 1.5589, -0.0070, 0.1263),
        (ql.Date(27, 10, 2030), 1.3584, 1.5986, 0.0272, 0.1832),
        (ql.Date(27, 10, 2031), 1.4023, 1.6488, 0.0744, 0.2599),
        (ql.Date(27, 10, 2036), 1.5657, 1.8136, 0.3011, 0.4406),
        (ql.Date(27, 10, 2041), 1.6191, 1.8749, 0.3882, 0.5331),
        (ql.Date(27, 10, 2046), 1.6199, 1.8701, 0.3762, 0.5225),
        (ql.Date(27, 10, 2051), 1.6208, 1.8496, 0.3401, 0.4926),
    ],
    columns=["date", "SOFR", "USDLibor3M", "EUR-USD-discount", "Estr"],
)

def sample_curve(tag):
    curve = ql.ZeroCurve(
        sample_rates["date"], sample_rates[tag] / 100, ql.Actual365Fixed()
    )
    return ql.YieldTermStructureHandle(curve)

# Construct basis curves
eur_ois_curve = sample_curve("Estr")
eur_ois = ql.Estr(eur_ois_curve)

usd_ois_curve = sample_curve("SOFR")
usd_ois = ql.Sofr(usd_ois_curve)

# Construct usd_leg for a simple 3 month cross currency swap.
# Spread for the usd_leg is set to zero so I expect the NPV of the usd_leg
# is 0 if use usdlibor3M_curve to discount.
notional = 1_000_000
fx_0 = 0.85

calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)
start_date = calendar.advance(today, ql.Period(2, ql.Days))
end_date = calendar.advance(start_date, ql.Period(3, ql.Months))
tenor = ql.Period(3, ql.Months)
rule = ql.DateGeneration.Forward
convention = ql.Following
end_of_month = False

schedule = ql.Schedule(
    start_date,
    end_date,
    tenor,
    calendar,
    convention,
    convention,
    rule,
    end_of_month,
)

usd_leg = (
    (ql.SimpleCashFlow(-notional, schedule[0]),)
    + ql.OvernightLeg(
        nominals=[notional],
        schedule=schedule,
        index=usd_ois,
    )
    + (ql.SimpleCashFlow(notional, schedule[-1]),)
)

usd_npv = ql.CashFlows.npv(usd_leg, usd_ois_curve, True)
print(usd_npv)
# usd_npv = 1.1641532182693481e-10

# usd_npv is zero. Everyting is good so far.

# Now I create the EUR-USD-discount using the ConstNotionalCrossCurrencyBasisSwapRateHelper
# For simplicity, I only include one ccbs quote that the base currency is USD and quote currency is EUR
# Collateral currency is USD and the quote is on EUR
quote = -0.03
ccbs_quotes = {'3M': quote}
day_counter = eur_ois.dayCounter()

ccbs_helpers = [ql.ConstNotionalCrossCurrencyBasisSwapRateHelper(
                        ql.QuoteHandle(ql.SimpleQuote(value)),
                        ql.Period(period),
                        2,
                        calendar,
                        convention,
                        end_of_month,
                        usd_ois,
                        eur_ois,
                        usd_ois_curve,
                        True,
                        False)
                for period, value in ccbs_quotes.items()]

eur_usd_curve = ql.YieldTermStructureHandle(
                    ql.PiecewiseLogLinearDiscount(2, calendar, ccbs_helpers, day_counter))

# Construct eur_leg with spread = ccbs quote. I expect the NPV of eur_leg zero using eur_usd_curve to discount
eur_leg = (
    (ql.SimpleCashFlow(-notional * fx_0, schedule[0]),)
    + ql.OvernightLeg(
        nominals=[notional * fx_0],
        schedule=schedule,
        index=eur_ois,
        spreads=[quote]
    )
    + (ql.SimpleCashFlow(notional * fx_0, schedule[-1]),)
)

eur_npv = ql.CashFlows.npv(eur_leg, eur_usd_curve, True, start_date)
print(eur_npv)
# eur_npv = -35.281226538005285

# eur_npv is not zero. What is wrong in the example above?
```

## Answer by user35980 (score 1)

https://quant.stackexchange.com/a/78468

I think the issue is coming from your curve reference dates. The fwd curves you're using reference to today while your xccy curve references to the sett date. You can change this by specifying consistent curve reference dates. I've illustrated this using libors below (cause it's easier and what the xccy helpers were designed for), but RFRs should also work (according to this answer anyway).

```
import QuantLib as ql

today = ql.Date(27, ql.October, 2021)
ql.Settings.instance().evaluationDate = today

calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)
euribor3M_curve = ql.YieldTermStructureHandle(ql.FlatForward(2,calendar,0.06,ql.Actual360()))
euribor3M = ql.Euribor(ql.Period(3, ql.Months), euribor3M_curve)
usdlibor3M_curve = ql.YieldTermStructureHandle(ql.FlatForward(2,calendar,0.02,ql.Actual365Fixed()))
usdlibor3M = ql.USDLibor(ql.Period(3, ql.Months), usdlibor3M_curve)
notional = 1_000_000
fx_0 = 0.85
start_date = calendar.advance(today, ql.Period(2, ql.Days))
end_date = calendar.advance(start_date, ql.Period(3, ql.Months))
tenor = ql.Period(3, ql.Months)
rule = ql.DateGeneration.Forward
convention = ql.Following
end_of_month = False
schedule = ql.Schedule(start_date,end_date,tenor,calendar,convention,convention,rule,end_of_month)
usd_leg = ((ql.SimpleCashFlow(-notional, schedule[0]),)+ ql.IborLeg(nominals=[notional],schedule=schedule,index=usdlibor3M,)
           + (ql.SimpleCashFlow(notional, schedule[-1]),))
usd_npv = ql.CashFlows.npv(usd_leg, usdlibor3M_curve, True,start_date)
print(usd_npv)

quote = -0.04
ccbs_quotes = {'3M': quote}
day_counter = euribor3M_curve.dayCounter()
ccbs_helpers = [ql.ConstNotionalCrossCurrencyBasisSwapRateHelper(
                        ql.QuoteHandle(ql.SimpleQuote(value)),
                        ql.Period(period),
                        2,
                        calendar,
                        convention,
                        end_of_month,
                        usdlibor3M,
                        euribor3M,
                        usdlibor3M_curve,
                        True,
                        False)
                for period, value in ccbs_quotes.items()]

eur_usd_curve = ql.YieldTermStructureHandle(ql.PiecewiseLogLinearDiscount(2, calendar, ccbs_helpers, day_counter))
eur_leg = ((ql.SimpleCashFlow(-notional * fx_0, schedule[0]),)+ ql.IborLeg(nominals=[notional * fx_0],schedule=schedule,index=euribor3M,spreads=[quote])
           + (ql.SimpleCashFlow(notional * fx_0, schedule[-1]),))
eur_npv = ql.CashFlows.npv(eur_leg,eur_usd_curve, True,start_date)
print(eur_npv)

eur_leg_euribor = ((ql.SimpleCashFlow(-notional * fx_0, schedule[0]),)+ ql.IborLeg(nominals=[notional * fx_0],schedule=schedule,index=euribor3M,spreads=[0])
           + (ql.SimpleCashFlow(notional * fx_0, schedule[-1]),))
eur_npv_euribor = ql.CashFlows.npv(eur_leg_euribor,euribor3M_curve, True,start_date)
print(eur_npv_euribor)
```

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.