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.
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(())
}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 the issuer calls where continuing is worth more; on a put date the holder puts where it's worth less:
The OAS is the flat continuous spread on the discounting that reprices the clean quote . 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 |
|---|---|---|---|
| value on the lattice | MarketView::npv on AnyInstrument::Callable |
Instrument.npv |
|
| option-adjusted spread | callable_oas(&bond, &market, clean)? |
bonds.callable_oas(bond, market, clean) |
|
| clean price at a spread | clean_price_at_oas(&bond, &market, s)? |
bonds.clean_price_at_oas(bond, market, s) |
|
| OAS at a compounding frequency | compounded_oas(s, Frequency::SemiAnnual) |
models.compounded_oas(s, frequency) |
|
| yield to a call date | yield_to_call(&bond, dirty, date, s, convention, None)? |
bonds.yield_to_call(bond, dirty, date, s, convention) |
|
| 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_inputunderhull_white_mean_reversion_labelandhull_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.
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(())
}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.