# 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:

1. Create a sample SOFR curve as the usd ois curve
2. Create a sample Estr curve as the eur ois curve
3. Create a cross currency basis swap quote for EUR USD at 3M.
4. Bootstrap a eur usd curve using ConstNotionalCrossCurrencyBasisSwapRateHelper

The tests are

1. Create a 3 month usd_leg that is based on usd ois curve with notional payments. The spread is zero in this curve.
2. Price the usd_leg using usd ois curve. As expected, the value is zero. Everything is good so far.
3. 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
4. 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)
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,
)
+ (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?


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