Esc

    The API is under testing and may change before release.

    Bond option

    This page lists what you need to price a European bond option: the constructor, the three ways to state a strike, the two engines, the diagnostics it reports, and examples you can run. A bond option is the right to buy (a call) or sell (a put) a bond at a set price on a set date.

    Quick example

    The example reads its swap quotes from the desk sheets; load them once and it runs as shown.

    This prices six-month options, expiring on 2 September 2026, on a ten-year 4.25% semiannual note, off a SOFR curve and a lognormal yield vol surface stated in the file. It reads the call's inputs and premium, checks the call less the put, and states the same strike clean, dirty and as a yield.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::USD_CLOSE_MARCH;
    use helixrs::{
        AnyInstrument, BondOption, BondStrike, CallableBond, CollateralSpec, Currency,
        DayCountFraction, FixedRateBond, Frequency, MarketContext, OptionKind, PriceBasis,
        PricingRiskFactors, QuoteSheet, RateVolSurface, VolConvention, YieldCurve,
        YieldQuoteConvention, forward_price,
    };
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    /// A yield vol surface with the same row at every expiry.
    fn surface(row: [f64; 3], convention: VolConvention) -> RateVolSurface {
        RateVolSurface::new(
            vec![0.25, 0.5, 1.0, 2.0],
            vec![2.0, 5.0, 10.0],
            &vec![row.to_vec(); 4],
            convention,
        )
        .expect("a well-formed surface")
    }
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let valuation = date(2026, 3, 2);
        let expiry = date(2026, 9, 2);
        let usd = Currency::new("USD").expect("a registered currency");
        let bond = FixedRateBond::bullet(
            usd,
            date(2025, 5, 15),
            date(2035, 5, 15),
            0.0425,
            Frequency::SemiAnnual,
            DayCountFraction::ActActIcma,
            100.0,
        )
        .expect("a well-formed bond");
        let underlying = CallableBond::new(bond.clone(), Vec::new());
        let option = |strike: BondStrike, kind: OptionKind| {
            BondOption::new(underlying.clone(), expiry, strike, kind).expect("a well-formed option")
        };
    
        let collateral = CollateralSpec::Currency(usd);
        let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
        let marked = MarketContext::as_of(valuation)
            .with_recipe_quotes(&collateral, &sheet)
            .expect("a marked market");
        let (curve, _) = marked
            .curve(&"discount(USD)".parse().expect("a curve id"))
            .expect("the USD curve");
        let market = marked
            .clone()
            .with_bond_yield_vols("USD", surface([0.20, 0.19, 0.18], VolConvention::Lognormal));
        let lifted = market.lift::<f64>();
        let view = market.view(&lifted).expect("a market that binds");
        let npv = |option: &BondOption| {
            view.npv(&AnyInstrument::BondOption(Box::new(option.clone())))
                .expect("a priced option")
        };
    
        // A call on the bond struck at 100 clean. Black's model prices it on the
        // forward dirty price, with a price volatility converted from the yield
        // volatility the surface quotes.
        let call = option(BondStrike::Clean(100.0), OptionKind::Call);
        let premium = npv(&call);
        let resolved = view
            .resolve(&call.risk_factors().expect("the option's market data"))
            .expect("the curve and the surface are pinned");
        let (_, seen) = call
            .npv_with_diagnostics(&resolved)
            .expect("the engine's inputs");
        let forward = seen.forward_dirty;
        let strike = seen.dirty_strike;
        let df = curve.view::<f64>().discount(expiry).expect("on the curve");
    
        println!("forward dirty     {forward:.6}");
        println!("dirty strike      {strike:.6}");
        println!("forward yield     {:.6}%", seen.forward_yield * 100.0);
        println!("forward duration  {:.6}", seen.forward_duration);
        println!("yield vol         {:.4}%", seen.yield_vol * 100.0);
        println!("price vol         {:.4}%", seen.price_vol * 100.0);
        println!("call premium      {premium:.6}");
    
        // A call less a put at the same strike is a forward purchase at the
        // strike, whatever the volatility: C - P = DF(T) (F - K).
        let put = option(BondStrike::Clean(100.0), OptionKind::Put);
        let put_premium = npv(&put);
    
        println!("call              {premium:.6}");
        println!("put               {put_premium:.6}");
        println!("call - put        {:.6}", premium - put_premium);
        println!("DF (F - K)        {:.6}", df * (forward - strike));
    
        // The same economic strike stated clean, dirty, or as the yield that
        // prices the bond at 100 clean on the exercise date. All three are one
        // option.
        let accrued = bond.accrued(expiry);
        let strike_yield = bond
            .yield_(
                100.0,
                expiry,
                PriceBasis::Clean,
                YieldQuoteConvention::Default,
                None,
            )
            .expect("a bracketed solve");
        let by_clean = npv(&call);
        let by_dirty = npv(&option(
            BondStrike::Dirty(100.0 + accrued),
            OptionKind::Call,
        ));
        let by_yield = npv(&option(BondStrike::Yield(strike_yield), OptionKind::Call));
    
        println!("strike yield      {:.6}%", strike_yield * 100.0);
        println!("struck clean      {by_clean:.6}");
        println!("struck dirty      {by_dirty:.6}");
        println!("struck at yield   {by_yield:.6}");
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import USD_CLOSE_MARCH
    
    VALUATION = dt.date(2026, 3, 2)
    EXPIRY = dt.date(2026, 9, 2)
    BOND = hlx.products.fixed_rate_bond(
        "USD", dt.date(2025, 5, 15), dt.date(2035, 5, 15), coupon=0.0425,
        frequency=hlx.dates.Frequency.SemiAnnual, day_count=hlx.dates.DayCountFraction.ActActIcma,
    )
    CALL = hlx.products.OptionKind.Call
    PUT = hlx.products.OptionKind.Put
    CLEAN = hlx.bonds.BondStrikeKind.Clean
    DIRTY = hlx.bonds.BondStrikeKind.Dirty
    YIELD = hlx.bonds.BondStrikeKind.Yield
    
    EXPIRIES = [0.25, 0.5, 1.0, 2.0]
    TENORS = [2.0, 5.0, 10.0]
    LOGNORMAL = hlx.vols.RateVolSurface(EXPIRIES, TENORS, [[0.20, 0.19, 0.18]] * 4, hlx.vols.VolConvention.Lognormal)
    MARKET = hlx.MarketContext(VALUATION).with_recipe_quotes("USD", USD_CLOSE_MARCH).with_bond_yield_vols("USD", LOGNORMAL)
    CURVE = MARKET.curve("discount(USD)")
    
    # A call on the bond struck at 100 clean. Black's model prices it on the
    # forward dirty price, with a price volatility converted from the yield
    # volatility the surface quotes.
    call = hlx.products.bond_option(BOND, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=CALL)
    premium = call.npv(MARKET)
    seen = call.diagnostics(MARKET)
    forward = seen.forward_dirty()
    strike = seen.dirty_strike()
    df = CURVE.discount(EXPIRY)
    
    print(f"forward dirty     {forward:.6f}")
    print(f"dirty strike      {strike:.6f}")
    print(f"forward yield     {seen.forward_yield() * 100:.6f}%")
    print(f"forward duration  {seen.forward_duration():.6f}")
    print(f"yield vol         {seen.yield_vol() * 100:.4f}%")
    print(f"price vol         {seen.price_vol() * 100:.4f}%")
    print(f"call premium      {premium:.6f}")
    
    # A call less a put at the same strike is a forward purchase at the strike,
    # whatever the volatility: C - P = DF(T) (F - K).
    put = hlx.products.bond_option(BOND, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=PUT)
    put_premium = put.npv(MARKET)
    
    print(f"call              {premium:.6f}")
    print(f"put               {put_premium:.6f}")
    print(f"call - put        {premium - put_premium:.6f}")
    print(f"DF (F - K)        {df * (forward - strike):.6f}")
    
    # The same economic strike stated clean, dirty, or as the yield that prices
    # the bond at 100 clean on the exercise date. All three are one option.
    accrued = BOND.accrued(EXPIRY)
    strike_yield = BOND.yield_(100.0, EXPIRY, hlx.bonds.PriceBasis.Clean)
    by_clean = call.npv(MARKET)
    by_dirty = hlx.products.bond_option(BOND, EXPIRY, strike=100.0 + accrued, strike_kind=DIRTY, kind=CALL).npv(MARKET)
    by_yield = hlx.products.bond_option(BOND, EXPIRY, strike=strike_yield, strike_kind=YIELD, kind=CALL).npv(MARKET)
    
    print(f"strike yield      {strike_yield * 100:.6f}%")
    print(f"struck clean      {by_clean:.6f}")
    print(f"struck dirty      {by_dirty:.6f}")
    print(f"struck at yield   {by_yield:.6f}")
    forward dirty     101.814799
    dirty strike      101.270380
    forward yield     4.174009%
    forward duration  7.128998
    yield vol         18.5742%
    price vol         5.5270%
    call premium      1.838924
    call              1.838924
    put               1.305438
    call - put        0.533485
    DF (F - K)        0.533485
    strike yield      4.249255%
    struck clean      1.838924
    struck dirty      1.838924
    struck at yield   1.838924

    The dirty strike is the 100 clean strike plus the accrued at exercise. The price vol is the 18.57% yield vol, read off the surface at the forward duration, times the forward duration times the forward yield. The call less the put equals the discount factor times the forward less the strike, whatever the vol. Struck clean, dirty or at the yield that prices the bond at 100, the option is one option.

    How it's priced

    With FF the forward dirty price, KK the dirty strike, TT the expiry in years on ACT/365 Fixed, and the price vol σP\sigma_P converted from the yield vol σy\sigma_y through the forward duration DFD_F:

    C=DF(T)(F Φ(d1)−K Φ(d2)),σP=σy DF yF (lognormal),σP=σy DF F (normal),C−P=DF(T) (F−K).C = DF(T)\bigl(F\,\Phi(d_1) - K\,\Phi(d_2)\bigr), \qquad \sigma_P = \sigma_y\,D_F\,y_F \ \text{(lognormal)}, \qquad \sigma_P = \sigma_y\,D_F\,F \ \text{(normal)}, \qquad C - P = DF(T)\,(F - K).

    Under a normal surface the formula is Bachelier's. Under Hull-White, Jamshidian's decomposition splits the option into options on each flow. Why the option reads the forward rather than the spot, and both calculations worked by hand, are in how bonds work.

    API

    Argument Type Default Meaning
    bond FixedRateBond or Instrument required the underlying bullet bond; in Rust, a CallableBond with an empty call schedule
    expiry date required the exercise date
    strike float required the strike, in the form strike_kind names
    strike_kind bonds.BondStrikeKind or str "clean" Clean, Dirty or Yield
    kind products.OptionKind or str "call" Call or Put
    settlement date None, the expiry when the bond is delivered on exercise
    engine products.BondOptionEngine or str "yield_vol" YieldVol, Black on the forward, or HullWhite, Jamshidian's decomposition
    repo_key str None a repo curve to carry the bond to settlement on
    yield_vol str None, the bond currency's surface the key of the yield vol surface to read, as pinned by MarketContext.with_bond_yield_vols

    products.bond_option(bond, expiry, *, strike, strike_kind, kind, ...) books it; in Rust, BondOption::new(underlying, expiry, BondStrike::Clean(K), OptionKind::Call)?, with .with_engine(BondOptionEngine::HullWhite).

    Symbol Meaning Rust Python
    CC, PP the premium MarketView::npv on AnyInstrument::BondOption Instrument.npv
    FF, KK forward dirty price and dirty strike npv_with_diagnostics(..): forward_dirty, dirty_strike option.diagnostics(market).forward_dirty(), .dirty_strike()
    yFy_F, DFD_F forward yield and duration forward_yield, forward_duration .forward_yield(), .forward_duration()
    σy\sigma_y, σP\sigma_P yield and price vol yield_vol, price_vol .yield_vol(), .price_vol()
    TT expiry in years expiry_years .expiry_years()
    AI(Ts)AI(T_s) accrued at exercise accrued_at_exercise .accrued_at_exercise()

    Defaults and limits

    • Market inputs: the currency's discount curve, and a bond yield vol surface (MarketContext.with_bond_yield_vols) for the yield-vol engine, or Hull-White parameters (MarketContext.with_hull_white) for the Hull-White engine.
    • Amortising bonds: a clean or dirty strike is quoted per 100 of the face alive at exercise, and restated per 100 of the original face, the basis every price is reported on. A yield strike needs no restatement.
    • Exercise: European only. For a bond with embedded calls, see callable bond.

    More examples

    The same call priced on a normal (basis-point) yield vol surface, and the option on a version of the note that repays a fifth of its face on 15 May 2026, before expiry.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::USD_CLOSE_MARCH;
    use helixrs::{
        AnyInstrument, BondOption, BondStrike, CallableBond, CollateralSpec, Currency,
        DayCountFraction, FixedRateBond, Frequency, MarketContext, OptionKind, PricingRiskFactors,
        QuoteSheet, RateVolSurface, VolConvention, YieldCurve,
    };
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    /// A yield vol surface with the same row at every expiry.
    fn surface(row: [f64; 3], convention: VolConvention) -> RateVolSurface {
        RateVolSurface::new(
            vec![0.25, 0.5, 1.0, 2.0],
            vec![2.0, 5.0, 10.0],
            &vec![row.to_vec(); 4],
            convention,
        )
        .expect("a well-formed surface")
    }
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let valuation = date(2026, 3, 2);
        let expiry = date(2026, 9, 2);
        let usd = Currency::new("USD").expect("a registered currency");
        let bond = FixedRateBond::bullet(
            usd,
            date(2025, 5, 15),
            date(2035, 5, 15),
            0.0425,
            Frequency::SemiAnnual,
            DayCountFraction::ActActIcma,
            100.0,
        )
        .expect("a well-formed bond");
        let underlying = CallableBond::new(bond.clone(), Vec::new());
        let option = |strike: BondStrike, kind: OptionKind| {
            BondOption::new(underlying.clone(), expiry, strike, kind).expect("a well-formed option")
        };
    
        let collateral = CollateralSpec::Currency(usd);
        let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
        let marked = MarketContext::as_of(valuation)
            .with_recipe_quotes(&collateral, &sheet)
            .expect("a marked market");
        let market = marked
            .clone()
            .with_bond_yield_vols("USD", surface([0.20, 0.19, 0.18], VolConvention::Lognormal));
        let lifted = market.lift::<f64>();
        let view = market.view(&lifted).expect("a market that binds");
        let npv = |option: &BondOption| {
            view.npv(&AnyInstrument::BondOption(Box::new(option.clone())))
                .expect("a priced option")
        };
    
        // The six-month call struck at 100 clean, on the lognormal surface.
        let call = option(BondStrike::Clean(100.0), OptionKind::Call);
        let premium = npv(&call);
        let resolved = view.resolve(&call.risk_factors()?)?;
        println!("lognormal premium {premium:.6}");
    
        // Under a normal (basis-point) yield surface the conversion multiplies by
        // the forward price instead of the forward yield, and the premium is
        // Bachelier's.
        let normal_market = marked.clone().with_bond_yield_vols(
            "USD",
            surface([0.0090, 0.0085, 0.0080], VolConvention::Normal),
        );
        let normal_lifted = normal_market.lift::<f64>();
        let normal_view = normal_market
            .view(&normal_lifted)
            .expect("a market that binds");
        let normal_premium = normal_view
            .npv(&AnyInstrument::BondOption(Box::new(call.clone())))
            .expect("a priced option");
        let normal_resolved = normal_view
            .resolve(&call.risk_factors().expect("the option's market data"))
            .expect("the curve and the surface are pinned");
        let (_, normal_seen) = call
            .npv_with_diagnostics(&normal_resolved)
            .expect("the engine's inputs");
    
        println!(
            "yield vol         {:.4} bp",
            normal_seen.yield_vol * 10_000.0
        );
        println!("price vol         {:.6} per 100", normal_seen.price_vol);
        println!("call premium      {normal_premium:.6}");
    
        // The same note with a sinking profile: a fifth of the face is repaid on
        // 15 May 2026, before the option expires. The strike is quoted per 100 of
        // the face alive at exercise, and the engine restates it per 100 of the
        // original face, the basis every price is reported on.
        let mut profile = vec![1.0; 2];
        profile.extend(vec![0.8; 18]);
        let sinking = bond
            .clone()
            .with_amortisation(profile)
            .expect("a well-formed profile");
        let alive = sinking.outstanding_fraction(expiry);
        let on_sinking = CallableBond::new(sinking.clone(), Vec::new());
        let sinking_option = |kind: OptionKind| {
            BondOption::new(on_sinking.clone(), expiry, BondStrike::Clean(100.0), kind)
                .expect("a well-formed option")
        };
        let sinking_call = sinking_option(OptionKind::Call);
        let (_, sinking_seen) = sinking_call
            .npv_with_diagnostics(&resolved)
            .expect("the engine's inputs");
        let sinking_premium = npv(&sinking_call);
    
        println!("fraction alive    {alive:.2}");
        println!("dirty strike      {:.6}", sinking_seen.dirty_strike);
        println!("forward dirty     {:.6}", sinking_seen.forward_dirty);
        println!("call premium      {sinking_premium:.6}");
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import USD_CLOSE_MARCH
    
    VALUATION = dt.date(2026, 3, 2)
    EXPIRY = dt.date(2026, 9, 2)
    BOND = hlx.products.fixed_rate_bond(
        "USD", dt.date(2025, 5, 15), dt.date(2035, 5, 15), coupon=0.0425,
        frequency=hlx.dates.Frequency.SemiAnnual, day_count=hlx.dates.DayCountFraction.ActActIcma,
    )
    CALL = hlx.products.OptionKind.Call
    CLEAN = hlx.bonds.BondStrikeKind.Clean
    
    EXPIRIES = [0.25, 0.5, 1.0, 2.0]
    TENORS = [2.0, 5.0, 10.0]
    LOGNORMAL = hlx.vols.RateVolSurface(EXPIRIES, TENORS, [[0.20, 0.19, 0.18]] * 4, hlx.vols.VolConvention.Lognormal)
    NORMAL = hlx.vols.RateVolSurface(EXPIRIES, TENORS, [[0.0090, 0.0085, 0.0080]] * 4, hlx.vols.VolConvention.Normal)
    MARKET = hlx.MarketContext(VALUATION).with_recipe_quotes("USD", USD_CLOSE_MARCH).with_bond_yield_vols("USD", LOGNORMAL)
    
    # The six-month call struck at 100 clean, on the lognormal surface.
    call = hlx.products.bond_option(BOND, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=CALL)
    premium = call.npv(MARKET)
    print(f"lognormal premium {premium:.6f}")
    
    # Under a normal (basis-point) yield surface the conversion multiplies by the
    # forward price instead of the forward yield, and the premium is Bachelier's.
    normal_market = hlx.MarketContext(VALUATION).with_recipe_quotes("USD", USD_CLOSE_MARCH).with_bond_yield_vols("USD", NORMAL)
    normal_premium = call.npv(normal_market)
    normal_seen = call.diagnostics(normal_market)
    
    print(f"yield vol         {normal_seen.yield_vol() * 10_000:.4f} bp")
    print(f"price vol         {normal_seen.price_vol():.6f} per 100")
    print(f"call premium      {normal_premium:.6f}")
    
    # The same note with a sinking profile: a fifth of the face is repaid on
    # 15 May 2026, before the option expires. The strike is quoted per 100 of the
    # face alive at exercise, and the engine restates it per 100 of the original
    # face, the basis every price is reported on.
    sinking = BOND.with_amortisation([1.0] * 2 + [0.8] * 18)
    alive = sinking.outstanding_fraction(EXPIRY)
    sinking_call = hlx.products.bond_option(sinking, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=CALL)
    sinking_seen = sinking_call.diagnostics(MARKET)
    sinking_premium = sinking_call.npv(MARKET)
    
    print(f"fraction alive    {alive:.2f}")
    print(f"dirty strike      {sinking_seen.dirty_strike():.6f}")
    print(f"forward dirty     {sinking_seen.forward_dirty():.6f}")
    print(f"call premium      {sinking_premium:.6f}")
    lognormal premium 1.838924
    yield vol         82.8710 bp
    price vol         6.015088 per 100
    call premium      1.949858
    fraction alive    0.80
    dirty strike      81.016304
    forward dirty     81.451839
    call premium      1.471139

    Under the normal surface the price vol is per 100 face and the premium is Bachelier's. On the sinking note 80% of the face is alive at exercise, so the dirty strike and the forward are restated on the original face, and the premium is exactly 80% of the bullet's.

    See also

    • Source: crates/helixrs/src/instruments/bond_derivatives.rs
    • Related pages:
      • How bonds work, for the forward, the vol conversion, and both engines worked by hand.
      • Callable bond, for options embedded in the bond.
      • Rate vol surface, for the yield vol surface.
      • Hull-White, for the model behind the second engine.

    From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.