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.
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(())
}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 the forward dirty price, the dirty strike, the expiry in years on ACT/365 Fixed, and the price vol converted from the yield vol through the forward duration :
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 |
|---|---|---|---|
| , | the premium | MarketView::npv on AnyInstrument::BondOption |
Instrument.npv |
| , | forward dirty price and dirty strike | npv_with_diagnostics(..): forward_dirty, dirty_strike |
option.diagnostics(market).forward_dirty(), .dirty_strike() |
| , | forward yield and duration | forward_yield, forward_duration |
.forward_yield(), .forward_duration() |
| , | yield and price vol | yield_vol, price_vol |
.yield_vol(), .price_vol() |
| expiry in years | expiry_years |
.expiry_years() |
|
| 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.
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(())
}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.