Cross-currency swap
This page lists what you need to value a cross-currency swap: the booking call and its arguments, how the value and each leg are priced, and the defaults you get. A cross-currency swap exchanges interest in two currencies on notionals exchanged at the start and returned at the end.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This marks a EUR against USD market with the euro curve collateralised in dollars, values a five-year €STR against SOFR basis swap from both sides, and reads its par spread.
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE, Sheet, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, AnyTemplate, CollateralSpec, CrossCurrencySwap, CurrencyPair, FxRates,
MarketContext, QuoteSheet, template,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const BASIS: Sheet = &[
("EUR-USD-ESTR-SOFR-BASIS", "1Y", -0.0010),
("EUR-USD-ESTR-SOFR-BASIS", "2Y", -0.0012),
("EUR-USD-ESTR-SOFR-BASIS", "5Y", -0.0015),
("EUR-USD-ESTR-SOFR-BASIS", "10Y", -0.0016),
];
fn key(spelling: &str) -> Outcome<CollateralSpec> {
Ok(spelling.parse()?)
}
fn spot(base: &str, quote: &str, rate: f64) -> Outcome<FxRates> {
Ok(FxRates::from_quotes(&[(
CurrencyPair::new(base.parse()?, quote.parse()?),
rate,
)])?)
}
fn xccy(
code: &str,
end: NaiveDate,
notional: f64,
fx_rate: f64,
quote: f64,
pays_quoted_leg: bool,
resets: bool,
) -> Outcome<AnyInstrument> {
let AnyTemplate::Xccy(mut t) = template(code)? else {
unreachable!("a cross-currency template")
};
if !resets {
t = t.with_resetting_leg(None);
}
let mut swap =
CrossCurrencySwap::from_template(&t, date((2026, 10, 5)), end, notional, fx_rate, quote)?;
if pays_quoted_leg {
swap = swap.reversed();
}
Ok(AnyInstrument::CrossCurrency(Box::new(swap)))
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let market = MarketContext::as_of(close)
.with_recipe_quotes(
&key("USD")?,
&QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
)?
.with_recipe_quotes(
&key("EUR")?,
&QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?,
)?
.with_fx_rates(spot("EUR", "USD", 1.08)?)
.with_recipe_quotes(
&key("USD-CSA-EUR")?,
&QuoteSheet::from_rows(BASIS.iter().copied())?,
)?;
let basis_swap = |spread: f64, pays: bool, resets: bool| {
xccy(
"EUR-USD-ESTR-SOFR-BASIS",
date((2031, 10, 5)),
10_000_000.0,
1.08,
spread,
pays,
resets,
)
};
for (role, key) in market.contents() {
println!("{role:<11} {key}");
}
// The swap values in its quoted leg's currency, euros, whichever side the
// holder takes. Paying the quoted leg is the other side of the same trade, so
// the two values sum to zero as read, with no conversion.
let flat = basis_swap(0.0, false, true)?;
let other = basis_swap(0.0, true, true)?;
let flat_npv = flat.npv(&market)?;
println!("swap rec. EUR {flat_npv:>12.2} {}", flat.currency());
println!("swap pay EUR {:>12.2} {}", other.npv(&market)?, other.currency());
// Five years of €STR plus a spread against SOFR, notionals exchanged at the
// spot rate. The quote the curve was solved to reprices to the spread.
let spread = flat.par_spread(&market)?;
println!("npv at zero spread {flat_npv:.2}");
println!("par spread {spread:.8}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE, USD_SOFR_CLOSE
BASIS = {"EUR-USD-ESTR-SOFR-BASIS": {"1Y": -0.0010, "2Y": -0.0012, "5Y": -0.0015, "10Y": -0.0016}}
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_fx_rates([("EUR", "USD", 1.08)])
.with_recipe_quotes("USD-CSA-EUR", BASIS)
)
eurusd = hlx.templates.get("EUR-USD-ESTR-SOFR-BASIS")
def basis_swap(spread, pays_quoted_leg=False, template=eurusd):
return template.cross_currency_swap(
dt.date(2026, 10, 5),
dt.date(2031, 10, 5),
notional=10_000_000.0,
fx_rate=1.08,
spread=spread,
pays_quoted_leg=pays_quoted_leg,
)
for role, key in market.contents():
print(f"{role:<11} {key}")
# The swap values in its quoted leg's currency, euros, whichever side the
# holder takes. Paying the quoted leg is the other side of the same trade, so
# the two values sum to zero as read, with no conversion.
flat = basis_swap(0.0)
other = basis_swap(0.0, pays_quoted_leg=True)
print(f"swap rec. EUR {flat.npv(market):>12.2f} {flat.npv_ccy()}")
print(f"swap pay EUR {other.npv(market):>12.2f} {other.npv_ccy()}")
# Five years of €STR plus a spread against SOFR, notionals exchanged at the
# spot rate. The quote the curve was solved to reprices to the spread.
spread = flat.par_spread(market)
print(f"npv at zero spread {flat.npv(market):.2f}")
print(f"par spread {spread:.8f}")discount EUR
discount USD
discount EUR-CSA-USD
projection EUR-ESTR
projection EUR-EURIBOR-3M
projection USD-SOFR
fx EUR/USD
swap rec. EUR 72307.51 EUR
swap pay EUR -72307.51 EUR
npv at zero spread 72307.51
par spread -0.00150000
Both sides of one trade value in euros and sum to zero, and the par spread is the five-year basis quote the curve was solved from.
How it's priced
On notional in the quoted currency and in the other, with the exchange rate on the trade, each leg's value is its coupons plus its notional exchanges, discounted on that currency's curve under the collateral. A resetting leg also exchanges the change in its notional at each reset. Write and for the two legs' values to the holder, each in its own currency and signed, and for the spot in units of the other currency per unit of the quoted one. The swap's value is
in the quoted leg's currency, whichever side the holder takes. Paying the quoted leg flips the sign of both legs, so the two sides of one trade sum to zero as read.
A book reports in its own P&L currency . The value, and each leg, converts at the spot :
The legs, each in its own currency, sum to the value in any currency. The par spread makes , so at par the two legs cancel. Why a dollar-collateralised swap discounts its euro flows on a curve implied from basis quotes, and how each leg is valued by hand, is covered in how rates products work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the market | both curves, spot, and the basis curve | with_recipe_quotes on each key, MarketContext::with_fx_rates |
MarketContext.with_recipe_quotes, MarketContext.with_fx_rates |
| the trade | a swap on its template | CrossCurrencySwap::from_template |
Template.cross_currency_swap |
| other side | pays the quoted leg | CrossCurrencySwap::reversed |
pays_quoted_leg=True |
| the npv currency | the quoted leg's | AnyInstrument::currency |
Instrument.npv_ccy |
| , and their sum in each currency | the value by leg | AnyInstrument::leg_npvs(market, pnl) |
Instrument.leg_npvs(market, pnl_ccy=) |
| the P&L currency, default the npv currency | the pnl argument |
pnl_ccy= |
|
| a constant notional | no leg resets | XccySwapTemplate::with_resetting_leg(None) |
Template.with_resetting_leg(None) |
| a leg on another index | one leg projects on another index | the leg's with_index, then XccySwapTemplate::with_leg |
Template.with_leg_index(leg, index) |
| the par spread | AnyInstrument::par_spread |
Instrument.par_spread |
The leg_npvs columns are side, ccy (the leg's currency), npv_local (in it), npv_ccy and
npv (in the swap's currency), and pnl_ccy and npv_pnl (in the P&L currency).
Template.cross_currency_swap takes:
| Argument | Type | Default | Meaning |
|---|---|---|---|
effective |
date |
required | the first notional exchange |
termination |
date |
required | the final notional exchange |
notional |
float |
required | in the quoted leg's currency |
fx_rate |
float |
required | units of the other leg's currency per unit of the quoted leg's |
spread |
float or None |
None |
the quote on a floating quoted leg; give this or fixed_rate |
fixed_rate |
float or None |
None |
the quote on a fixed quoted leg |
pays_quoted_leg |
bool |
False |
book the side paying the quoted leg; Rust .reversed() |
own_curves |
bool |
False |
no CSA: discount each leg on its own currency's curve |
In Rust, CrossCurrencySwap::from_template(template, effective, termination, notional, fx_rate, quote) takes the same values positionally, quote being the spread or the fixed rate.
Defaults and limits
- Collateral: the recipe for the key
USD-CSA-EURbuilds the euro curve collateralised in dollars from basis quotes; see cross-currency calibration. From basis quotes alone it files that curve underEUR-CSA-USD, the key a euro leg asks for, as the output above shows. Given the cross sheet's FX swaps too, it also filesUSD-CSA-EUR, as on counterparties and CSAs. - Resets: the EUR-USD basis template resets the SOFR leg's notional to the forward FX rate every
period.
with_resetting_leg(None)keeps both notionals constant. - Legs by hand: for a resetting swap,
leg_npvsis the only public view of each leg, because the resets are not among the leg's coupons. - Another index:
with_leg_indexnames the leg by its currency, or as"leg1"or"leg2", and takes a registry code or anIndexvalue. The leg's kind follows the index, so an overnight leg moved onto a term index becomes a term leg; every other convention is kept. - Templates: both legs, and which one is quoted, come from the template; see rates conventions.
More examples
This program shows the value by leg in a P&L currency, the same swap without resets, a fixed-float TRY against USD swap, and the EUR leg moved onto EURIBOR 3M:
use helixrs::statics::templates::xccy::{XccyLegSide, XccySwapTemplate};
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE, Sheet, USD_SOFR_CLOSE};
use helixrs::{
AnyInstrument, AnyTemplate, CollateralSpec, CrossCurrencySwap, CurrencyPair, FxRates,
MarketContext, QuoteSheet, Records, template,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const BASIS: Sheet = &[
("EUR-USD-ESTR-SOFR-BASIS", "1Y", -0.0010),
("EUR-USD-ESTR-SOFR-BASIS", "2Y", -0.0012),
("EUR-USD-ESTR-SOFR-BASIS", "5Y", -0.0015),
("EUR-USD-ESTR-SOFR-BASIS", "10Y", -0.0016),
];
const TRY_OIS: Sheet = &[
("TRY-TLREF-OIS", "1Y", 0.38),
("TRY-TLREF-OIS", "2Y", 0.34),
("TRY-TLREF-OIS", "5Y", 0.30),
];
const TRY_CCS: Sheet = &[
("TRY-USD-CCS", "1Y", 0.39),
("TRY-USD-CCS", "2Y", 0.35),
("TRY-USD-CCS", "5Y", 0.31),
];
fn key(spelling: &str) -> Outcome<CollateralSpec> {
Ok(spelling.parse()?)
}
fn spot(base: &str, quote: &str, rate: f64) -> Outcome<FxRates> {
Ok(FxRates::from_quotes(&[(
CurrencyPair::new(base.parse()?, quote.parse()?),
rate,
)])?)
}
fn xccy(
code: &str,
end: NaiveDate,
notional: f64,
fx_rate: f64,
quote: f64,
pays_quoted_leg: bool,
resets: bool,
) -> Outcome<AnyInstrument> {
let AnyTemplate::Xccy(mut t) = template(code)? else {
unreachable!("a cross-currency template")
};
if !resets {
t = t.with_resetting_leg(None);
}
let mut swap =
CrossCurrencySwap::from_template(&t, date((2026, 10, 5)), end, notional, fx_rate, quote)?;
if pays_quoted_leg {
swap = swap.reversed();
}
Ok(AnyInstrument::CrossCurrency(Box::new(swap)))
}
fn text<'r>(rows: &'r Records, name: &str) -> &'r [String] {
rows[name].text().expect("a text column")
}
fn numbers<'r>(rows: &'r Records, name: &str) -> &'r [f64] {
rows[name].numbers().expect("a number column")
}
fn show(rows: &Records) {
let header = |name: &str| format!("in {}", text(rows, name)[0]);
println!(
"{:<8} {:<4} {:>12} {:>12} {:>12}",
"side", "ccy", "local", header("npv_ccy"), header("pnl_ccy")
);
let (side, ccy) = (text(rows, "side"), text(rows, "ccy"));
let (local, npv, pnl) = (numbers(rows, "npv_local"), numbers(rows, "npv"), numbers(rows, "npv_pnl"));
for i in 0..side.len() {
println!("{:<8} {:<4} {:>12.2} {:>12.2} {:>12.2}", side[i], ccy[i], local[i], npv[i], pnl[i]);
}
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let market = MarketContext::as_of(close)
.with_recipe_quotes(
&key("USD")?,
&QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
)?
.with_recipe_quotes(
&key("EUR")?,
&QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?,
)?
.with_fx_rates(spot("EUR", "USD", 1.08)?)
.with_recipe_quotes(
&key("USD-CSA-EUR")?,
&QuoteSheet::from_rows(BASIS.iter().copied())?,
)?;
let basis_swap = |spread: f64, pays: bool, resets: bool| {
xccy(
"EUR-USD-ESTR-SOFR-BASIS",
date((2031, 10, 5)),
10_000_000.0,
1.08,
spread,
pays,
resets,
)
};
let usd = "USD".parse()?;
let flat = basis_swap(0.0, false, true)?;
let spread = flat.par_spread(&market)?;
// The value by leg: each leg in its own currency, in the swap's and in a P&L
// currency, dollars here, both converted at the spot. The pay leg is the SOFR
// leg, with its notional exchanges and resets. Each column sums to the value.
let rows = flat.leg_npvs(&market, Some(usd))?;
show(&rows);
let npv = numbers(&rows, "npv");
// At the par spread the legs cancel: what the €STR leg is worth in euros, the
// SOFR leg is worth too.
let par_rows = basis_swap(spread, false, true)?.leg_npvs(&market, None)?;
show(&par_rows);
// The template resets the SOFR leg's notional to the forward FX rate every
// period. Without resets the notional stays at 10.8 million dollars: the €STR
// leg is the same, and only the SOFR leg moves.
let constant = basis_swap(0.0, false, false)?;
let fixed_rows = constant.leg_npvs(&market, None)?;
show(&fixed_rows);
let fixed_npv = numbers(&fixed_rows, "npv");
println!("resets add {:.2} EUR to the SOFR leg", npv[0] - fixed_npv[0]);
// A fixed-float swap on a market whose local leg is fixed: 41.5 million lira at
// 36% against a million dollars on SOFR, discounted on the TRY curve
// collateralised in dollars. The lira leg is quoted, so the swap values in lira
// and its fx_rate is dollars per lira. By leg, in dollars.
let try_market = MarketContext::as_of(close)
.with_recipe_quotes(
&key("USD")?,
&QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?,
)?
.with_recipe_quotes(
&key("TRY")?,
&QuoteSheet::from_rows(TRY_OIS.iter().copied())?,
)?
.with_fx_rates(spot("USD", "TRY", 41.5)?)
.with_recipe_quotes(
&key("TRY-CSA-USD")?,
&QuoteSheet::from_rows(TRY_CCS.iter().copied())?,
)?;
let lira = xccy("TRY-USD-CCS", date((2028, 10, 5)), 41_500_000.0, 1.0 / 41.5, 0.36, false, true)?;
let try_rows = lira.leg_npvs(&try_market, Some(usd))?;
show(&try_rows);
// A template's leg can project on another index: take the EUR leg, move it
// onto the index with `with_index`, and put it back with `with_leg`; the
// leg's kind follows the index. The EUR
// leg on EURIBOR 3M instead of €STR receives a rate that fixes higher, so the
// par basis that makes the swap fair falls by about the €STR-EURIBOR basis.
let AnyTemplate::Xccy(eurusd) = template("EUR-USD-ESTR-SOFR-BASIS")? else {
unreachable!("a cross-currency template")
};
let at_par = |t: &XccySwapTemplate| -> Outcome<f64> {
let swap = CrossCurrencySwap::from_template(
t,
date((2026, 10, 5)),
date((2031, 10, 5)),
10_000_000.0,
1.08,
0.0,
)?;
Ok(AnyInstrument::CrossCurrency(Box::new(swap)).par_spread(&market)?)
};
let side = [XccyLegSide::Leg1, XccyLegSide::Leg2]
.into_iter()
.find(|s| eurusd.leg(*s).currency == "EUR")
.expect("a EUR leg");
let on_euribor_leg = eurusd.leg(side).clone().with_index("EUR-EURIBOR-3M")?;
let on_euribor_template = eurusd.clone().with_leg(side, on_euribor_leg)?;
let (on_estr, on_euribor) = (at_par(&eurusd)?, at_par(&on_euribor_template)?);
println!("par basis, EUR leg on ESTR {:.6} bp", on_estr * 10_000.0);
println!("par basis, EUR leg on EURIBOR 3M {:.6} bp", on_euribor * 10_000.0);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE, USD_SOFR_CLOSE
BASIS = {"EUR-USD-ESTR-SOFR-BASIS": {"1Y": -0.0010, "2Y": -0.0012, "5Y": -0.0015, "10Y": -0.0016}}
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_fx_rates([("EUR", "USD", 1.08)])
.with_recipe_quotes("USD-CSA-EUR", BASIS)
)
eurusd = hlx.templates.get("EUR-USD-ESTR-SOFR-BASIS")
def basis_swap(spread, pays_quoted_leg=False, template=eurusd):
return template.cross_currency_swap(
dt.date(2026, 10, 5),
dt.date(2031, 10, 5),
notional=10_000_000.0,
fx_rate=1.08,
spread=spread,
pays_quoted_leg=pays_quoted_leg,
)
flat = basis_swap(0.0)
spread = flat.par_spread(market)
def show(rows):
print(f"{'side':<8} {'ccy':<4} {'local':>12} {'in ' + rows['npv_ccy'][0]:>12} {'in ' + rows['pnl_ccy'][0]:>12}")
for side, ccy, local, npv, pnl in zip(rows["side"], rows["ccy"], rows["npv_local"], rows["npv"], rows["npv_pnl"]):
print(f"{side:<8} {ccy:<4} {local:>12.2f} {npv:>12.2f} {pnl:>12.2f}")
# The value by leg: each leg in its own currency, in the swap's and in a P&L
# currency, dollars here, both converted at the spot. The pay leg is the SOFR
# leg, with its notional exchanges and resets. Each column sums to the value.
rows = flat.leg_npvs(market, pnl_ccy="USD").to_dict()
show(rows)
# At the par spread the legs cancel: what the €STR leg is worth in euros, the
# SOFR leg is worth too.
par_rows = basis_swap(spread).leg_npvs(market).to_dict()
show(par_rows)
# The template resets the SOFR leg's notional to the forward FX rate every
# period. Without resets the notional stays at 10.8 million dollars: the €STR
# leg is the same, and only the SOFR leg moves.
constant = basis_swap(0.0, template=eurusd.with_resetting_leg(None))
fixed_rows = constant.leg_npvs(market).to_dict()
show(fixed_rows)
print(f"resets add {rows['npv'][0] - fixed_rows['npv'][0]:.2f} EUR to the SOFR leg")
# A fixed-float swap on a market whose local leg is fixed: 41.5 million lira at
# 36% against a million dollars on SOFR, discounted on the TRY curve
# collateralised in dollars. The lira leg is quoted, so the swap values in lira
# and its fx_rate is dollars per lira. By leg, in dollars.
try_market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("TRY", {"TRY-TLREF-OIS": {"1Y": 0.38, "2Y": 0.34, "5Y": 0.30}})
.with_fx_rates([("USD", "TRY", 41.5)])
.with_recipe_quotes("TRY-CSA-USD", {"TRY-USD-CCS": {"1Y": 0.39, "2Y": 0.35, "5Y": 0.31}})
)
lira = hlx.templates.get("TRY-USD-CCS").cross_currency_swap(
dt.date(2026, 10, 5), dt.date(2028, 10, 5), notional=41_500_000.0, fx_rate=1 / 41.5, fixed_rate=0.36
)
try_rows = lira.leg_npvs(try_market, pnl_ccy="USD").to_dict()
show(try_rows)
# A template's leg can project on another index: with_leg_index names the leg,
# by its currency, and the index, and the leg's kind follows the index. The EUR
# leg on EURIBOR 3M instead of €STR receives a rate that fixes higher, so the
# par basis that makes the swap fair falls by about the €STR-EURIBOR basis.
def at_par(template):
swap = template.cross_currency_swap(
dt.date(2026, 10, 5), dt.date(2031, 10, 5), notional=10_000_000.0, fx_rate=1.08, spread=0.0
)
return swap.par_spread(market)
on_estr = at_par(eurusd)
on_euribor = at_par(eurusd.with_leg_index("EUR", "EUR-EURIBOR-3M"))
print(f"par basis, EUR leg on ESTR {on_estr * 10_000:.6f} bp")
print(f"par basis, EUR leg on EURIBOR 3M {on_euribor * 10_000:.6f} bp")side ccy local in EUR in USD
pay USD 868.75 804.40 868.75
receive EUR 71503.11 71503.11 77223.36
side ccy local in EUR in EUR
pay USD 868.75 804.40 804.40
receive EUR -804.40 -804.40 -804.40
side ccy local in EUR in EUR
pay USD 814.62 754.28 754.28
receive EUR 71503.11 71503.11 71503.11
resets add 50.12 EUR to the SOFR leg
side ccy local in TRY in USD
pay USD 26.10 1083.09 26.10
receive TRY 529644.80 529644.80 12762.53
par basis, EUR leg on ESTR -15.000000 bp
par basis, EUR leg on EURIBOR 3M -28.592493 bp
Each column of the leg table sums to the swap's value; at the par spread the legs cancel. Without resets only the SOFR leg moves. On EURIBOR 3M the EUR leg receives a rate that fixes higher, so the par basis falls.
See also
- Source:
crates/helixrs/src/instruments/rates.rs - Related pages:
- Cross-currency calibration, for the basis curve.
- Non-deliverable swaps, for swaps settled in dollars.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.