Value a cross-currency swap leg by leg
You'll value a EUR/USD basis swap one leg at a time, in each leg's own currency, in the swap's currency and in dollars, so you can see where its value comes from.
Before you start
- helixio installed: see installing.
- Quotes for both currencies, the EUR/USD spot, and the cross-currency basis quotes. The basis builds the curve that discounts euros collateralised in dollars.
Steps
- Build one market holding both currencies, the spot, and the basis under the key
USD-CSA-EUR. - Book the swap from its template, with the notional exchanged at the spot rate.
- Call
leg_npvs(market, pnl_ccy="USD")(leg_npvs(&market, Some(usd))in Rust). You get one row per leg with its value in its own currency (npv_local), in the swap's currency (npv) and in your P&L currency (npv_pnl).
Rust
use chrono::NaiveDate;
use helixrs::{
AnyInstrument, AnyTemplate, CollateralSpec, CrossCurrencySwap, CurrencyPair, FxRates,
MarketContext, QuoteSheet, template,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real date")
}
fn main() -> Outcome<()> {
let close = date(2026, 10, 1);
let usd_sofr_close = [
("USD-SOFR-OIS", "1M", 0.0391),
("USD-SOFR-OIS", "3M", 0.0402),
("USD-SOFR-OIS", "1Y", 0.0439),
("USD-SOFR-OIS", "2Y", 0.0459),
("USD-SOFR-OIS", "3Y", 0.0468),
("USD-SOFR-OIS", "4Y", 0.0469),
("USD-SOFR-OIS", "5Y", 0.0471),
("USD-SOFR-OIS", "7Y", 0.0475),
("USD-SOFR-OIS", "10Y", 0.0483),
("USD-SOFR-OIS", "15Y", 0.0494),
("USD-SOFR-OIS", "20Y", 0.0499),
("USD-SOFR-OIS", "30Y", 0.0492),
];
let eur_close = [
("EUR-ESTR-OIS", "1Y", 0.0195),
("EUR-ESTR-OIS", "2Y", 0.0200),
("EUR-ESTR-OIS", "5Y", 0.0215),
("EUR-ESTR-OIS", "10Y", 0.0240),
("EUR-ESTR-OIS", "20Y", 0.0262),
("EUR-ESTR-OIS", "30Y", 0.0265),
("EUR-EURIBOR-3M-IRS", "1Y", 0.0210),
("EUR-EURIBOR-3M-IRS", "2Y", 0.0216),
("EUR-EURIBOR-3M-IRS", "5Y", 0.0232),
("EUR-EURIBOR-3M-IRS", "10Y", 0.0257),
("EUR-EURIBOR-3M-IRS", "20Y", 0.0278),
("EUR-EURIBOR-3M-IRS", "30Y", 0.0280),
];
let basis = [
("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),
];
// 1. A market with both currencies, the spot, and the basis that discounts
// euros collateralised in dollars.
let key = |spelling: &str| spelling.parse::<CollateralSpec>();
let spot = FxRates::from_quotes(&[(CurrencyPair::new("EUR".parse()?, "USD".parse()?), 1.08)])?;
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)
.with_recipe_quotes(&key("USD-CSA-EUR")?, &QuoteSheet::from_rows(basis.iter().copied())?)?;
// 2. Five years of €STR flat against SOFR, notionals exchanged at the spot.
let AnyTemplate::Xccy(terms) = template("EUR-USD-ESTR-SOFR-BASIS")? else {
unreachable!("a cross-currency template")
};
let swap = AnyInstrument::CrossCurrency(Box::new(CrossCurrencySwap::from_template(
&terms, date(2026, 10, 5), date(2031, 10, 5), 10_000_000.0, 1.08, 0.0,
)?));
// 3. One row per leg: in its own currency, in the swap's (EUR), and in dollars.
let rows = swap.leg_npvs(&market, Some("USD".parse()?))?;
let text = |name: &str| rows[name].text().expect("a text column");
let numbers = |name: &str| rows[name].numbers().expect("a number column");
let (side, ccy) = (text("side"), text("ccy"));
let (local, npv, pnl) = (numbers("npv_local"), numbers("npv"), numbers("npv_pnl"));
println!("{:<8} {:<4} {:>12} {:>12} {:>12}", "side", "ccy", "local", "in EUR", "in USD");
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]);
}
let total = swap.npv(&market)?;
println!("swap npv {total:.2} EUR");
Ok(())
}Python
from datetime import date
import helixio as hlx
SOFR_CLOSE_DATE = date(2026, 10, 1)
USD_SOFR_CLOSE = {
"USD-SOFR-OIS": {
"1M": 0.0391, "3M": 0.0402, "1Y": 0.0439, "2Y": 0.0459, "3Y": 0.0468, "4Y": 0.0469,
"5Y": 0.0471, "7Y": 0.0475, "10Y": 0.0483, "15Y": 0.0494, "20Y": 0.0499, "30Y": 0.0492,
},
}
EUR_CLOSE = {
"EUR-ESTR-OIS": {"1Y": 0.0195, "2Y": 0.0200, "5Y": 0.0215, "10Y": 0.0240, "20Y": 0.0262, "30Y": 0.0265},
"EUR-EURIBOR-3M-IRS": {"1Y": 0.0210, "2Y": 0.0216, "5Y": 0.0232, "10Y": 0.0257, "20Y": 0.0278, "30Y": 0.0280},
}
BASIS = {"EUR-USD-ESTR-SOFR-BASIS": {"1Y": -0.0010, "2Y": -0.0012, "5Y": -0.0015, "10Y": -0.0016}}
# 1. A market with both currencies, the spot, and the basis that discounts
# euros collateralised in dollars.
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)
)
# 2. Five years of €STR flat against SOFR, notionals exchanged at the spot.
swap = hlx.templates.get("EUR-USD-ESTR-SOFR-BASIS").cross_currency_swap(
date(2026, 10, 5), date(2031, 10, 5), notional=10_000_000.0, fx_rate=1.08, spread=0.0
)
# 3. One row per leg: in its own currency, in the swap's (EUR), and in dollars.
rows = swap.leg_npvs(market, pnl_ccy="USD").to_dict()
print(f"{'side':<8} {'ccy':<4} {'local':>12} {'in EUR':>12} {'in USD':>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}")
total = swap.npv(market)
print(f"swap npv {total:.2f} EUR")side ccy local in EUR in USD
pay USD 868.75 804.40 868.75
receive EUR 71503.11 71503.11 77223.36
swap npv 72307.51 EUR
Check it worked
- The
in EURcolumn adds up to the swap's value. - The
in USDcolumn adds up to that value converted at the spot. - The dollar leg's
localandin USDfigures are the same, because dollars are its own currency.
Related
- Cross-currency swap, the swap's arguments and its par spread.
- How rates products work, which values each leg by hand on its own curve.
- Cross-currency calibration, how the basis curve is solved.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.