Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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 AA the annuity, the value of one unit of spread; II the index leg with its notional exchanges; PVbondPV_{\text{bond}} the bond's flows after the start on the risk-free curve; and NN the notional:

    V=s A+I−PVbond+Pdirty100 N DF(T0),s∗=PVbond−Pdirty100 N DF(T0)−IA.V = s\,A + I - PV_{\text{bond}} + \frac{P_{\text{dirty}}}{100}\,N\,DF(T_0), \qquad s^\ast = \frac{PV_{\text{bond}} - \frac{P_{\text{dirty}}}{100}\,N\,DF(T_0) - I}{A}.

    A package traded at ss is worth (s−s∗) A(s - s^\ast)\,A 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
    VV the package's value MarketView::npv on AnyInstrument::AssetSwap Instrument.npv
    s∗s^\ast par asset swap spread Priceable::par_spread inside MarketContext::priced Instrument.par_spread
    AA spread annuity AssetSwap::annuity(&view, notional) the slope of Instrument.npv between two spreads
    PVbondPV_{\text{bond}} 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.