Esc

    The API is under testing and may change before release.

    Callable bond

    This page lists what you need to price a callable or putable bond: the constructor and its exercise schedule, the option-adjusted spread, the yields to call and to worst, effective risk, and examples you can run. A callable bond lets the issuer repay early, at set prices on set dates; a putable bond gives that right to the holder.

    Quick example

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

    This takes an eight-year USD corporate paying 5% semiannually on 30/360, callable at par in May 2028 and May 2030, on a SOFR curve with Hull-White parameters of 3% mean reversion and 1% vol. It values the bond with and without the calls, solves the option-adjusted spread of a 101.00 quote, reads the yields to each call and to worst, and the effective risk.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::USD_CLOSE_MARCH;
    use helixrs::{
        AnyInstrument, CallKind, CallPrice, Callability, CallableBond, CollateralSpec, Currency,
        DayCountFraction,
        FixedRateBond, Frequency, MarketContext, PriceBasis, QuoteSheet, YieldCurve,
        YieldQuoteConvention, callable_oas, clean_price_at_oas, compounded_oas, effective_risk,
        hull_white_mean_reversion_label, hull_white_sigma_label, yield_to_call,
        yield_to_maturity_scenario, yield_to_worst,
    };
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    const COUPON: f64 = 0.05;
    
    /// A fixed 5% semiannual 30/360 bullet from the common issue date.
    fn bullet(usd: Currency, maturity: NaiveDate) -> FixedRateBond {
        FixedRateBond::bullet(
            usd,
            date(2025, 5, 15),
            maturity,
            COUPON,
            Frequency::SemiAnnual,
            DayCountFraction::Thirty360,
            100.0,
        )
        .expect("a well-formed bond")
    }
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let settlement = date(2026, 3, 2);
        let first_call = date(2028, 5, 15);
        let second_call = date(2030, 5, 15);
        let usd = Currency::new("USD").expect("a registered currency");
        let bond = bullet(usd, date(2033, 5, 15));
        let ust = YieldQuoteConvention::UsStreet;
    
        let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
        let market = MarketContext::as_of(settlement)
            .with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)
            .expect("a marked market")
            .pin_input(hull_white_mean_reversion_label(usd), 0.03)
            .pin_input(hull_white_sigma_label(usd), 0.01);
        let callable = CallableBond::new(
            bond.clone(),
            vec![
                Callability::call(first_call, 100.0),
                Callability::call(second_call, 100.0),
            ],
        );
        let callable_any = AnyInstrument::Callable(Box::new(callable.clone()));
        let plain = CallableBond::new(bond.clone(), Vec::new());
        let lifted = market.lift::<f64>();
        let view = market.view(&lifted).expect("a market that binds");
    
        // The issuer holds the call, so the holder owns the bullet and is short
        // the option: the callable is worth less, by the option's value.
        let bullet_value = view.npv(&AnyInstrument::Callable(Box::new(plain.clone())))?;
        let callable_value = view.npv(&AnyInstrument::Callable(Box::new(callable.clone())))?;
        println!("bullet, dirty       {bullet_value:.6}");
        println!("callable, dirty     {callable_value:.6}");
        println!("issuer's call worth {:.6}", bullet_value - callable_value);
    
        // The option-adjusted spread is the flat continuous spread over the curve,
        // on the same lattice, that reprices the quoted clean price.
        let quote = 101.00;
        let oas = callable_oas(&callable_any, &market, quote)?;
        let repriced = clean_price_at_oas(&callable_any, &market, oas)?;
        let semiannual = compounded_oas(oas, Frequency::SemiAnnual);
        println!("quoted clean        {quote:.6}");
        println!("OAS, continuous     {:.4} bp", oas * 10_000.0);
        println!("OAS, semiannual     {:.4} bp", semiannual * 10_000.0);
        println!("repriced clean      {repriced:.6}");
    
        // Each call date is a scenario in which the bond redeems early at the call
        // price. The yield to worst is the lowest yield over those scenarios and
        // maturity.
        let dirty = quote + bond.accrued(settlement);
        let to_maturity = bond
            .yield_(dirty, settlement, PriceBasis::Dirty, ust, None)
            .expect("a bracketed solve");
        let to_first = yield_to_call(&callable_any, dirty, first_call, settlement, ust, None)?;
        let to_second = yield_to_call(&callable_any, dirty, second_call, settlement, ust, None)?;
        let (worst_date, worst) = yield_to_worst(&callable_any, dirty, settlement, ust, None)?;
        println!("yield to maturity   {:.6}%", to_maturity * 100.0);
        println!("yield to {first_call}  {:.6}%", to_first * 100.0);
        println!("yield to {second_call}  {:.6}%", to_second * 100.0);
        println!("yield to worst      {:.6}% on {worst_date}", worst * 100.0);
    
        // The call decision moves when rates move, so the bullet's yield duration
        // doesn't apply. Effective measures shift the whole discounting on the
        // lattice and let the exercise decision respond.
        let moments = effective_risk(&callable_any, &market, oas)?;
        let (duration, convexity, dv01) =
            (moments.effective_duration(), moments.effective_convexity(), moments.effective_dv01());
        println!("effective duration  {duration:.6}");
        println!("effective convexity {convexity:.6}");
        println!("effective DV01      {dv01:.6}");
    
        Ok(())
    }
    Python
    import datetime as dt
    
    import helixio as hlx
    from desk_sheets import USD_CLOSE_MARCH
    
    SETTLEMENT = dt.date(2026, 3, 2)
    ISSUE = dt.date(2025, 5, 15)
    MATURITY = dt.date(2033, 5, 15)
    FIRST_CALL = dt.date(2028, 5, 15)
    SECOND_CALL = dt.date(2030, 5, 15)
    COUPON = 0.05
    BOND = hlx.products.fixed_rate_bond(
        "USD", ISSUE, MATURITY, coupon=COUPON, frequency=hlx.dates.Frequency.SemiAnnual,
        day_count=hlx.dates.DayCountFraction.Thirty360,
    )
    CALL = hlx.bonds.CallKind.Call
    UST = hlx.bonds.YieldConvention.UsStreet
    DIRTY = hlx.bonds.PriceBasis.Dirty
    
    MARKET = (
        hlx.MarketContext(SETTLEMENT)
        .with_recipe_quotes("USD", USD_CLOSE_MARCH)
        .with_hull_white("USD", 0.03, 0.01)
    )
    CALLABLE = hlx.products.callable_bond(BOND, [(FIRST_CALL, 100.0, CALL), (SECOND_CALL, 100.0, CALL)])
    BULLET = hlx.products.callable_bond(BOND, [])
    
    # The issuer holds the call, so the holder owns the bullet and is short the
    # option: the callable is worth less, by the option's value.
    bullet_value = BULLET.npv(MARKET)
    callable_value = CALLABLE.npv(MARKET)
    print(f"bullet, dirty       {bullet_value:.6f}")
    print(f"callable, dirty     {callable_value:.6f}")
    print(f"issuer's call worth {bullet_value - callable_value:.6f}")
    
    # The option-adjusted spread is the flat continuous spread over the curve,
    # on the same lattice, that reprices the quoted clean price.
    quote = 101.00
    oas = hlx.bonds.callable_oas(CALLABLE, MARKET, quote)
    repriced = hlx.bonds.clean_price_at_oas(CALLABLE, MARKET, oas)
    semiannual = hlx.models.compounded_oas(oas, hlx.dates.Frequency.SemiAnnual)
    print(f"quoted clean        {quote:.6f}")
    print(f"OAS, continuous     {oas * 10_000:.4f} bp")
    print(f"OAS, semiannual     {semiannual * 10_000:.4f} bp")
    print(f"repriced clean      {repriced:.6f}")
    
    # Each call date is a scenario in which the bond redeems early at the call
    # price. The yield to worst is the lowest yield over those scenarios and
    # maturity.
    dirty = quote + BOND.accrued(SETTLEMENT)
    to_maturity = BOND.yield_(dirty, SETTLEMENT, DIRTY, UST)
    to_first = hlx.bonds.yield_to_call(CALLABLE, dirty, FIRST_CALL, SETTLEMENT, UST)
    to_second = hlx.bonds.yield_to_call(CALLABLE, dirty, SECOND_CALL, SETTLEMENT, UST)
    worst_date, worst = hlx.bonds.yield_to_worst(CALLABLE, dirty, SETTLEMENT, UST)
    print(f"yield to maturity   {to_maturity * 100:.6f}%")
    print(f"yield to {FIRST_CALL}  {to_first * 100:.6f}%")
    print(f"yield to {SECOND_CALL}  {to_second * 100:.6f}%")
    print(f"yield to worst      {worst * 100:.6f}% on {worst_date}")
    
    # The call decision moves when rates move, so the bullet's yield duration
    # doesn't apply. Effective measures shift the whole discounting on the
    # lattice and let the exercise decision respond.
    duration, convexity, dv01 = hlx.bonds.effective_risk(CALLABLE, MARKET, oas)
    print(f"effective duration  {duration:.6f}")
    print(f"effective convexity {convexity:.6f}")
    print(f"effective DV01      {dv01:.6f}")
    bullet, dirty       107.244321
    callable, dirty     102.552310
    issuer's call worth 4.692012
    quoted clean        101.000000
    OAS, continuous     1.9515 bp
    OAS, semiannual     1.9516 bp
    repriced clean      101.000000
    yield to maturity   4.832752%
    yield to 2028-05-15  4.514735%
    yield to 2030-05-15  4.732948%
    yield to worst      4.514735% on 2028-05-15
    effective duration  3.357160
    effective convexity 15.339531
    effective DV01      0.033907

    The holder owns the bullet and is short the issuer's call, so the callable is worth less by the call's value. The OAS reprices the quote on the same lattice. A par call on a coupon date is the same flows as a shorter bond, so the yield to that call is the shorter bond's yield; the yield to worst is the lowest of the three.

    How it's priced

    The lattice carries the bond's value back from maturity. On a call date with call price KK the issuer calls where continuing is worth more; on a put date the holder puts where it's worth less:

    V←min⁡(Vcontinue,K) (call),V←max⁡(Vcontinue,K) (put),Plattice(s)=P,sm=m (es/m−1).V \leftarrow \min(V_{\text{continue}}, K) \ \text{(call)}, \qquad V \leftarrow \max(V_{\text{continue}}, K) \ \text{(put)}, \qquad P_{\text{lattice}}(s) = P, \qquad s_m = m\,(e^{s/m} - 1).

    The OAS ss is the flat continuous spread on the discounting that reprices the clean quote PP. Effective duration and convexity shift the whole discounting on the lattice and let the exercise decision respond. Why each side owns its option, and what the yields leave out, is covered in how bonds work.

    API

    Argument Type Default Meaning
    bond FixedRateBond required the bond, bullet or amortising
    schedule list[(date, float, CallKind)] or list[(date, float, CallKind, "dirty")] required; [] for a bullet each exercise date, its price per 100 and who holds it; a fourth element "dirty" makes the price dirty
    issuer str None tags the issuer, so a credit curve values the bond risky
    security_spread str None a key whose spread curve the bond discounts under
    make_whole list[(date, float)] [] make-whole calls at a spread in basis points over the benchmark
    exercise str "bermudan" "bermudan" exercises each right on its date only; "american" makes each right exercisable from its date until the next entry

    products.callable_bond(bond, schedule, issuer, security_spread, *, make_whole, exercise) books it; in Rust, CallableBond::new(bond, vec![Callability::call(date, K), Callability::put(..)]), with CallPrice::Dirty(K) and CallPrice::MakeWhole { spread_bp } for the other prices.

    Symbol Meaning Rust Python
    VV value on the lattice MarketView::npv on AnyInstrument::Callable Instrument.npv
    ss option-adjusted spread callable_oas(&bond, &market, clean)? bonds.callable_oas(bond, market, clean)
    Plattice(s)P_{\text{lattice}}(s) clean price at a spread clean_price_at_oas(&bond, &market, s)? bonds.clean_price_at_oas(bond, market, s)
    sms_m OAS at a compounding frequency compounded_oas(s, Frequency::SemiAnnual) models.compounded_oas(s, frequency)
    yTcy_{T_c} yield to a call date yield_to_call(&bond, dirty, date, s, convention, None)? bonds.yield_to_call(bond, dirty, date, s, convention)
    yworsty_{\text{worst}} yield to worst and its date yield_to_worst(&bond, dirty, s, convention, None)? bonds.yield_to_worst(bond, dirty, s, convention)
    maturity yield the maturity scenario yield_to_maturity_scenario(..)? bonds.yield_to_maturity_scenario(..)
    effective risk duration, convexity, DV01 effective_risk(&bond, &market, s)? bonds.effective_risk(bond, market, s)

    Defaults and limits

    • Market inputs: the currency's discount curve and Hull-White parameters for the currency, MarketContext.with_hull_white(currency, a, sigma); in Rust, pin_input under hull_white_mean_reversion_label and hull_white_sigma_label. Higher vol makes the issuer's option worth more and the bond worth less.
    • Call prices are clean per 100 unless marked dirty. On an amortising bond they're quoted per 100 of the face alive on the call date, and values per 100 of the original face.
    • Make-whole calls are booked and reported, but not exercised on the lattice.
    • A bullet: an empty schedule is the plain bond, priced off the curve.

    More examples

    The same bond with a put instead of a call, with calls at three prices on a sinking version, and with dirty and make-whole call prices.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::USD_CLOSE_MARCH;
    use helixrs::{
        AnyInstrument, CallKind, CallPrice, Callability, CallableBond, CollateralSpec, Currency,
        DayCountFraction, FixedRateBond, Frequency, MarketContext, QuoteSheet, YieldCurve,
        hull_white_mean_reversion_label, hull_white_sigma_label,
    };
    
    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
    }
    
    const COUPON: f64 = 0.05;
    
    /// A fixed 5% semiannual 30/360 bullet from the common issue date.
    fn bullet(usd: Currency, maturity: NaiveDate) -> FixedRateBond {
        FixedRateBond::bullet(
            usd,
            date(2025, 5, 15),
            maturity,
            COUPON,
            Frequency::SemiAnnual,
            DayCountFraction::Thirty360,
            100.0,
        )
        .expect("a well-formed bond")
    }
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let settlement = date(2026, 3, 2);
        let first_call = date(2028, 5, 15);
        let second_call = date(2030, 5, 15);
        let usd = Currency::new("USD").expect("a registered currency");
        let bond = bullet(usd, date(2033, 5, 15));
    
        let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
        let market = MarketContext::as_of(settlement)
            .with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)
            .expect("a marked market")
            .pin_input(hull_white_mean_reversion_label(usd), 0.03)
            .pin_input(hull_white_sigma_label(usd), 0.01);
        let plain = CallableBond::new(bond.clone(), Vec::new());
        let lifted = market.lift::<f64>();
        let view = market.view(&lifted).expect("a market that binds");
    
        // A put at par in May 2028 is the holder's option, so it adds value to the
        // bullet rather than subtracting it.
        let bullet_value = view.npv(&AnyInstrument::Callable(Box::new(plain.clone())))?;
        let putable = CallableBond::new(bond.clone(), vec![Callability::put(first_call, 100.0)]);
        let putable_value = view.npv(&AnyInstrument::Callable(Box::new(putable)))?;
        println!("bullet, dirty       {bullet_value:.6}");
        println!("putable, dirty      {putable_value:.6}");
        println!("holder's put worth  {:.6}", putable_value - bullet_value);
    
        // The same bond repaying a quarter of its face in May 2029, a quarter in May
        // 2031 and the rest at maturity. A call price is quoted per 100 of the face
        // alive on the call date, three quarters in May 2030.
        let mut profile = vec![1.0; 8];
        profile.extend(vec![0.75; 4]);
        profile.extend(vec![0.5; 4]);
        let sinking = bond.clone().with_amortisation(profile.clone())?;
        let alive = sinking.outstanding_fraction(second_call);
        let called_at = |calls: Vec<Callability>| {
            view.npv(&AnyInstrument::Callable(Box::new(CallableBond::new(sinking.clone(), calls))))
        };
        let sinking_value = called_at(Vec::new())?;
        let out_of_reach = called_at(vec![Callability::call(second_call, 200.0)])?;
        let certain = called_at(vec![Callability::call(second_call, 50.0)])?;
        let at_par = called_at(vec![Callability::call(second_call, 100.0)])?;
        println!("face alive at the call  {alive:.2}");
        println!("sinking bond, dirty     {sinking_value:.6}");
        println!("call at 200             {out_of_reach:.6}");
        println!("call at 50              {certain:.6}");
        println!("call at par             {at_par:.6}");
    
        // A call price is clean unless the schedule says dirty. Between coupons a
        // dirty price carries the accrued inside it. A make-whole call is booked
        // and reported, but not exercised on the lattice.
        let mid_period = date(2028, 8, 15);
        let accrued = bond.accrued(mid_period);
        let value_with = |entries: Vec<Callability>| {
            view.npv(&AnyInstrument::Callable(Box::new(CallableBond::new(bond.clone(), entries))))
        };
        let entry = |price: CallPrice| Callability { date: mid_period, price, kind: CallKind::Call };
        let clean_call = value_with(vec![Callability::call(mid_period, 100.0)])?;
        let dirty_call = value_with(vec![entry(CallPrice::Dirty(100.0 + accrued))])?;
        let dirty_at_par = value_with(vec![entry(CallPrice::Dirty(100.0))])?;
        let make_whole = value_with(vec![Callability {
            date: first_call,
            price: CallPrice::MakeWhole { spread_bp: 25.0 },
            kind: CallKind::Call,
        }])?;
        println!("accrued at the call     {accrued:.6}");
        println!("clean call at 100       {clean_call:.6}");
        println!("dirty call at 100+acc   {dirty_call:.6}");
        println!("dirty call at 100       {dirty_at_par:.6}");
        println!("make-whole only         {make_whole:.6}");
    
        Ok(())
    }
    Python
    import datetime as dt
    import helixio as hlx
    from desk_sheets import USD_CLOSE_MARCH
    
    SETTLEMENT = dt.date(2026, 3, 2)
    ISSUE = dt.date(2025, 5, 15)
    MATURITY = dt.date(2033, 5, 15)
    FIRST_CALL = dt.date(2028, 5, 15)
    SECOND_CALL = dt.date(2030, 5, 15)
    COUPON = 0.05
    BOND = hlx.products.fixed_rate_bond(
        "USD", ISSUE, MATURITY, coupon=COUPON, frequency=hlx.dates.Frequency.SemiAnnual,
        day_count=hlx.dates.DayCountFraction.Thirty360,
    )
    CALL = hlx.bonds.CallKind.Call
    PUT = hlx.bonds.CallKind.Put
    
    MARKET = (
        hlx.MarketContext(SETTLEMENT)
        .with_recipe_quotes("USD", USD_CLOSE_MARCH)
        .with_hull_white("USD", 0.03, 0.01)
    )
    BULLET = hlx.products.callable_bond(BOND, [])
    
    # A put at par in May 2028 is the holder's option, so it adds value to the
    # bullet rather than subtracting it.
    bullet_value = BULLET.npv(MARKET)
    putable_value = hlx.products.callable_bond(BOND, [(FIRST_CALL, 100.0, PUT)]).npv(MARKET)
    print(f"bullet, dirty       {bullet_value:.6f}")
    print(f"putable, dirty      {putable_value:.6f}")
    print(f"holder's put worth  {putable_value - bullet_value:.6f}")
    
    # The same bond repaying a quarter of its face in May 2029, a quarter in May
    # 2031 and the rest at maturity. A call price is quoted per 100 of the face
    # alive on the call date, three quarters in May 2030.
    PROFILE = [1.0] * 8 + [0.75] * 4 + [0.5] * 4
    sinking = BOND.with_amortisation(PROFILE)
    alive = sinking.outstanding_fraction(SECOND_CALL)
    sinking_value = hlx.products.callable_bond(sinking, []).npv(MARKET)
    out_of_reach = hlx.products.callable_bond(sinking, [(SECOND_CALL, 200.0, CALL)]).npv(MARKET)
    certain = hlx.products.callable_bond(sinking, [(SECOND_CALL, 50.0, CALL)]).npv(MARKET)
    at_par = hlx.products.callable_bond(sinking, [(SECOND_CALL, 100.0, CALL)]).npv(MARKET)
    print(f"face alive at the call  {alive:.2f}")
    print(f"sinking bond, dirty     {sinking_value:.6f}")
    print(f"call at 200             {out_of_reach:.6f}")
    print(f"call at 50              {certain:.6f}")
    print(f"call at par             {at_par:.6f}")
    
    # A call price is clean unless the schedule says "dirty". Between coupons a
    # dirty price carries the accrued inside it. A make-whole call is booked and
    # reported, but not exercised on the lattice.
    mid_period = dt.date(2028, 8, 15)
    accrued = BOND.accrued(mid_period)
    clean_call = hlx.products.callable_bond(BOND, [(mid_period, 100.0, CALL)]).npv(MARKET)
    dirty_call = hlx.products.callable_bond(BOND, [(mid_period, 100.0 + accrued, CALL, "dirty")]).npv(MARKET)
    dirty_at_par = hlx.products.callable_bond(BOND, [(mid_period, 100.0, CALL, "dirty")]).npv(MARKET)
    make_whole = hlx.products.callable_bond(BOND, [], make_whole=[(FIRST_CALL, 25.0)])
    print(f"accrued at the call     {accrued:.6f}")
    print(f"clean call at 100       {clean_call:.6f}")
    print(f"dirty call at 100+acc   {dirty_call:.6f}")
    print(f"dirty call at 100       {dirty_at_par:.6f}")
    print(f"make-whole only         {make_whole.npv(MARKET):.6f}")
    bullet, dirty       107.244321
    putable, dirty      108.132894
    holder's put worth  0.888573
    face alive at the call  0.75
    sinking bond, dirty     106.343734
    call at 200             106.343734
    call at 50              73.466900
    call at par             104.671993
    accrued at the call     1.250000
    clean call at 100       102.946715
    dirty call at 100+acc   102.946715
    dirty call at 100       102.099153
    make-whole only         107.244321

    The put is the holder's, so it adds value. On the sinking bond, a call at 200 is never used and leaves the curve value, a call at 50 is always used, and a call at par sits between them. A dirty call at par plus the accrued is the clean call at par, and a dirty call at par is cheaper for the issuer. A make-whole call alone leaves the bullet.

    See also

    • Source: crates/helixrs/src/instruments/bonds.rs, with the lattice pricing in the Hull-White engine.
    • Related pages:
      • How bonds work, for the option and the numbers it's quoted on.
      • Fixed-rate bond, for the bond and its amortisation.
      • Calibrate Hull-White and pin it, for fitting the model the lattice reads.
      • Bond conventions, for each market's yield convention.
      • Bond option, for a European option on a bond.

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