use crate::errors::BootstrapError;
use crate::types::{Date, Daycount, Frequency};
use super::schedule::SwapSchedule;
use super::{CurveSnapshot, InstrumentLike};
#[derive(Debug, Clone, PartialEq)]
pub struct Bond {
pub issue: Date,
pub maturity: Date,
pub coupon: f64,
pub freq: Frequency,
pub daycount: Daycount,
pub notional: f64,
pub clean_price: f64,
pub accrued: f64,
pub schedule: SwapSchedule,
}
impl Bond {
#[allow(clippy::too_many_arguments)]
pub fn new(
issue: Date,
maturity: Date,
coupon: f64,
freq: Frequency,
daycount: Daycount,
notional: f64,
clean_price: f64,
accrued: f64,
) -> Result<Self, BootstrapError> {
Self::validate(coupon, notional, clean_price, accrued, issue, maturity)?;
let schedule = SwapSchedule::from_regular(issue, maturity, freq)?;
Ok(Self {
issue,
maturity,
coupon,
freq,
daycount,
notional,
clean_price,
accrued,
schedule,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_schedule(
issue: Date,
maturity: Date,
coupon: f64,
freq: Frequency,
daycount: Daycount,
notional: f64,
clean_price: f64,
accrued: f64,
schedule: SwapSchedule,
) -> Result<Self, BootstrapError> {
Self::validate(coupon, notional, clean_price, accrued, issue, maturity)?;
if schedule.start() != issue || schedule.maturity() != maturity {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule endpoints must match (issue, maturity)",
});
}
Ok(Self {
issue,
maturity,
coupon,
freq,
daycount,
notional,
clean_price,
accrued,
schedule,
})
}
fn validate(
coupon: f64,
notional: f64,
clean_price: f64,
accrued: f64,
issue: Date,
maturity: Date,
) -> Result<(), BootstrapError> {
if !coupon.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "bond coupon must be finite",
});
}
if !notional.is_finite() || notional <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "bond notional must be strictly positive",
});
}
if !clean_price.is_finite() || clean_price <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "bond clean price must be strictly positive",
});
}
if !accrued.is_finite() || accrued < 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "bond accrued interest must be finite and non-negative",
});
}
if issue.days_between(maturity) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "bond issue must precede maturity",
});
}
Ok(())
}
pub(crate) fn coupon_pv(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let mut annuity = 0.0_f64;
for i in 0..self.schedule.len() {
let period_start = self.schedule.period_start(i);
let period_end = self.schedule.period_end(i);
let tau = self.daycount.year_fraction(period_start, period_end)?;
let t_pay = curve
.daycount
.year_fraction(curve.reference_date, period_end)?;
let d_pay = curve
.discount_at(t_pay)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if !d_pay.is_finite() || d_pay <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
annuity += tau * d_pay;
}
Ok(self.coupon * annuity * self.notional)
}
pub(crate) fn principal_pv(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let t_maturity = curve
.daycount
.year_fraction(curve.reference_date, self.maturity)?;
let d_maturity =
curve
.discount_at(t_maturity)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if !d_maturity.is_finite() || d_maturity <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(self.notional * d_maturity)
}
}
impl InstrumentLike for Bond {
#[inline]
fn pillar(&self) -> Date {
self.maturity
}
fn residual(
&self,
reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let coupon_pv = self.coupon_pv(reference_date, curve)?;
let principal_pv = self.principal_pv(reference_date, curve)?;
Ok(coupon_pv + principal_pv - (self.clean_price + self.accrued))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instruments::CurveSnapshot;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
#[test]
fn new_accepts_valid_5y_annual_bond() {
let issue = d(2024, 1, 2);
let maturity = d(2029, 1, 2);
let bond = Bond::new(
issue,
maturity,
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap();
assert_eq!(bond.issue, issue);
assert_eq!(bond.maturity, maturity);
assert!((bond.coupon - 0.05).abs() < 1e-15);
assert_eq!(bond.freq, Frequency::Annual);
assert_eq!(bond.daycount, Daycount::Act365F);
assert!((bond.notional - 1.0).abs() < 1e-15);
assert!((bond.clean_price - 1.0).abs() < 1e-15);
assert!((bond.accrued - 0.0).abs() < 1e-15);
assert_eq!(bond.schedule.len(), 5);
assert_eq!(bond.pillar(), maturity);
}
#[test]
fn new_rejects_nan_coupon() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
f64::NAN,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_coupon() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
f64::INFINITY,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_zero_notional() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
0.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_negative_notional() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
-1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_zero_clean_price() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
0.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_negative_clean_price() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
-0.5,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_negative_accrued() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
-0.01,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_nan_accrued() {
let err = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
f64::NAN,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inverted_dates() {
let err = Bond::new(
d(2029, 1, 2),
d(2024, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_equal_issue_and_maturity() {
let s = d(2024, 1, 2);
let err = Bond::new(
s,
s,
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_propagates_irregular_schedule_error() {
let err = Bond::new(
d(2024, 1, 2),
d(2025, 2, 2),
0.05,
Frequency::SemiAnnual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn with_schedule_accepts_matching_schedule() {
let issue = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let sch = SwapSchedule::from_regular(issue, maturity, Frequency::Annual).unwrap();
let bond = Bond::with_schedule(
issue,
maturity,
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
sch,
)
.unwrap();
assert_eq!(bond.schedule.len(), 2);
}
#[test]
fn with_schedule_rejects_mismatched_endpoints() {
let issue = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let other = d(2027, 1, 2);
let sch = SwapSchedule::from_regular(issue, other, Frequency::Annual).unwrap();
let err = Bond::with_schedule(
issue,
maturity,
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
sch,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn with_schedule_rejects_invalid_inputs() {
let issue = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let sch = SwapSchedule::from_regular(issue, maturity, Frequency::Annual).unwrap();
let err = Bond::with_schedule(
issue,
maturity,
f64::NAN,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
sch,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
fn flat_curve(reference_date: Date, daycount: Daycount, r: f64) -> (Vec<f64>, Vec<f64>) {
let mut times = Vec::new();
let mut discounts = Vec::new();
for i in 0..=120 {
let date = Date::from_serial(reference_date.serial() + i * 91);
let t = daycount.year_fraction(reference_date, date).unwrap();
times.push(t);
discounts.push((-r * t).exp());
}
(times, discounts)
}
fn par_coupon_flat(bond: &Bond, reference: Date, r_c: f64) -> f64 {
let dc_curve = bond.daycount; let mut annuity = 0.0_f64;
for i in 0..bond.schedule.len() {
let s = bond.schedule.period_start(i);
let e = bond.schedule.period_end(i);
let tau = bond.daycount.year_fraction(s, e).unwrap();
let t_pay = dc_curve.year_fraction(reference, e).unwrap();
annuity += tau * (-r_c * t_pay).exp();
}
let t_n = dc_curve.year_fraction(reference, bond.maturity).unwrap();
(1.0 - (-r_c * t_n).exp()) / annuity
}
#[test]
fn par_bond_residual_is_zero_5y_annual_flat_5pct() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.05_f64;
let issue = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let placeholder = Bond::new(
issue,
maturity,
0.0, Frequency::Annual,
dc,
1.0,
1.0,
0.0,
)
.unwrap();
let par_coupon = par_coupon_flat(&placeholder, reference, r_c);
let bond = Bond::new(
issue,
maturity,
par_coupon,
Frequency::Annual,
dc,
1.0,
1.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let residual = bond.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"par-bond residual on flat curve must be zero to 1e-12, got {residual}",
);
}
#[test]
fn off_par_bond_residual_matches_clean_price_shift() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.05_f64;
let issue = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let placeholder =
Bond::new(issue, maturity, 0.0, Frequency::Annual, dc, 1.0, 1.0, 0.0).unwrap();
let par_coupon = par_coupon_flat(&placeholder, reference, r_c);
let bond = Bond::new(
issue,
maturity,
par_coupon,
Frequency::Annual,
dc,
1.0,
0.95, 0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let residual = bond.residual(reference, &snapshot).unwrap();
assert!(
(residual - 0.05).abs() < 1e-12,
"off-par residual should be 0.05, got {residual}",
);
}
#[test]
fn coupon_pv_matches_manual_sum() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.04_f64;
let issue = reference;
let maturity = d(2027, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let bond = Bond::new(issue, maturity, 0.06, Frequency::Annual, dc, 1.0, 1.0, 0.0).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let mut expected = 0.0_f64;
for i in 0..bond.schedule.len() {
let p_start = bond.schedule.period_start(i);
let p_end = bond.schedule.period_end(i);
let tau = bond.daycount.year_fraction(p_start, p_end).unwrap();
let t = dc.year_fraction(reference, p_end).unwrap();
expected += tau * (-r_c * t).exp();
}
expected *= 0.06; let got = bond.coupon_pv(reference, &snapshot).unwrap();
assert!(
(got - expected).abs() < 1e-12,
"coupon_pv mismatch: got {got}, expected {expected}",
);
}
#[test]
fn principal_pv_equals_notional_times_discount() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.04_f64;
let issue = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let bond = Bond::new(
issue,
maturity,
0.05,
Frequency::Annual,
dc,
100.0,
100.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let t_n = dc.year_fraction(reference, maturity).unwrap();
let expected = 100.0 * (-r_c * t_n).exp();
let got = bond.principal_pv(reference, &snapshot).unwrap();
assert!(
(got - expected).abs() < 1e-10,
"principal_pv mismatch: got {got}, expected {expected}",
);
}
#[test]
fn residual_errors_on_empty_snapshot() {
let reference = d(2024, 1, 2);
let bond = Bond::new(
reference,
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act365F,
times: &[],
discounts: &[],
};
let err = bond.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn bond_residual_propagates_business252_error() {
let reference = d(2024, 1, 2);
let dc_curve = Daycount::Act365F;
let (times, discounts) = flat_curve(reference, dc_curve, 0.04);
let bond = Bond::new(
reference,
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Business252,
1.0,
1.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc_curve,
times: ×,
discounts: &discounts,
};
let err = bond.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::Type(_)));
}
#[test]
fn ten_year_semi_annual_par_bond_residual_zero() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.04_f64;
let issue = reference;
let maturity = d(2034, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let placeholder = Bond::new(
issue,
maturity,
0.0,
Frequency::SemiAnnual,
dc,
1.0,
1.0,
0.0,
)
.unwrap();
let par_coupon = par_coupon_flat(&placeholder, reference, r_c);
let bond = Bond::new(
issue,
maturity,
par_coupon,
Frequency::SemiAnnual,
dc,
1.0,
1.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let residual = bond.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"10y semi-annual residual at par should be < 1e-10, got {residual}",
);
}
#[test]
fn par_bond_invariant_under_mixed_daycounts() {
let reference = d(2024, 1, 2);
let issue = reference;
let maturity = d(2029, 1, 2);
let dc_curve = Daycount::Act365F;
let dc_coupon = Daycount::Thirty360BondBasis;
let r_c = 0.04_f64;
let (times, discounts) = flat_curve(reference, dc_curve, r_c);
let schedule = SwapSchedule::from_regular(issue, maturity, Frequency::Annual).unwrap();
let mut annuity = 0.0_f64;
for i in 0..schedule.len() {
let s = schedule.period_start(i);
let e = schedule.period_end(i);
let tau = dc_coupon.year_fraction(s, e).unwrap();
let t_pay = dc_curve.year_fraction(reference, e).unwrap();
annuity += tau * (-r_c * t_pay).exp();
}
let t_n = dc_curve.year_fraction(reference, maturity).unwrap();
let par_coupon = (1.0 - (-r_c * t_n).exp()) / annuity;
let bond = Bond::new(
issue,
maturity,
par_coupon,
Frequency::Annual,
dc_coupon,
1.0,
1.0,
0.0,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc_curve,
times: ×,
discounts: &discounts,
};
let residual = bond.residual(reference, &snapshot).unwrap();
assert!(residual.abs() < 1e-10, "mixed-DC residual: {residual}");
}
#[test]
fn debug_format_contains_struct_name() {
let bond = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap();
let s = format!("{bond:?}");
assert!(s.contains("Bond"));
}
#[test]
fn clone_and_eq_round_trip() {
let bond = Bond::new(
d(2024, 1, 2),
d(2029, 1, 2),
0.05,
Frequency::Annual,
Daycount::Act365F,
1.0,
1.0,
0.0,
)
.unwrap();
let cloned = bond.clone();
assert_eq!(bond, cloned);
}
#[test]
fn residual_with_accrued_subtracts_dirty_price() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act365F;
let r_c = 0.05_f64;
let issue = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, dc, r_c);
let placeholder =
Bond::new(issue, maturity, 0.0, Frequency::Annual, dc, 1.0, 1.0, 0.0).unwrap();
let par_coupon = par_coupon_flat(&placeholder, reference, r_c);
let bond = Bond::new(
issue,
maturity,
par_coupon,
Frequency::Annual,
dc,
1.0,
0.98,
0.02,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
let residual = bond.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"residual with accrued must be zero when PV = clean + accrued, got {residual}",
);
}
}