Cross-currency calibration
This page lists what you need to solve one currency's discount curve under another currency's collateral from cross-currency basis swaps: the rungs, the solve, and the key the curve is filed under. It also says when to use this explicit solve and when to let a recipe build the same curve.
Quick example
This marks the dollar and euro curves and the EUR/USD spot, solves the euro curve under dollar collateral from four basis swaps, files it, and compares it with €STR's own discounting at ten years. The example reads its dollar quotes from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
use helixrs::{
CollateralSpec, CurrencyPair, FixingStore, FxRates, Interpolation, Locator, MarketContext,
Period, QuoteSheet, Tolerance, YieldCurve, calibrate_cross_currency, cross_currency_rungs,
};
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")
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let eurusd = 1.17;
// Both currencies marked from their sheets, and the spot. Each rung is a
// EUR/USD basis swap at its quoted basis, its euro notional set at the spot.
let usd_sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let mut eur_sheet = QuoteSheet::new();
for (tenor, quote) in [("1Y", 0.0195), ("2Y", 0.0205), ("5Y", 0.0230), ("10Y", 0.0262)] {
eur_sheet = eur_sheet.with("EUR-ESTR-OIS", Locator::parse(tenor)?, quote);
}
let fx = FxRates::from_quotes(&[("EUR/USD".parse::<CurrencyPair>()?, eurusd)])?;
let market = MarketContext::as_of(close)
.with_recipe_quotes(&CollateralSpec::Currency("USD".parse()?), &usd_sheet)?
.with_recipe_quotes(&CollateralSpec::Currency("EUR".parse()?), &eur_sheet)?
.with_fx_rates(fx.clone());
let tenors = ["1Y", "2Y", "5Y", "10Y"].iter().map(|t| t.parse::<Period>()).collect::<Result<Vec<_>, _>>()?;
let bases = [-0.0010, -0.0012, -0.0014, -0.0015];
let rungs = cross_currency_rungs("EUR-USD-ESTR-SOFR-BASIS", close, &tenors, &bases, 1.0, eurusd)?;
for rung in &rungs {
println!("{} {} basis {:.4}", rung.pillar, rung.instrument.product_name(), rung.quote);
}
// The euro curve under dollar collateral, solved against the dollar discount
// and SOFR projection on the pay leg and the €STR projection on the receive
// leg, then filed under the key a euro leg of a dollar-collateralised swap
// declares.
let (usd, _) = market.curve(&"discount(USD)".parse()?).expect("the USD curve");
let (estr, _) = market.curve(&"projection(EUR-ESTR)".parse()?).expect("the ESTR projection");
let solved = calibrate_cross_currency(
close,
&usd,
&usd,
Some(&estr),
&rungs,
&FixingStore::new(),
&fx,
Tolerance::default(),
Interpolation::LogLinear,
)?;
let collateralised = market
.clone()
.with_discount(CollateralSpec::under_csa("EUR".parse()?, "USD".parse()?), solved.clone());
for (role, key) in collateralised.contents() {
if key.contains("CSA") {
println!("filed as {role} {key}");
}
}
let mut worst: f64 = 0.0;
for rung in &rungs {
worst = worst.max(rung.instrument.npv(&collateralised)?.abs());
}
println!("pillars {}, largest basis swap value {worst:.2}", solved.curve_value().parameter_count());
// The basis moves euro discounting away from €STR's own: the two curves
// differ, by an amount of the order of the basis over the horizon.
let ten_years = date((2036, 10, 1));
let (eur, _) = market.curve(&"discount(EUR)".parse()?).expect("the EUR curve");
let own = eur.view::<f64>().discount(ten_years)?;
let under_usd = solved.curve_value().view::<f64>().discount(ten_years)?;
println!("10Y EUR discount, EUR collateral {own:.8}");
println!("10Y EUR discount, USD collateral {under_usd:.8}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
calibration = hlx.calibration
EUR_ESTR = {"EUR-ESTR-OIS": {"1Y": 0.0195, "2Y": 0.0205, "5Y": 0.0230, "10Y": 0.0262}}
EURUSD = 1.17
# Both currencies marked from their sheets, and the spot. Each rung is a
# EUR/USD basis swap at its quoted basis, its euro notional set at the spot.
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_recipe_quotes("EUR", EUR_ESTR)
.with_fx_rates([("EUR", "USD", EURUSD)])
)
tenors, bases = ["1Y", "2Y", "5Y", "10Y"], [-0.0010, -0.0012, -0.0014, -0.0015]
rungs = calibration.cross_currency_rungs("EUR-USD-ESTR-SOFR-BASIS", SOFR_CLOSE_DATE, tenors, bases, 1.0, EURUSD)
for pillar, swap, basis in rungs:
print(f"{pillar} {swap.product()} basis {basis:.4f}")
# The euro curve under dollar collateral, solved against the dollar discount
# and SOFR projection on the pay leg and the €STR projection on the receive
# leg, then filed under the key a euro leg of a dollar-collateralised swap
# declares.
usd = market.curve("discount(USD)")
solved = calibration.cross_currency(
SOFR_CLOSE_DATE, usd, usd, market.curve("projection(EUR-ESTR)"), rungs, market=market
)
collateralised = market.with_discount("EUR", solved.curve(), collateral="USD")
for role, key in collateralised.contents():
if "CSA" in key:
print(f"filed as {role} {key}")
worst = max(abs(swap.npv(collateralised)) for _, swap, _ in rungs)
print(f"pillars {solved.curve().parameter_count()}, largest basis swap value {worst:.2f}")
# The basis moves euro discounting away from €STR's own: the two curves
# differ, by an amount of the order of the basis over the horizon.
ten_years = dt.date(2036, 10, 1)
own = market.curve("discount(EUR)").discount(ten_years)
under_usd = solved.curve().discount(ten_years)
print(f"10Y EUR discount, EUR collateral {own:.8f}")
print(f"10Y EUR discount, USD collateral {under_usd:.8f}")2027-10-07 CrossCurrencySwap basis -0.0010
2028-10-10 CrossCurrencySwap basis -0.0012
2031-10-08 CrossCurrencySwap basis -0.0014
2036-10-08 CrossCurrencySwap basis -0.0015
filed as discount EUR-CSA-USD
pillars 4, largest basis swap value 0.00
10Y EUR discount, EUR collateral 0.76730551
10Y EUR discount, USD collateral 0.77896217
The basis is negative, so euros funded through dollar collateral are worth a little more: the curve under USD collateral discounts less than €STR's own.
How it's priced
Write D_{\}F_{$}F_{€}XN_{€}N_{$} = X N_{€}b$ on the euro leg is worth, in dollars,
each leg including its notional exchanges. Only D_{€|\}V = 0$. Why collateral changes discounting is in how calibration works.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the spot | FxRates::from_quotes, MarketContext::with_fx_rates |
MarketContext.with_fx_rates |
|
| basis rungs | basis swaps at each tenor | cross_currency_rungs |
calibration.cross_currency_rungs |
| D_{€\mid\}$ | the collateral curve solved | calibrate_cross_currency |
calibration.cross_currency |
| filed curve | the curve under EUR-CSA-USD |
MarketContext::with_discount(CollateralSpec::under_csa(..), ..) |
MarketContext.with_discount(.., collateral=) |
calibration.cross_currency_rungs(template, valuation, tenors, quotes, notional, fx_rate) builds
one (pillar, instrument, basis) rung per tenor from a basis-swap template, such as
EUR-USD-ESTR-SOFR-BASIS, dated by the template's conventions. calibration.cross_currency
takes:
| Argument | Type | Default | Meaning |
|---|---|---|---|
reference |
date |
required | the curve's reference date |
pay_discount |
DiscountCurve |
required | the pay leg's discount curve, here the dollar curve |
pay_projection |
DiscountCurve |
required | the pay leg's projection, here SOFR |
recv_projection |
DiscountCurve or None |
required | the receive leg's projection, here €STR; None for a fixed receive leg |
rungs |
list[Rung] or list[tuple[date, Instrument, float]] |
required | the basis swaps with their quoted bases |
market |
MarketContext or None |
None |
where the spot, fixings and FX are read |
interpolation |
Interpolation or str |
"loglinear" |
how the solved curve reads between pillars |
tension |
float or None |
None |
the tension, for a tension spline |
end_slopes |
tuple[float, float] or None |
None |
the end slopes, for a clamped spline |
tolerance |
Tolerance or None |
the defaults | how hard the solve tries; see bootstrapping |
The result is a Calibration, as for bootstrapping.
Defaults and limits
- What must be marked first: both currencies' own curves and the spot. The solve reads them; it doesn't build them.
- The key: a curve filed with
collateral="USD"on a"EUR"discount is keyedEUR-CSA-USD, the key a euro leg of a dollar-collateralised trade asks for. - Fixed receive legs: a fixed-float cross-currency swap, such as a lira leg against dollars,
solves with
recv_projection=None. - This solve or a recipe: use this solve when you have basis swap quotes and want the solve's
report, its Jacobian, or your own rungs. When a counterparty's CSA decides the collateral and
your sheet already lists the cross curve's FX swaps and basis, let the recipe build it with
with_recipe_quoteson that cross curve's key. The counterparties and CSAs page does this forUSD-CSA-EUR, dollars under euro collateral, the mirror of the curve solved here.
See also
- Source:
crates/helixrs/src/calibration.rs,crates/helixrs/src/products.rs - Related pages:
- Quote sheets and recipes, for the two currencies' own curves and the offshore recipes.
- Bootstrapping, for the solve this one varies.
- Counterparties and CSAs, for the recipe route keyed by a counterparty.
- Cross-currency swaps, the trades this curve prices.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.