Fixed-rate bond
This page lists what you need to price a fixed-rate bond: the constructor and its options, the price, yield and risk calls, their defaults, and examples you can run. A fixed-rate bond pays a set coupon on a set schedule and repays its face at maturity; almost every other bond on these pages is a variation on it.
Quick example
The example reads its curve quotes from the desk sheets; load them once and it runs as shown.
This takes a ten-year US Treasury-style note paying 4.25% semiannually on ACT/ACT ICMA, settling on 2 March 2026. It shows each call once, per 100 face: the accrued and the dirty price of a 98.50 clean quote, price and yield in both directions, duration, convexity and DV01, and the price off a SOFR curve instead of a yield. To quote a position of your own bond, see quote a bond from its yield.
use chrono::NaiveDate;
use helixio_docs_sheets::USD_CLOSE_MARCH;
use helixrs::{
CollateralSpec, Currency, DayCountFraction, FixedRateBond, Frequency, MarketContext,
PriceBasis, QuoteSheet, YieldCurve, YieldQuoteConvention, macaulay_duration, yield_moments,
z_spread,
};
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.0425;
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),
COUPON,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
)?;
let ust = YieldQuoteConvention::UsStreet;
let ust_basis = ust.basis(bond.frequency).expect("a compounding convention");
// A quote is clean. The buyer also pays the coupon the seller earned since
// the last payment date, so the cash that changes hands is the dirty price.
let quoted_clean = 98.50;
let accrued = bond.accrued(settlement);
let dirty = quoted_clean + accrued;
println!("accrued {accrued:.6}");
println!("clean quote {quoted_clean:.6}");
println!("dirty (cash) {dirty:.6}");
// A price at a street yield, and the yield a price implies.
let y = 0.044;
let price_dirty = bond.price(y, settlement, PriceBasis::Dirty, ust, None);
let implied = bond
.yield_(price_dirty, settlement, PriceBasis::Dirty, ust, None)
.expect("a bracketed solve");
let quoted_yield = bond
.yield_(quoted_clean, settlement, PriceBasis::Clean, ust, None)
.expect("a bracketed solve");
println!("dirty at 4.40% {price_dirty:.6}");
println!("yield back {:.10}%", implied * 100.0);
println!("98.50 clean is {:.6}%", quoted_yield * 100.0);
// Every yield-space risk number is a reading of the price and its first two
// derivatives in yield, taken at the quoted yield.
let moments = yield_moments(&bond, quoted_yield, settlement, ust_basis);
let modified = moments.modified_duration();
let macaulay = macaulay_duration(&bond, quoted_yield, settlement, ust_basis);
let convexity = moments.convexity();
let dv01 = moments.dv01();
println!("modified duration {modified:.6}");
println!("Macaulay duration {macaulay:.6}");
println!("convexity {convexity:.6}");
println!("DV01 per 100 {dv01:.6}");
// A curve prices each flow at its own discount factor instead of one yield.
// The market is marked from the morning's swap sheet.
let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied())?;
let market = MarketContext::as_of(settlement)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)?;
let curve_dirty = bond.instrument().npv(&market)?;
let curve_yield = bond
.yield_(curve_dirty, settlement, PriceBasis::Dirty, ust, None)
.expect("a bracketed solve");
let spread = z_spread(&bond, &market, curve_dirty, PriceBasis::Dirty, None)?;
println!("dirty off the curve {curve_dirty:.6}");
println!("clean off the curve {:.6}", curve_dirty - accrued);
println!("its street yield {:.6}%", curve_yield * 100.0);
println!("z-spread to the curve {:.6} bp", spread.abs() * 10_000.0);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import USD_CLOSE_MARCH
ISSUE = dt.date(2025, 5, 15)
MATURITY = dt.date(2035, 5, 15)
COUPON = 0.0425
SETTLEMENT = dt.date(2026, 3, 2)
BOND = hlx.products.fixed_rate_bond(
"USD", ISSUE, MATURITY, coupon=COUPON, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma,
)
UST = hlx.bonds.YieldConvention.UsStreet
CLEAN = hlx.bonds.PriceBasis.Clean
DIRTY = hlx.bonds.PriceBasis.Dirty
# A quote is clean. The buyer also pays the coupon the seller earned since the
# last payment date, so the cash that changes hands is the dirty price.
quoted_clean = 98.50
accrued = BOND.accrued(SETTLEMENT)
dirty = quoted_clean + accrued
print(f"accrued {accrued:.6f}")
print(f"clean quote {quoted_clean:.6f}")
print(f"dirty (cash) {dirty:.6f}")
# A price at a street yield, and the yield a price implies.
y = 0.044
price_dirty = BOND.price(y, SETTLEMENT, DIRTY, UST)
implied = BOND.yield_(price_dirty, SETTLEMENT, DIRTY, UST)
quoted_yield = BOND.yield_(quoted_clean, SETTLEMENT, CLEAN, UST)
print(f"dirty at 4.40% {price_dirty:.6f}")
print(f"yield back {implied * 100:.10f}%")
print(f"98.50 clean is {quoted_yield * 100:.6f}%")
# Every yield-space risk number is a reading of the price and its first two
# derivatives in yield, taken at the quoted yield.
modified = BOND.modified_duration(quoted_yield, SETTLEMENT, UST)
macaulay = BOND.macaulay_duration(quoted_yield, SETTLEMENT, UST)
convexity = BOND.convexity(quoted_yield, SETTLEMENT, UST)
dv01 = BOND.dv01(quoted_yield, SETTLEMENT, UST)
print(f"modified duration {modified:.6f}")
print(f"Macaulay duration {macaulay:.6f}")
print(f"convexity {convexity:.6f}")
print(f"DV01 per 100 {dv01:.6f}")
# A curve prices each flow at its own discount factor instead of one yield. The
# market is marked from the morning's swap sheet.
market = hlx.MarketContext(SETTLEMENT).with_recipe_quotes("USD", USD_CLOSE_MARCH)
curve_dirty = BOND.instrument().npv(market)
curve_yield = BOND.yield_(curve_dirty, SETTLEMENT, DIRTY, UST)
spread = hlx.bonds.z_spread(BOND, market, curve_dirty, DIRTY)
print(f"dirty off the curve {curve_dirty:.6f}")
print(f"clean off the curve {curve_dirty - accrued:.6f}")
print(f"its street yield {curve_yield * 100:.6f}%")
print(f"z-spread to the curve {abs(spread) * 10_000:.6f} bp")accrued 1.256215
clean quote 98.500000
dirty (cash) 99.756215
dirty at 4.40% 100.125335
yield back 4.4000000000%
98.50 clean is 4.449635%
modified duration 7.438360
Macaulay duration 7.603850
convexity 66.270537
DV01 per 100 0.074202
dirty off the curve 101.877897
clean off the curve 100.621681
its street yield 4.167287%
z-spread to the curve 0.000000 bp
The accrued is the coupon earned since 15 November, so the cash price sits above the quote. The yield that comes back from a price is the yield that went in. The curve price is higher than the quote, because the SOFR curve discounts the bond's flows at less than the 4.45% the quote implies; its z-spread to that curve is zero, since the curve itself set the price.
How it's priced
Each remaining flow is discounted at the yield , compounded times a year, with its time counted in coupon periods. Off a curve each flow takes its own discount factor instead:
Duration, convexity and DV01 are readings of the first two yield derivatives of the dirty price. Why quotes are clean, why a yield needs its convention, and what each risk number means are covered in how bonds work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
products.fixed_rate_bond(currency, issue, maturity, *, coupon, frequency, day_count, face) |
str, date, date, float, dates.Frequency, dates.DayCountFraction, float |
required; face=100.0 |
the bond, per 100 face; Rust FixedRateBond::bullet(ccy, issue, maturity, coupon, frequency, day_count, face) |
.with_first_coupon(date) |
date |
a regular first period | an irregular first period, measured against notional regular periods |
.with_penultimate_coupon(date) |
date |
a regular last period | an irregular final period |
.with_end_of_month() |
— | off | coupons on the last day of each month |
.with_ex_div(days, day_type) |
int, "Business" or "Calendar" |
no ex-dividend period | a coupon is forgone after its ex-date; Rust with_ex_div(ExDivRule { days, day_type }) |
.issued_by(issuer) |
str |
the bond is riskless | tags the issuer, so a credit curve values it risky |
.accrued(settlement) |
date |
— | accrued interest per 100 face |
.price(y, settlement, price_basis, convention) |
float, date, bonds.PriceBasis, convention |
— | the price per 100 at a yield; Rust adds a trailing None |
.yield_(price, settlement, price_basis, convention) |
float, date, bonds.PriceBasis, convention |
— | the yield a price implies, by a bracketed solve |
.modified_duration, .macaulay_duration, .convexity, .dv01 |
(y, settlement, convention) |
— | yield-space risk; Rust reads them off yield_moments(&bond, y, s, basis) |
.flows(settlement) |
date |
— | the remaining flows, with amount and time columns |
.instrument() |
— | — | the bond as an instrument, valued by npv(market) off the currency's discount curve |
bonds.z_spread(bond, market, price, price_basis) |
— | — | the spread over the curve that reprices a quote |
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| accrued per 100 | bond.accrued(s) |
FixedRateBond.accrued |
|
| , | price at a yield | bond.price(y, s, PriceBasis::Clean, convention, None) |
FixedRateBond.price |
| the yield a price implies | bond.yield_(price, s, basis, convention, None) |
FixedRateBond.yield_ |
|
| the yield convention | YieldQuoteConvention::UsStreet |
bonds.YieldConvention.UsStreet, or "ust" |
|
| , | the remaining flows and times | bond.timed_flows_per_100(s, None) |
FixedRateBond.flows(s) |
| the bond off a curve | bond.instrument().npv(&market) |
FixedRateBond.instrument().npv |
|
| a curve's discount factor | curve.view::<f64>().discount(d) |
curves.DiscountCurve.discount |
Defaults and limits
- Yield conventions:
"default"is the bond's own frequency, which for a semiannual bond is the street convention"ust"."annual","semiannual","quarterly"and"continuous"restate the same discount at another compounding. Each market's convention is in bond conventions. - Price basis:
PriceBasis.Cleanleaves the accrued out andPriceBasis.Dirtyincludes it. The risk calls work on the dirty price. - Face: prices, accrued and DV01 are per 100 face. Multiply by face / 100 for a position.
- The solve: the price falls strictly as the yield rises, so each price has one yield.
- Ex-dividend: after the ex-date a buyer forgoes the next coupon, and the accrued is negative.
- Off a curve: the instrument discounts on the currency's discount curve, so the market needs it. Tagged to an issuer, it also needs that issuer's credit curve and recovery.
More examples
The same note, read as a yield under each convention, with a short final coupon, traded ex-dividend, and tagged to an issuer on a 2% hazard.
use chrono::NaiveDate;
use helixio_docs_sheets::USD_CLOSE_MARCH;
use helixrs::{
AnyInstrument, CallableBond, CollateralSpec, Currency, DayCountFraction, ExDivDayType,
ExDivRule, FixedRateBond, FlatHazard, Frequency, MarketContext, PriceBasis, QuoteSheet,
Survival, YieldQuoteConvention,
};
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.0425;
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),
COUPON,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
)?;
// One clean price, read as a yield under several conventions. The cash
// flows and their times do not change; only the compounding the yield is
// stated in.
let names = ["default", "ust", "semiannual", "annual", "quarterly", "continuous"];
let mut yields = Vec::new();
for name in names {
let convention: YieldQuoteConvention = name.parse()?;
let value = bond
.yield_(98.50, settlement, PriceBasis::Clean, convention, None)
.expect("a bracketed solve");
println!("{name:<11} {:.6}%", value * 100.0);
yields.push(value);
}
// A note maturing on 15 February 2035 whose last regular coupon is on
// 15 November 2034 has a short final period.
let short_end = FixedRateBond::bullet(
usd,
date(2025, 5, 15),
date(2035, 2, 15),
COUPON,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
)?
.with_penultimate_coupon(date(2034, 11, 15))?;
let flows = short_end.timed_flows_per_100(settlement, None);
let last_amount = flows[flows.len() - 1].1;
println!("short final flow {last_amount:.6}");
// Traded ex-dividend seven business days before each coupon, a buyer
// settling after the ex-date forgoes the coupon, and the accrued turns
// negative.
let ex_div = bond.clone().with_ex_div(ExDivRule {
days: 7,
day_type: ExDivDayType::Business,
});
let settle_late = date(2026, 5, 12);
let negative = ex_div.accrued(settle_late);
println!(
"ex-dividend date {}",
ex_div.ex_div_date(date(2026, 5, 15)).expect("an ex-dividend rule")
);
println!(
"ex-div on {settle_late} {}",
if ex_div.is_ex_div(settle_late) { "yes" } else { "no" }
);
println!("accrued ex-div {negative:.6}");
// Tagged to its issuer, the bullet is valued risky: each flow weighted by
// the issuer's survival, with recovery of face on default. A bullet reaches
// the risky valuation as a callable with no calls.
let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied())?;
let market = MarketContext::as_of(settlement)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)?;
let issuer = "UST/SeniorUnSec/XR14/USD";
let on_hazard = |hazard: f64| -> Result<MarketContext, Box<dyn std::error::Error>> {
let curve = Survival::FlatHazard(FlatHazard::new(settlement, hazard, DayCountFraction::Act365Fixed)?);
Ok(market
.clone()
.with_credit_curve(issuer.parse()?, std::sync::Arc::new(curve), Some(0.40)))
};
let tagged = AnyInstrument::Callable(Box::new(
CallableBond::new(bond.clone(), Vec::new()).issued_by(issuer.parse()?),
));
let curve_dirty = bond.instrument().npv(&market)?;
let risky = tagged.npv(&on_hazard(0.02)?)?;
let safe = tagged.npv(&on_hazard(0.0)?)?;
println!("curve price {curve_dirty:.6}");
println!("risky, 2% hazard {risky:.6}");
println!("risky, no hazard {safe:.6}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import USD_CLOSE_MARCH
ISSUE = dt.date(2025, 5, 15)
MATURITY = dt.date(2035, 5, 15)
COUPON = 0.0425
SETTLEMENT = dt.date(2026, 3, 2)
BOND = hlx.products.fixed_rate_bond(
"USD", ISSUE, MATURITY, coupon=COUPON, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma,
)
CLEAN = hlx.bonds.PriceBasis.Clean
# One clean price, read as a yield under several conventions. The cash flows
# and their times do not change; only the compounding the yield is stated in.
names = ["default", "ust", "semiannual", "annual", "quarterly", "continuous"]
yields = {name: BOND.yield_(98.50, SETTLEMENT, CLEAN, name) for name in names}
for name in names:
print(f"{name:<11} {yields[name] * 100:.6f}%")
# A note maturing on 15 February 2035 whose last regular coupon is on
# 15 November 2034 has a short final period.
short_end = hlx.products.fixed_rate_bond(
"USD", ISSUE, dt.date(2035, 2, 15), coupon=COUPON, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma,
).with_penultimate_coupon(dt.date(2034, 11, 15))
last_amount = short_end.flows(SETTLEMENT).to_dict()["amount"][-1]
print(f"short final flow {last_amount:.6f}")
# Traded ex-dividend seven business days before each coupon, a buyer settling
# after the ex-date forgoes the coupon, and the accrued turns negative.
ex_div = BOND.with_ex_div(7, "Business")
settle_late = dt.date(2026, 5, 12)
negative = ex_div.accrued(settle_late)
print(f"ex-dividend date {ex_div.ex_div_date(dt.date(2026, 5, 15))}")
print(f"ex-div on {settle_late} {'yes' if ex_div.is_ex_div(settle_late) else 'no'}")
print(f"accrued ex-div {negative:.6f}")
# Tagged to its issuer, the bullet is valued risky: each flow weighted by the
# issuer's survival, with recovery of face on default.
market = hlx.MarketContext(SETTLEMENT).with_recipe_quotes("USD", USD_CLOSE_MARCH)
ISSUER = "UST/SeniorUnSec/XR14/USD"
def on_hazard(hazard):
return market.with_credit_curve(ISSUER, hlx.curves.SurvivalCurve.flat_hazard(SETTLEMENT, hazard), 0.40)
curve_dirty = BOND.instrument().npv(market)
risky = BOND.issued_by(ISSUER).npv(on_hazard(0.02))
safe = BOND.issued_by(ISSUER).npv(on_hazard(0.0))
print(f"curve price {curve_dirty:.6f}")
print(f"risky, 2% hazard {risky:.6f}")
print(f"risky, no hazard {safe:.6f}")default 4.449635%
ust 4.449635%
semiannual 4.449635%
annual 4.499133%
quarterly 4.425158%
continuous 4.400859%
short final flow 101.080110
ex-dividend date 2026-05-06
ex-div on 2026-05-12 yes
accrued ex-div -0.035221
curve price 101.877897
risky, 2% hazard 93.270698
risky, no hazard 101.877897
Compounding less often needs a higher rate for the same discount, so the annual yield is the highest and the continuous one the lowest. The short final flow pays the days it runs out of a notional six-month period. On a 2% hazard the risky price is lower; with no hazard it is the curve price.
See also
- Source:
crates/helixrs/src/instruments/bonds.rs, andcrates/helixrs/src/statics/calc_methods.rsfor the yield conventions. - Related pages:
- How bonds work, for the ideas behind clean, dirty, yield and risk, and the flows worked by hand.
- Quote a bond from its yield, step by step.
- Spread measures, for the spreads between a price and a curve.
- Day counts, for ACT/ACT ICMA and its siblings.
- Bond conventions, for each market's day count, frequency and yield convention.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.