How bonds work
You'll come away knowing how a bond is quoted, what its price depends on, and how each bond on these pages varies the plain fixed-rate bond, so the reference pages read as a list of arguments rather than a puzzle. Nothing here is a step to follow; the how-to guides do that.
Price, yield and accrued
A bond's screen price is clean: it leaves out the interest earned since the last coupon. The buyer pays that earned interest, the accrued, on top, so the cash that changes hands is the dirty price. Markets quote clean so the price doesn't drop on every coupon date just because a coupon was paid.
Traders talk in yield: the one rate that discounts the bond's remaining flows to its dirty price. It carries no extra information; it's the price restated. What it needs is a convention, the compounding it's stated in. US Treasuries and gilts quote a semiannual yield, Bunds an annual one, and models often a continuous one. Two yields describe the same price when they give every flow the same discount, , so a yield quoted without its convention isn't a number you can use.
The price falls strictly as the yield rises, so each price has exactly one yield. Modified duration is the percentage price change per unit of yield, convexity how that changes, and DV01 the price change per 100 face for one basis point. Macaulay duration is the present-value weighted mean time of the flows; under periodic compounding it's modified duration times .
A curve prices each flow at its own discount factor instead of one yield. The gap between the curve price and the market's price is what spread measures explain.
The program below takes the bond's own flows, as times in years and amounts per 100, and does the discounting in the open: the price at a 4.40% street yield, and Macaulay duration as the present-value weighted mean time. Both land on what the library returns; the short way is on fixed-rate bond.
use chrono::NaiveDate;
use helixrs::{
Currency, DayCountFraction, FixedRateBond, Frequency, PriceBasis, YieldQuoteConvention,
pv_weighted_time,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let settlement = date(2026, 3, 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 ust = YieldQuoteConvention::UsStreet;
let basis = ust.basis(bond.frequency).expect("a compounding convention");
// Each remaining flow as the pricing body sees it: the time to it in
// years, counted in coupon periods under ACT/ACT ICMA, and its amount per
// 100 face. The last one carries the redemption.
let flows = bond.timed_flows_per_100(settlement, None);
let first_time = flows[0].0;
let last = flows[flows.len() - 1];
// The first flow is 74 days of a 181-day half-year away; later flows are
// whole half-years after it.
println!("flows remaining {}", flows.len());
println!("first at {first_time:.6} years, {:.6}", flows[0].1);
println!("last at {:.6} years, {:.6}", last.0, last.1);
// The street yield compounds twice a year, so each flow is worth its
// amount times (1 + y/2)^(-2t). Summed in schedule order, this is the
// library's own body, so it lands on the same bits.
let y: f64 = 0.044;
let mut price_dirty = 0.0;
let mut time_weighted = 0.0;
for &(t, amount) in &flows {
let present = (y / 2.0 + 1.0).powf(-2.0 * t) * amount;
price_dirty += present;
time_weighted += t * present;
}
let library = bond.price(y, settlement, PriceBasis::Dirty, ust, None);
// Macaulay duration is the present-value-weighted mean time of the flows.
let macaulay = pv_weighted_time(&flows, y, basis);
println!("dirty at 4.40% {price_dirty:.6}");
println!(" the library {library:.6}");
println!("Macaulay duration {macaulay:.6}");
println!(" by hand {:.6}", time_weighted / price_dirty);
Ok(())
}import datetime as dt
import helixio as hlx
SETTLEMENT = dt.date(2026, 3, 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,
)
UST = hlx.bonds.YieldConvention.UsStreet
# Each remaining flow as the pricing body sees it: the time to it in
# years, counted in coupon periods under ACT/ACT ICMA, and its amount per
# 100 face. The last one carries the redemption.
table = BOND.flows(SETTLEMENT).to_dict()
flows = list(zip(table["time"], table["amount"]))
first_time = flows[0][0]
last = flows[-1]
# The first flow is 74 days of a 181-day half-year away; later flows are
# whole half-years after it.
print(f"flows remaining {len(flows)}")
print(f"first at {first_time:.6f} years, {flows[0][1]:.6f}")
print(f"last at {last[0]:.6f} years, {last[1]:.6f}")
# The street yield compounds twice a year, so each flow is worth its
# amount times (1 + y/2)^(-2t). Summed in schedule order, this is the
# library's own body, so it lands on the same bits.
y = 0.044
price_dirty = 0.0
time_weighted = 0.0
for t, amount in flows:
present = (y / 2.0 + 1.0) ** (-2.0 * t) * amount
price_dirty += present
time_weighted += t * present
# Macaulay duration is the present-value-weighted mean time of the flows.
macaulay = hlx.bonds.pv_weighted_time(flows, y, UST)
library = BOND.price(y, SETTLEMENT, hlx.bonds.PriceBasis.Dirty, UST)
print(f"dirty at 4.40% {price_dirty:.6f}")
print(f" the library {library:.6f}")
print(f"Macaulay duration {macaulay:.6f}")
print(f" by hand {time_weighted / price_dirty:.6f}")flows remaining 19
first at 0.204420 years, 2.125000
last at 9.204420 years, 102.125000
dirty at 4.40% 100.125335
the library 100.125335
Macaulay duration 7.607552
by hand 7.607552
Zero-coupon bonds
A zero pays no coupon: you buy it below its face and receive the face, so the discount is your interest. Three numbers are called "the yield". The discount yield is the discount as a share of the face, per year, and it's what the zero is struck on. The money-market yield is the same discount as a share of the price you paid, so it's always the higher of the two below par. The bond-equivalent yield compounds twice a year on ACT/365 Fixed, so a zero can stand beside a coupon note.
Which of these a market quotes is fixed by the zero's registered calculation method, not by the caller: French and Japanese discount paper quote simple interest, Norwegian zeros an annually compounded yield. A zero's value moves only with the discount factor to its maturity.
Payment-in-kind bonds
A PIK bond pays its coupon in more bond. Each coupon date adds the interest to the face, so every later coupon accrues on a larger amount. PIK notes appear where cash is scarce: a leveraged buyout that can't service interest in its first years, a distressed issuer converting cash coupons to survive a restructuring, a holding company whose cash is trapped in its subsidiaries. The coupon is high because the holder lends more every period to a borrower whose credit is already in question.
The face grows by the product of one plus the rate times each period's own year fraction, not by : on ACT/365 Fixed, for example, a period holding a leap day accrues 366 days over 365. With no cash coupon, the bond has one flow, the accreted face at maturity. A PIK trades per 100 of par face or per 100 of accreted face, and the two differ by the accretion factor at settlement, so always ask which basis a quote is on.
Floating-rate notes
A floating-rate note pays a term index plus a fixed margin each period. The running coupon has fixed already, so it pays the published rate; every later coupon projects the index's forward from the projection curve, and every flow is discounted on the currency's discount curve. When the projection curve sits above the discount curve, a note paying a margin on top is worth more than par.
A floater trades on its margin. The discount margin is the flat spread on the discounting that reprices a quote, with the projected coupons held fixed. The simple margin skips the curves: the pull to par spread evenly over the remaining years, added to the quoted margin. Because each coupon resets, a floater's rate risk is small and resets every period, but its spread duration, the sensitivity to a spread on the discounting, runs to maturity.
Spread measures
A spread turns a bond's price into a number of basis points over a reference curve, so bonds of different coupons and maturities can be compared.
- The z-spread is the one continuous spread that, added to every zero rate of the curve, discounts the bond's flows to its dirty price. It's unique, because the price falls strictly as the spread rises. Pinned in the market as the bond's own security spread, it reprices the quote exactly.
- The G-spread is the bond's yield over the government curve's yield at the bond's maturity, and the I-spread the same over the swap curve. They differ by exactly the swap spread at maturity.
- Spread duration is the relative price fall for a unit rise in the z-spread: the present-value weighted mean time of the flows at the curve plus the spread.
Asset swaps and repack notes
An asset swap packages a fixed-rate bond with a swap that turns its coupons into a floating rate plus a spread, so an investor holds the bond's credit without its rate risk.
A repack note puts that package inside a special-purpose vehicle. The vehicle buys the bond, pays its coupons to a bank under the swap, and receives the floating rate plus the spread, which it passes to the noteholders; the notes redeem from the bond. Since the swap is between the vehicle's own two parties, the bank's side and every note together hold exactly the bond. If the issuer defaults, the swap terminates and the bond is sold for its recovery; the bank is paid its swap claim first. When the swap owes the bank more than the recovery covers, the bank loses the difference. That loss is the gap, and its expected discounted value is the gap charge a desk reserves for. A higher recovery covers more, so the charge falls as recovery rises.
Bond futures
A bond future is an exchange-traded contract to deliver a government bond in a set month. The seller chooses which bond from a basket of deliverables, scaled by each one's conversion factor: roughly its price per unit of face at the contract's notional yield. The buyer pays the futures price times the factor, plus accrued; that's the invoice.
| Exchange | Contracts | Notional yield | Life measured from |
|---|---|---|---|
| CME | US Treasury note and bond futures | 6%, semiannual | the first day of the delivery month, rounded down to a quarter or a month |
| Eurex | Schatz, Bobl, Bund and Buxl | 6%, annual | the delivery day |
| ICE | Long Gilt | 4%, semiannual | the first day of the delivery month |
Factors are exact only at the notional yield. At any other yield one bond is cheaper to deliver than the rest, and the seller delivers it, so the future trades as a forward on that cheapest to deliver. Traders watch three numbers per bond: the gross basis, the clean price less the futures price times the factor; the net basis, which takes out the carry to delivery; and the implied repo, the financing rate earned by buying the bond and delivering it. The cheapest to deliver has the smallest net basis. A position is margined daily, so its value isn't discounted.
Inflation-linked bonds
A linker pays a fixed real coupon on a principal that grows with a consumer price index. Governments issue most of them, and pension funds and insurers buy them to match liabilities that rise with prices.
The quote is real and the cash is nominal. A linker trades on a real price and a real yield, both describing the uninflated flows as if the index never moved. At settlement the buyer pays the real price and real accrued, each multiplied by the index ratio: the reference index on that date over the bond's base index. Each market rounds the reference index and the ratio its own way before money changes hands.
A price index is published once a month, weeks after the month ends, so a linker reads it with a lag: three months for US TIPS and most modern linkers. The reference index for a day moves in a straight line between the two monthly values it sits between. Published months come from the fixings; later ones from an inflation curve. Many linkers carry a deflation floor: the redemption never falls below the face, even if prices fall over the bond's life.
Callable bonds
A callable lets the issuer repay early, at set prices on set dates. Companies and agencies issue them so they can refinance if rates fall, and investors are paid a higher coupon for granting the option. A call belongs to the issuer, who exercises when the bond would be worth more than the call price, so the holder is short that option and a callable is worth less than the same bond without it. A put belongs to the holder and adds value.
Callables trade on a clean price, with three numbers read off it. The yield to call is the yield if the bond redeems on one call date; the yield to worst is the lowest over every call date and maturity. Both ignore what the option is worth. The option-adjusted spread accounts for it: the spread over the curve left once the option is valued on a short-rate lattice, so it compares callables with different schedules. Because a callable's flows depend on rates, a yield duration doesn't apply; effective duration shifts the whole discounting and lets the exercise decision respond. Higher rate vol makes the issuer's option worth more and the bond worth less.
Bond options
A bond option is the right to buy or sell a bond at a set price on a set date. Exercised only at expiry, with the bond delivered then, a European option depends on the bond's forward price, the spot carried to that date, not on the spot alone. Its strike can be stated clean, dirty, or as a yield; all three describe one cash amount, the dirty strike.
Bond option vol is quoted on the yield, by expiry and by the bond's forward duration, either lognormal (a relative vol) or normal (basis points). The pricing formula needs a price vol, so the yield vol is converted through the forward duration: times the forward yield for a lognormal surface, times the forward price for a normal one. Under a one-factor short-rate model instead, every discount bond falls as the short rate rises, so an option on a coupon bond splits into options on each flow paid after exercise, Jamshidian's decomposition.
The program reads its swap quotes from the desk sheets; load them once and it runs as shown. It does both by hand: the engine's resolved inputs put through Black's formula and discounted from expiry, and the Hull-White decomposition over the eighteen flows after expiry, with its zero-vol limit against the discounted intrinsic value. Each lands on the booked option; the short way is on bond option.
use chrono::NaiveDate;
use helixio_docs_sheets::USD_CLOSE_MARCH;
use helixrs::{
AnyInstrument, BondOption, BondOptionEngine, BondStrike, CallableBond, CollateralSpec,
Currency, DayCountFraction, FixedRateBond, Frequency, HullWhite, MarketContext,
OptionKind, PricingRiskFactors, QuoteSheet, RateVolSurface, VolConvention, YieldCurve, black76,
coupon_bond_option, 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")
}
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 lognormal = RateVolSurface::new(
vec![0.25, 0.5, 1.0, 2.0],
vec![2.0, 5.0, 10.0],
&vec![vec![0.20, 0.19, 0.18]; 4],
VolConvention::Lognormal,
)
.expect("a well-formed surface");
let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
let market = MarketContext::as_of(valuation)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)
.expect("a marked market")
.with_bond_yield_vols("USD", lognormal)
.pin_input(hull_white_mean_reversion_label(usd), 0.03)
.pin_input(hull_white_sigma_label(usd), 0.01);
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
let (curve, _) = market
.curve(&"discount(USD)".parse().expect("a curve id"))
.expect("the USD curve");
let reader = curve.view::<f64>();
// The engine's resolved inputs, put through the Black-76 kernel and
// discounted from expiry. Face is 100, so the per-100 premium is the
// value, and the result is the booked option's value to the bit.
let call = BondOption::new(
underlying.clone(),
expiry,
BondStrike::Clean(100.0),
OptionKind::Call,
)
.expect("a well-formed option");
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 undiscounted: f64 = black76(
seen.forward_dirty,
seen.dirty_strike,
seen.price_vol,
seen.expiry_years,
OptionKind::Call,
)
.expect("a positive forward and strike");
let df = reader.discount(expiry).expect("on the curve");
let call_premium = undiscounted * df;
let booked_black = view
.npv(&AnyInstrument::BondOption(Box::new(call.clone())))
.expect("a priced option");
// Expiry is measured on ACT/365 fixed from the valuation date.
println!("Black, undiscounted {undiscounted:.6}");
println!("discount to expiry {df:.6}");
println!("call premium {call_premium:.6}");
println!(" the booked option {booked_black:.6}");
// Under Hull-White a coupon bond option splits into options on each
// deliverable flow. The flows are those paid after exercise settlement,
// per unit face, with the redemption in the last one.
let pay_dates: Vec<NaiveDate> = (0..18)
.map(|k| date(2026 + (k + 1) / 2, if k % 2 == 0 { 11 } else { 5 }, 15))
.collect();
let mut coupons = vec![0.02125; 17];
coupons.push(1.02125);
let model = HullWhite::new(0.03, 0.01).expect("a valid model");
let strike = 100.0 + bond.accrued(expiry);
let per_unit: f64 = coupon_bond_option(
&model.lift::<f64>(),
&reader,
expiry,
&pay_dates,
&coupons,
strike / 100.0,
OptionKind::Call,
)
.expect("on the curve");
let hull_white_premium = per_unit * 100.0;
let booked = call.clone().with_engine(BondOptionEngine::HullWhite);
let booked_hull_white = view
.npv(&AnyInstrument::BondOption(Box::new(booked)))
.expect("a priced option");
// With no volatility the option is worth its discounted intrinsic value,
// which the curve alone determines.
let still = HullWhite::new(0.03, 1e-12).expect("a valid model");
let intrinsic: f64 = coupon_bond_option(
&still.lift::<f64>(),
&reader,
expiry,
&pay_dates,
&coupons,
strike / 100.0,
OptionKind::Call,
)
.expect("on the curve");
let mut forward_value = 0.0;
for (paid, amount) in pay_dates.iter().zip(&coupons) {
forward_value += amount * reader.discount(*paid).expect("on the curve");
}
println!("flows after expiry {}", pay_dates.len());
println!("per unit face {per_unit:.8}");
println!("Hull-White premium {hull_white_premium:.6}");
println!(" the booked option {booked_hull_white:.6}");
println!("at zero volatility {:.6}", intrinsic * 100.0);
println!(
" intrinsic, by hand {:.6}",
(forward_value - strike / 100.0 * df).max(0.0) * 100.0
);
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
LOGNORMAL = hlx.vols.RateVolSurface(
[0.25, 0.5, 1.0, 2.0], [2.0, 5.0, 10.0], [[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)
.with_hull_white("USD", 0.03, 0.01)
)
CURVE = MARKET.curve("discount(USD)")
# The engine's resolved inputs, put through the Black-76 kernel and
# discounted from expiry. Face is 100, so the per-100 premium is the value,
# and the result is the booked option's value to the bit.
call = hlx.products.bond_option(BOND, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=CALL)
seen = call.diagnostics(MARKET)
undiscounted = hlx.models.black76(
seen.forward_dirty(), seen.dirty_strike(), seen.price_vol(), seen.expiry_years(), CALL
)
call_premium = undiscounted * CURVE.discount(EXPIRY)
# Expiry is measured on ACT/365 fixed from the valuation date.
print(f"Black, undiscounted {undiscounted:.6f}")
print(f"discount to expiry {CURVE.discount(EXPIRY):.6f}")
print(f"call premium {call_premium:.6f}")
print(f" the booked option {call.npv(MARKET):.6f}")
# Under Hull-White a coupon bond option splits into options on each
# deliverable flow. The flows are those paid after exercise settlement,
# per unit face, with the redemption in the last one.
pay_dates = [dt.date(2026 + (k + 1) // 2, 11 if k % 2 == 0 else 5, 15) for k in range(18)]
coupons = [0.02125] * 17 + [1.02125]
model = hlx.models.HullWhite(0.03, 0.01)
strike = 100.0 + BOND.accrued(EXPIRY)
per_unit = hlx.models.coupon_bond_option(model, CURVE, EXPIRY, pay_dates, coupons, strike / 100.0, CALL)
hull_white_premium = per_unit * 100.0
booked = hlx.products.bond_option(
BOND, EXPIRY, strike=100.0, strike_kind=CLEAN, kind=CALL, engine=hlx.products.BondOptionEngine.HullWhite
)
# With no volatility the option is worth its discounted intrinsic value,
# which the curve alone determines.
still = hlx.models.HullWhite(0.03, 1e-12)
intrinsic = hlx.models.coupon_bond_option(still, CURVE, EXPIRY, pay_dates, coupons, strike / 100.0, CALL)
forward_value = 0.0
for paid, amount in zip(pay_dates, coupons):
forward_value += amount * CURVE.discount(paid)
print(f"flows after expiry {len(pay_dates)}")
print(f"per unit face {per_unit:.8f}")
print(f"Hull-White premium {hull_white_premium:.6f}")
print(f" the booked option {booked.npv(MARKET):.6f}")
print(f"at zero volatility {intrinsic * 100.0:.6f}")
print(f" intrinsic, by hand {max(forward_value - strike / 100.0 * CURVE.discount(EXPIRY), 0.0) * 100.0:.6f}")Black, undiscounted 1.876610
discount to expiry 0.979918
call premium 1.838924
the booked option 1.838924
flows after expiry 18
per unit face 0.02083477
Hull-White premium 2.083477
the booked option 2.083477
at zero volatility 0.533485
intrinsic, by hand 0.533485
Bond future options
A bond future option is a listed option to buy or sell a bond future at a strike, how a desk trades the vol of the bond futures market without owning bonds. Each exercises into one future and expires before its delivery month. A future costs nothing to enter, so its price is driftless and Black's model on the futures price fits it.
How the premium changes hands changes the value. CBOT's paid premium is cash on the trade date for a payoff at expiry, so its value is discounted, and an American option has an early-exercise premium worth having. Eurex's and ICE's futures-style premium is margined daily like the future itself, so it's undiscounted, and exercising early gains nothing.
Structured notes
A structured note is a bond whose coupons are a leg built to order: floating, capped or floored, CMS, or a CMS spread. An issuer sells it to investors who want that coupon, and the investor takes the issuer's credit along with it. It trades as a price per 100 of face. A price below the note's risk-free value is the market charging for the issuer, either as a spread on the discounting, or as the chance the issuer defaults and pays only a recovery.
From formula to API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| , | a yield in a convention | bond.yield_(price, s, basis, convention, None) |
FixedRateBond.yield_(price, s, basis, convention) |
| discounting a flow at a yield | bond.price(y, s, basis, convention, None) |
FixedRateBond.price |
|
| a PIK face's growth | pik.current_face(T) |
bonds.accreted_face(pik, T) |
See also
- Fixed-rate bond, the bond every other page varies.
- Bond conventions, for each market's day count, frequency and yield convention.
- How rates products work, for the swaps asset swaps and callables are built from.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.