Asset swap
This page lists what you need to book and price an asset swap: the constructor, the par-par and proceeds conventions, the par spread and the value of a traded package, and an example you can run. An asset swap packages a bond with a swap that turns its fixed coupons into a floating index plus a spread, so the buyer holds the bond without its interest-rate risk.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This packages a 3.5% annual EUR bond maturing on 15 February 2031, bought at 101.50 dirty, against EURIBOR 3M from 4 March 2026. On a one-curve market marked from €STR alone, it finds the par spread, marks a package traded at 50 basis points from both sides, compares par-par with proceeds, and then moves to a market where EURIBOR projects off its own curve.
use chrono::NaiveDate;
use helixio_docs_sheets::EUR_CLOSE_MARCH;
use helixrs::{
AnyIndex, AnyInstrument, AssetSwap, AssetSwapKind, CallableBond, CollateralSpec, Currency,
DayCountFraction, FixedRateBond, Frequency, IborIndex, MarketContext, Period, PeriodEnum,
Priceable, PricingRiskFactors, QuoteSheet, YieldCurve, index,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const INDEX: &str = "EUR-EURIBOR-3M";
const COUPON: f64 = 0.035;
const NOTIONAL: f64 = 100.0;
const PRICE: f64 = 101.50;
fn euribor() -> IborIndex {
match index(INDEX) {
Some(AnyIndex::Ibor(index)) => index,
_ => panic!("a registered term index"),
}
}
fn npv(package: &AssetSwap, market: &MarketContext) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
view.npv(&AnyInstrument::AssetSwap(Box::new(package.clone())))
.expect("a priced package")
}
fn par_spread(package: &AssetSwap, market: &MarketContext) -> f64 {
let factors = package.risk_factors().expect("the package's curves");
market
.priced(&factors, |resolved| Priceable::<f64>::par_spread(package, resolved))
.expect("both curves pinned and a live annuity")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let valuation = date(2026, 3, 2);
let start = date(2026, 3, 4);
let maturity = date(2031, 2, 15);
let eur = Currency::new("EUR").expect("a registered currency");
let bond = FixedRateBond::bullet(
eur,
date(2024, 2, 15),
maturity,
COUPON,
Frequency::Annual,
DayCountFraction::ActActIcma,
100.0,
)
.expect("a well-formed bond");
let bullet = CallableBond::new(bond, Vec::new());
let package = |price: f64, spread: f64, kind: AssetSwapKind, sold: bool| {
let built = AssetSwap::new(
bullet.clone(),
start,
INDEX,
Period::new(3, PeriodEnum::Month),
NOTIONAL,
price,
spread,
)
.expect("a well-formed package")
.with_kind(kind);
if sold { built.sold() } else { built }
};
let collateral = CollateralSpec::Currency(eur);
// Marked on the €STR swaps alone, the market discounts EUR; projecting
// EURIBOR off that same curve is the one-curve world the par spread is first
// read in.
let estr_only = EUR_CLOSE_MARCH
.iter()
.copied()
.filter(|(template, _, _)| *template == "EUR-ESTR-OIS");
let estr_sheet = QuoteSheet::from_rows(estr_only).expect("a sheet");
let one_curve = MarketContext::as_of(valuation)
.with_recipe_quotes(&collateral, &estr_sheet)
.expect("a marked market");
let (discount, _) = one_curve
.curve(&"discount(EUR)".parse().expect("a curve id"))
.expect("the EUR curve");
let market = one_curve
.clone()
.with_projection(INDEX, (*discount).clone());
let reader = discount.view::<f64>();
let df = |at: NaiveDate| -> f64 { reader.discount(at).expect("on the curve") };
// The par spread is the spread over EURIBOR at which the package, bought at
// 101.50, is worth nothing.
let par = par_spread(&package(PRICE, 0.0, AssetSwapKind::ParPar, false), &market);
println!("par spread {:.4} bp", par * 10_000.0);
println!(
"struck at par, worth {:.6}",
npv(&package(PRICE, par, AssetSwapKind::ParPar, false), &market).abs()
);
// A package traded at 50 basis points, from the buyer's side and the
// seller's.
let traded = 0.0050;
let marked = npv(&package(PRICE, traded, AssetSwapKind::ParPar, false), &market);
let sold = npv(&package(PRICE, traded, AssetSwapKind::ParPar, true), &market);
println!("traded spread {:.4} bp", traded * 10_000.0);
println!("value to the buyer {marked:.6}");
println!("value to the seller {sold:.6}");
// A proceeds package runs its floating leg on the cash paid, not on par.
// Bought at exactly what the bond is worth, either kind leaves only the
// floating leg's own small residual.
let lifted = market.lift::<f64>();
let view = market.view(&lifted)?;
let bullet_value = view.npv(&AnyInstrument::Callable(Box::new(bullet.clone())))?;
let fair_price = bullet_value / df(start);
let residual = par_spread(&package(fair_price, 0.0, AssetSwapKind::ParPar, false), &market);
let residual_proceeds =
par_spread(&package(fair_price, 0.0, AssetSwapKind::Proceeds, false), &market);
let proceeds_par = par_spread(&package(PRICE, 0.0, AssetSwapKind::Proceeds, false), &market);
println!("fair dirty price {fair_price:.6}");
println!(
"residual, each kind {:.4} bp {:.4} bp",
residual * 10_000.0,
residual_proceeds * 10_000.0
);
println!("par-par spread {:.4} bp", par * 10_000.0);
println!("proceeds spread {:.4} bp", proceeds_par * 10_000.0);
// Project EURIBOR off its own curve, above the discount curve. The
// floating leg now pays more than it discounts, and the buyer needs less
// spread.
let sheet = QuoteSheet::from_rows(EUR_CLOSE_MARCH.iter().copied())?;
let on_basis = MarketContext::as_of(valuation).with_recipe_quotes(&collateral, &sheet)?;
let par_on_basis = par_spread(&package(PRICE, 0.0, AssetSwapKind::ParPar, false), &on_basis);
println!("par spread, one curve {:.4} bp", par * 10_000.0);
println!("par spread, on a basis {:.4} bp", par_on_basis * 10_000.0);
println!("basis taken out {:.4} bp", (par - par_on_basis) * 10_000.0);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE_MARCH
VALUATION = dt.date(2026, 3, 2)
START = dt.date(2026, 3, 4)
MATURITY = dt.date(2031, 2, 15)
INDEX = "EUR-EURIBOR-3M"
COUPON = 0.035
NOTIONAL = 100.0
PRICE = 101.50
BOND = hlx.products.fixed_rate_bond(
"EUR", dt.date(2024, 2, 15), MATURITY, coupon=COUPON,
frequency=hlx.dates.Frequency.Annual, day_count=hlx.dates.DayCountFraction.ActActIcma,
)
BULLET = hlx.products.callable_bond(BOND, [])
PAR_PAR = hlx.products.AssetSwapKind.ParPar
PROCEEDS = hlx.products.AssetSwapKind.Proceeds
def package(price, spread, kind=PAR_PAR, sold=False):
return hlx.products.asset_swap(BULLET, START, INDEX, "3M", notional=NOTIONAL, dirty_price=price, spread=spread, kind=kind, sold=sold)
# Marked on the €STR swaps alone, the market discounts EUR; projecting EURIBOR
# off that same curve is the one-curve world the par spread is first read in.
ESTR_ONLY = {"EUR-ESTR-OIS": EUR_CLOSE_MARCH["EUR-ESTR-OIS"]}
ONE_CURVE = hlx.MarketContext(VALUATION).with_recipe_quotes("EUR", ESTR_ONLY)
DISCOUNT = ONE_CURVE.curve("discount(EUR)")
MARKET = ONE_CURVE.with_projection(INDEX, DISCOUNT)
# The par spread is the spread over EURIBOR at which the package, bought at
# 101.50, is worth nothing.
par = package(PRICE, 0.0).par_spread(MARKET)
print(f"par spread {par * 10_000:.4f} bp")
print(f"struck at par, worth {abs(package(PRICE, par).npv(MARKET)):.6f}")
# A package traded at 50 basis points, from the buyer's side and the seller's.
traded = 0.0050
marked = package(PRICE, traded).npv(MARKET)
sold = package(PRICE, traded, sold=True).npv(MARKET)
print(f"traded spread {traded * 10_000:.4f} bp")
print(f"value to the buyer {marked:.6f}")
print(f"value to the seller {sold:.6f}")
# A proceeds package runs its floating leg on the cash paid, not on par.
# Bought at exactly what the bond is worth, either kind leaves only the
# floating leg's own small residual.
fair_price = BULLET.npv(MARKET) / DISCOUNT.discount(START)
residual = package(fair_price, 0.0).par_spread(MARKET)
residual_proceeds = package(fair_price, 0.0, PROCEEDS).par_spread(MARKET)
proceeds_par = package(PRICE, 0.0, PROCEEDS).par_spread(MARKET)
print(f"fair dirty price {fair_price:.6f}")
print(f"residual, each kind {residual * 10_000:.4f} bp {residual_proceeds * 10_000:.4f} bp")
print(f"par-par spread {par * 10_000:.4f} bp")
print(f"proceeds spread {proceeds_par * 10_000:.4f} bp")
# Project EURIBOR off its own curve, above the discount curve. The floating
# leg now pays more than it discounts, and the buyer needs less spread.
on_basis = hlx.MarketContext(VALUATION).with_recipe_quotes("EUR", EUR_CLOSE_MARCH)
par_on_basis = package(PRICE, 0.0).par_spread(on_basis)
print(f"par spread, one curve {par * 10_000:.4f} bp")
print(f"par spread, on a basis {par_on_basis * 10_000:.4f} bp")
print(f"basis taken out {(par - par_on_basis) * 10_000:.4f} bp")par spread 75.9057 bp
struck at par, worth 0.000000
traded spread 50.0000 bp
value to the buyer -1.225650
value to the seller 1.225650
fair dirty price 105.091896
residual, each kind -0.0045 bp -0.0045 bp
par-par spread 75.9057 bp
proceeds spread 74.7839 bp
par spread, one curve 75.9057 bp
par spread, on a basis 64.6430 bp
basis taken out 11.2627 bp
Traded at 50 basis points, below the par spread, the package is worth the gap times the annuity, and the seller holds the opposite. Above par, a proceeds package's floating leg runs on more cash, so its spread is the smaller one. Once EURIBOR projects above the discount curve, the floating leg is worth more and the buyer needs about 11 basis points less spread.
How it's priced
With the annuity, the value of one unit of spread; the index leg with its notional exchanges; the bond's flows after the start on the risk-free curve; and the notional:
A package traded at is worth exactly. Why the spread is the quote, and how the basis lands in it, is covered in how bonds work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
bond |
bond Instrument |
required | the bond, as products.callable_bond(bond, []) for a bullet |
start |
date |
required | when the package settles and the floating leg begins |
index, float_frequency |
str, str |
required | the floating index and its period, such as "EUR-EURIBOR-3M" and "3M" |
notional |
float |
required | the notional, in bond face |
dirty_price |
float |
required | the price paid, per 100 |
spread |
float |
required | the struck spread over the index |
kind |
products.AssetSwapKind or str |
"par_par" |
ParPar, the floating leg on the full notional, or Proceeds, on the cash paid |
sold |
bool |
False |
books the seller's side |
fx_rate |
float |
None |
for a bond and floating leg in different currencies |
products.asset_swap(bond, start, index, float_frequency, *, notional, dirty_price, spread, kind, sold, fx_rate) books it; in Rust, AssetSwap::new(bond, start, index, period, notional, dirty, spread)?, with .with_kind(AssetSwapKind::Proceeds) and .sold().
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the package's value | MarketView::npv on AnyInstrument::AssetSwap |
Instrument.npv |
|
| par asset swap spread | Priceable::par_spread inside MarketContext::priced |
Instrument.par_spread |
|
| spread annuity | AssetSwap::annuity(&view, notional) |
the slope of Instrument.npv between two spreads |
|
| the bond on the risk-free curve | AssetSwap::bond_value(&view) |
Instrument.npv on the bond |
Defaults and limits
- The curve: the spread is measured over the bond currency's risk-free curve, never over an issuer curve.
- The floating schedule rolls back from the bond's maturity, so its periods end on the bond's coupon days, and it accrues on the index's day count.
- Market inputs: the discount curve and the index's projection curve. Projecting off the
discount curve itself, with
MarketContext.with_projection(index, curve), gives the one-curve spread. - Seller: the seller's value is the buyer's, negated.
See also
- Source:
crates/helixrs/src/instruments/asset_swap.rs - Related pages:
- How bonds work, for the package and its quote.
- Repack notes, for the package inside a vehicle.
- Spread measures, for spreads read off a curve rather than a swap.
- Rates conventions, for each index's terms.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.