Welcome back.

From time to time, a question on the QuantLib mailing list or, as in this case, on the Quantitative Finance StackExchange causes me to answer with a notebook. Who knows, there might be a second cookbook in there somewhere. Anyway, this time the question was: the library has cross-currency swap rate helpers, but what about the instrument itself?

Subscribe to my Substack to receive my posts in your inbox, or follow me on Twitter or LinkedIn if you want to be notified of new posts, or subscribe via RSS if you’re the tech type: the buttons for all that are in the footer. Also, I’m available for training, both online and (when possible) on-site: visit my Training page for more information.

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.

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

For the purposes of this notebook, we’ll use mock rates. The data frame below holds made-up zero rates for SOFR, USD LIBOR, ESTR and Euribor at a number of nodes…

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", "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):
    curve = ql.ZeroCurve(
        sample_rates["date"], sample_rates[tag] / 100, ql.Actual365Fixed()
    )
    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.

euribor3M_curve = sample_curve("Euribor3M")
euribor3M = ql.Euribor(ql.Period(3, ql.Months), euribor3M_curve)
usdlibor3M_curve = sample_curve("USDLibor3M")
usdlibor3M = ql.USDLibor(ql.Period(3, ql.Months), usdlibor3M_curve)

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.

notional = 1_000_000
fx_0 = 0.85
spread = 50.0 * bps

As I mentioned, the swaps make quarterly payments. The corresponding schedule starts spot and ends in five years.

calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)
start_date = calendar.advance(today, ql.Period(2, ql.Days))
end_date = calendar.advance(start_date, ql.Period(5, ql.Years))
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,
)

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 = (
    (ql.SimpleCashFlow(-notional, schedule[0]),)
    + ql.IborLeg(
        nominals=[notional],
        schedule=schedule,
        index=usdlibor3M,
        spreads=[spread],
    )
    + (ql.SimpleCashFlow(notional, schedule[-1]),)
)

For the EUR leg, of course, we’ll have to convert the notional in the proper currency.

eur_leg = (
    (ql.SimpleCashFlow(-notional * fx_0, schedule[0]),)
    + ql.IborLeg(nominals=[notional * fx_0], schedule=schedule, index=euribor3M)
    + (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.

sofr_curve = sample_curve("SOFR")
usd_npv = ql.CashFlows.npv(usd_leg, sofr_curve, True)
usd_npv
35427.65532574046
eurusd_curve = sample_curve("EUR-USD-discount")
eur_npv = ql.CashFlows.npv(eur_leg, eurusd_curve, True)
eur_npv
6908.723028828739

And of course, we can convert the NPV of the EUR leg in USD:

ql.CashFlows.npv(eur_leg, eurusd_curve, True) / fx_0
8127.90944568087

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()):
        coupon = ql.as_floating_rate_coupon(cf)
        if coupon is None:
            data.append((cf.date(), None, None, cf.amount()))
        else:
            data.append(
                (
                    coupon.date(),
                    coupon.nominal(),
                    coupon.rate(),
                    coupon.amount(),
                )
            )
    return pd.DataFrame(
        data, columns=["date", "nominal", "rate", "amount"]
    ).style.format({"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:

NPV = usd_npv - eur_npv / fx_0
NPV
27299.74588005959

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:

start_dates = list(schedule)[:-1]

notionals = [notional * FX(d) for d in start_dates]

Given the notionals, we can also calculate the rebalancing cashflows:

rebalancing_cashflows = []
for i in range(len(notionals) - 1):
    rebalancing_cashflows.append(
        ql.SimpleCashFlow(notionals[i] - notionals[i + 1], schedule[i + 1])
    )

Finally, we can create the two legs and price them. The USD leg is as before, since its notional doesn’t change:

usd_leg = (
    (ql.SimpleCashFlow(-notional, schedule[0]),)
    + ql.IborLeg(
        nominals=[notional],
        schedule=schedule,
        index=usdlibor3M,
        spreads=[spread],
    )
    + (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 = (
    [ql.SimpleCashFlow(-notionals[0], schedule[0])]
    + list(ql.IborLeg(nominals=notionals, schedule=schedule, index=euribor3M))
    + 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:

usd_npv = ql.CashFlows.npv(usd_leg, sofr_curve, True)
usd_npv
35427.65532574046
eur_npv = ql.CashFlows.npv(eur_leg, eurusd_curve, True)
eur_npv
6678.7560644123005
NPV = usd_npv - eur_npv / fx_0
NPV
27570.29524996128

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.