import QuantLib as ql
import pandas as pd
= ql.Date(27, ql.October, 2021)
today = today
ql.Settings.instance().evaluationDate
= 1e-4 bps
Cross-currency swaps
At this time, there’s no instrument class in the library modeling cross-currency swaps. However, it’s possible to calculate their value by working with cashflows. Here is a short example.
Sample data
For the purposes of this notebook, we’ll use mock rates. 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), 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),
(ql.Date(
],=[
columns"date",
"SOFR",
"USDLibor3M",
"EUR-USD-discount",
"Euribor3M",
], )
…and this helper function uses them to create an interest-rate curve. Depending on the context, curves will be used for either forecasting or discounting.
def sample_curve(tag):
= ql.ZeroCurve(
curve "date"], sample_rates[tag] / 100, ql.Actual365Fixed()
sample_rates[
)return ql.YieldTermStructureHandle(curve)
Const-notional cross-currency swaps
The first kind of cross-currency swaps we’ll model is less common but simpler. The notionals are exchanged at the beginning (where they have the same value, given the exchange rate at that time) and again at the end. There is no rebalancing during the life of the swap. All the coupons in either leg have the same notional in the leg’s payment currency.
In this example we’ll model a 5-years swap paying quarterly coupons, based on 3M Euribor on one leg and 3M USD Libor on the other. We start by creating the indexes and their forecasting curves.
= sample_curve("Euribor3M")
euribor3M_curve = ql.Euribor(ql.Period(3, ql.Months), euribor3M_curve) euribor3M
= sample_curve("USDLibor3M")
usdlibor3M_curve = ql.USDLibor(ql.Period(3, ql.Months), usdlibor3M_curve) usdlibor3M
For each of the currencies, we create a corresponding sequence of cashflows, including the notional exchanges (which, unlike for vanilla swaps, don’t cancel out—at least at maturity). The reference notional will be in dollars, converted in EUR at the present exchange rate (also made up). Just for kicks, we’ll also add a spread to the USD leg.
= 1_000_000
notional = 0.85
fx_0 = 50.0 * bps spread
As I mentioned, the swaps make quarterly payments. The corresponding schedule starts spot and ends in five years.
= ql.UnitedStates(ql.UnitedStates.FederalReserve)
calendar = calendar.advance(today, ql.Period(2, ql.Days))
start_date = calendar.advance(start_date, ql.Period(5, ql.Years))
end_date = ql.Period(3, ql.Months)
tenor = ql.DateGeneration.Forward
rule = ql.Following
convention = False
end_of_month
= ql.Schedule(
schedule
start_date,
end_date,
tenor,
calendar,
convention,
convention,
rule,
end_of_month, )
Since the notionals don’t change, the two legs are similar: an initial lending of notional, the interest payments received according to the schedule and the index fixings, and the final payment of the notional.
= (
usd_leg -notional, schedule[0]),)
(ql.SimpleCashFlow(+ ql.IborLeg(
=[notional],
nominals=schedule,
schedule=usdlibor3M,
index=[spread],
spreads
)+ (ql.SimpleCashFlow(notional, schedule[-1]),)
)
For the EUR leg, of course, we’ll have to convert the notional in the proper currency.
= (
eur_leg -notional * fx_0, schedule[0]),)
(ql.SimpleCashFlow(+ ql.IborLeg(
=[notional * fx_0], schedule=schedule, index=euribor3M
nominals
)+ (ql.SimpleCashFlow(notional * fx_0, schedule[-1]),)
)
Now, we can get the NPV of each leg in its own currency by discounting them with the corresponding curve. For the USD leg, that would be the SOFR curve. For the EUR leg, ideally, a discount curve bootstrapped on cross-currency instruments so that it can capture the basis.
= sample_curve("SOFR")
sofr_curve = ql.CashFlows.npv(usd_leg, sofr_curve, True)
usd_npv usd_npv
35427.6553257405
= sample_curve("EUR-USD-discount")
eurusd_curve = ql.CashFlows.npv(eur_leg, eurusd_curve, True)
eur_npv eur_npv
6908.723028828779
And of course, we can convert the NPV of the EUR leg in USD:
True) / fx_0 ql.CashFlows.npv(eur_leg, eurusd_curve,
8127.909445680917
We can also look at each cashflow by means of a short(ish) helper function:
def cashflow_data(leg):
= []
data for cf in sorted(leg, key=lambda c: c.date()):
= ql.as_floating_rate_coupon(cf)
coupon if coupon is None:
None, None, cf.amount()))
data.append((cf.date(), else:
data.append(
(
coupon.date(),
coupon.nominal(),
coupon.rate(),
coupon.amount(),
)
)return pd.DataFrame(
=["date", "nominal", "rate", "amount"]
data, columnsformat(
).style."amount": "{:.2f}", "nominal": "{:.2f}", "rate": "{:.2%}"}
{ )
Here are the cashflows in USD…
cashflow_data(usd_leg)
date | nominal | rate | amount | |
---|---|---|---|---|
0 | October 29th, 2021 | nan | nan% | -1000000.00 |
1 | January 31st, 2022 | 1000000.00 | 0.61% | 1585.56 |
2 | April 29th, 2022 | 1000000.00 | 0.72% | 1750.57 |
3 | July 29th, 2022 | 1000000.00 | 0.81% | 2040.59 |
4 | October 31st, 2022 | 1000000.00 | 0.91% | 2386.92 |
5 | January 30th, 2023 | 1000000.00 | 1.22% | 3080.34 |
6 | May 1st, 2023 | 1000000.00 | 1.40% | 3541.30 |
7 | July 31st, 2023 | 1000000.00 | 1.58% | 3999.86 |
8 | October 30th, 2023 | 1000000.00 | 1.76% | 4444.64 |
9 | January 29th, 2024 | 1000000.00 | 1.79% | 4518.96 |
10 | April 29th, 2024 | 1000000.00 | 1.93% | 4890.80 |
11 | July 29th, 2024 | 1000000.00 | 2.08% | 5262.77 |
12 | October 29th, 2024 | 1000000.00 | 2.22% | 5682.53 |
13 | January 29th, 2025 | 1000000.00 | 2.06% | 5257.82 |
14 | April 29th, 2025 | 1000000.00 | 2.16% | 5388.64 |
15 | July 29th, 2025 | 1000000.00 | 2.25% | 5695.32 |
16 | October 29th, 2025 | 1000000.00 | 2.35% | 6000.94 |
17 | January 29th, 2026 | 1000000.00 | 2.27% | 5807.00 |
18 | April 29th, 2026 | 1000000.00 | 2.35% | 5873.71 |
19 | July 29th, 2026 | 1000000.00 | 2.43% | 6133.38 |
20 | October 29th, 2026 | 1000000.00 | 2.50% | 6388.32 |
21 | October 29th, 2026 | nan | nan% | 1000000.00 |
…and here are those in EUR.
cashflow_data(eur_leg)
date | nominal | rate | amount | |
---|---|---|---|---|
0 | October 29th, 2021 | nan | nan% | -850000.00 |
1 | January 31st, 2022 | 850000.00 | -0.50% | -1108.91 |
2 | April 29th, 2022 | 850000.00 | -0.53% | -1109.57 |
3 | July 29th, 2022 | 850000.00 | -0.49% | -1042.88 |
4 | October 31st, 2022 | 850000.00 | -0.46% | -1020.88 |
5 | January 30th, 2023 | 850000.00 | -0.30% | -644.90 |
6 | May 1st, 2023 | 850000.00 | -0.22% | -479.05 |
7 | July 31st, 2023 | 850000.00 | -0.15% | -314.08 |
8 | October 30th, 2023 | 850000.00 | -0.07% | -158.20 |
9 | January 29th, 2024 | 850000.00 | -0.12% | -266.58 |
10 | April 29th, 2024 | 850000.00 | -0.08% | -163.55 |
11 | July 29th, 2024 | 850000.00 | -0.03% | -60.50 |
12 | October 29th, 2024 | 850000.00 | 0.02% | 42.55 |
13 | January 29th, 2025 | 850000.00 | 0.04% | 96.24 |
14 | April 29th, 2025 | 850000.00 | 0.09% | 188.22 |
15 | July 29th, 2025 | 850000.00 | 0.13% | 284.92 |
16 | October 29th, 2025 | 850000.00 | 0.18% | 383.98 |
17 | January 29th, 2026 | 850000.00 | 0.20% | 443.61 |
18 | April 29th, 2026 | 850000.00 | 0.25% | 523.68 |
19 | July 29th, 2026 | 850000.00 | 0.29% | 619.73 |
20 | October 29th, 2026 | 850000.00 | 0.33% | 708.11 |
21 | October 29th, 2026 | nan | nan% | 850000.00 |
To get the NPV of the swap, we add those of the two legs after converting the EUR leg back to dollars:
= usd_npv - eur_npv / fx_0
NPV NPV
27299.745880059585
Mark-to-market cross-currency swaps
In this more common kind of cross-currency swaps, the notionals are rebalanced at each coupon date so that their value remain the same (according, of course, to the value of the FX rate at each coupon start).
In order to model this rebalancing feature we will need to estimate the FX rates in the future, the corresponding notionals in Euro for the floating cashflows, and the amounts exchanged due to rebalancing.
The future FX rates can be forecast from the discount curves, since they model the cost of money. We’ll write a convenience function to extract it at any given date:
def FX(date):
return fx_0 * sofr_curve.discount(date) / eurusd_curve.discount(date)
The notionals at the start of each coupon can now be calculated from the FX rates:
= list(schedule)[:-1]
start_dates
= [notional * FX(d) for d in start_dates] notionals
Given the notionals, we can also calculate the rebalancing cashflows:
= []
rebalancing_cashflows for i in range(len(notionals) - 1):
rebalancing_cashflows.append(- notionals[i + 1], schedule[i + 1])
ql.SimpleCashFlow(notionals[i] )
Finally, we can create the two legs and price them. The USD leg is as before, since its notional doesn’t change:
= (
usd_leg -notional, schedule[0]),)
(ql.SimpleCashFlow(+ ql.IborLeg(
=[notional],
nominals=schedule,
schedule=usdlibor3M,
index=[spread],
spreads
)+ (ql.SimpleCashFlow(notional, schedule[-1]),)
)
cashflow_data(usd_leg)
date | nominal | rate | amount | |
---|---|---|---|---|
0 | October 29th, 2021 | nan | nan% | -1000000.00 |
1 | January 31st, 2022 | 1000000.00 | 0.61% | 1585.56 |
2 | April 29th, 2022 | 1000000.00 | 0.72% | 1750.57 |
3 | July 29th, 2022 | 1000000.00 | 0.81% | 2040.59 |
4 | October 31st, 2022 | 1000000.00 | 0.91% | 2386.92 |
5 | January 30th, 2023 | 1000000.00 | 1.22% | 3080.34 |
6 | May 1st, 2023 | 1000000.00 | 1.40% | 3541.30 |
7 | July 31st, 2023 | 1000000.00 | 1.58% | 3999.86 |
8 | October 30th, 2023 | 1000000.00 | 1.76% | 4444.64 |
9 | January 29th, 2024 | 1000000.00 | 1.79% | 4518.96 |
10 | April 29th, 2024 | 1000000.00 | 1.93% | 4890.80 |
11 | July 29th, 2024 | 1000000.00 | 2.08% | 5262.77 |
12 | October 29th, 2024 | 1000000.00 | 2.22% | 5682.53 |
13 | January 29th, 2025 | 1000000.00 | 2.06% | 5257.82 |
14 | April 29th, 2025 | 1000000.00 | 2.16% | 5388.64 |
15 | July 29th, 2025 | 1000000.00 | 2.25% | 5695.32 |
16 | October 29th, 2025 | 1000000.00 | 2.35% | 6000.94 |
17 | January 29th, 2026 | 1000000.00 | 2.27% | 5807.00 |
18 | April 29th, 2026 | 1000000.00 | 2.35% | 5873.71 |
19 | July 29th, 2026 | 1000000.00 | 2.43% | 6133.38 |
20 | October 29th, 2026 | 1000000.00 | 2.50% | 6388.32 |
21 | October 29th, 2026 | nan | nan% | 1000000.00 |
The EUR leg, instead, changes notionals and thus it includes the rebalancing payments:
= (
eur_leg -notionals[0], schedule[0])]
[ql.SimpleCashFlow(+ list(
=notionals, schedule=schedule, index=euribor3M)
ql.IborLeg(nominals
)+ rebalancing_cashflows
+ [ql.SimpleCashFlow(notionals[-1], schedule[-1])]
)
cashflow_data(eur_leg)
date | nominal | rate | amount | |
---|---|---|---|---|
0 | October 29th, 2021 | nan | nan% | -849973.31 |
1 | January 31st, 2022 | 849973.31 | -0.50% | -1108.87 |
2 | January 31st, 2022 | nan | nan% | 1361.41 |
3 | April 29th, 2022 | 848611.90 | -0.53% | -1107.76 |
4 | April 29th, 2022 | nan | nan% | 1140.19 |
5 | July 29th, 2022 | 847471.71 | -0.49% | -1039.78 |
6 | July 29th, 2022 | nan | nan% | 1688.83 |
7 | October 31st, 2022 | 845782.88 | -0.46% | -1015.82 |
8 | October 31st, 2022 | nan | nan% | 2036.91 |
9 | January 30th, 2023 | 843745.97 | -0.30% | -640.16 |
10 | January 30th, 2023 | nan | nan% | 1989.74 |
11 | May 1st, 2023 | 841756.23 | -0.22% | -474.40 |
12 | May 1st, 2023 | nan | nan% | 2164.38 |
13 | July 31st, 2023 | 839591.85 | -0.15% | -310.23 |
14 | July 31st, 2023 | nan | nan% | 2337.65 |
15 | October 30th, 2023 | 837254.19 | -0.07% | -155.83 |
16 | October 30th, 2023 | nan | nan% | 2508.90 |
17 | January 29th, 2024 | 834745.29 | -0.12% | -261.80 |
18 | January 29th, 2024 | nan | nan% | 2661.04 |
19 | April 29th, 2024 | 832084.25 | -0.08% | -160.10 |
20 | April 29th, 2024 | nan | nan% | 2825.60 |
21 | July 29th, 2024 | 829258.65 | -0.03% | -59.02 |
22 | July 29th, 2024 | nan | nan% | 2988.43 |
23 | October 29th, 2024 | 826270.22 | 0.02% | 41.36 |
24 | October 29th, 2024 | nan | nan% | 3181.28 |
25 | January 29th, 2025 | 823088.95 | 0.04% | 93.19 |
26 | January 29th, 2025 | nan | nan% | 3166.37 |
27 | April 29th, 2025 | 819922.58 | 0.09% | 181.56 |
28 | April 29th, 2025 | nan | nan% | 3227.34 |
29 | July 29th, 2025 | 816695.24 | 0.13% | 273.75 |
30 | July 29th, 2025 | nan | nan% | 3392.07 |
31 | October 29th, 2025 | 813303.17 | 0.18% | 367.40 |
32 | October 29th, 2025 | nan | nan% | 3550.54 |
33 | January 29th, 2026 | 809752.63 | 0.20% | 422.60 |
34 | January 29th, 2026 | nan | nan% | 3259.96 |
35 | April 29th, 2026 | 806492.67 | 0.25% | 496.88 |
36 | April 29th, 2026 | nan | nan% | 3266.85 |
37 | July 29th, 2026 | 803225.82 | 0.29% | 585.63 |
38 | July 29th, 2026 | nan | nan% | 3380.28 |
39 | October 29th, 2026 | 799845.53 | 0.33% | 666.33 |
40 | October 29th, 2026 | nan | nan% | 799845.53 |
Finally, as in the previous case, we can calculate the NPV of each leg and of the swap:
= ql.CashFlows.npv(usd_leg, sofr_curve, True)
usd_npv usd_npv
35427.6553257405
= ql.CashFlows.npv(eur_leg, eurusd_curve, True)
eur_npv eur_npv
6678.756064412276
= usd_npv - eur_npv / fx_0
NPV NPV
27570.295249961353
Future developments
The above calculations work but are not very well integrated with the rest of the library; if you’re familiar with QuantLib, you know that usually instruments can monitor market quotes and recalculate automatically when they change. This is not possible with the above; it will be up to you to recreate the cashflows when the FX rate or the curves are updated. A cross-currency swap modeled as an instrument would be way more convenient.
The reason we don’t have such an instrument yet is that, unfortunately, the calculations above are a bit awkward to encapsulate in an instrument class. The constant-notional case is easy enough, but the mark-to-market case requires changing the notionals of the coupons when market quotes change, which is something our current classes were not prepared for. We’ll have to think about it and try to add the required functionality in future releases.