#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CouponFrequency {
Annual,
SemiAnnual,
Quarterly,
Monthly,
}
impl CouponFrequency {
pub fn periods_per_year(&self) -> u32 {
match self {
CouponFrequency::Annual => 1,
CouponFrequency::SemiAnnual => 2,
CouponFrequency::Quarterly => 4,
CouponFrequency::Monthly => 12,
}
}
}
#[derive(Debug, Clone)]
pub struct Bond {
pub face_value: f64,
pub coupon_rate: f64,
pub maturity_years: f64,
pub frequency: CouponFrequency,
pub settlement_date: u64,
}
#[inline]
fn periodic_coupon(bond: &Bond) -> f64 {
let m = f64::from(bond.frequency.periods_per_year());
bond.face_value * bond.coupon_rate / m
}
#[inline]
fn num_periods(bond: &Bond) -> u64 {
let m = f64::from(bond.frequency.periods_per_year());
(bond.maturity_years * m).round() as u64
}
#[inline]
fn period_rate(ytm: f64, bond: &Bond) -> f64 {
ytm / f64::from(bond.frequency.periods_per_year())
}
pub fn price(ytm: f64, bond: &Bond) -> f64 {
let r = period_rate(ytm, bond);
let c = periodic_coupon(bond);
let n = num_periods(bond);
if r.abs() < 1e-12 {
return c * n as f64 + bond.face_value;
}
let discount_n = (1.0 + r).powi(-(n as i32));
let coupon_pv = c * (1.0 - discount_n) / r;
let face_pv = bond.face_value * discount_n;
coupon_pv + face_pv
}
pub fn yield_to_maturity(market_price: f64, bond: &Bond) -> f64 {
let tol = 1e-8;
let max_iter = 200;
let f = |ytm: f64| price(ytm, bond) - market_price;
let mut a = 0.001_f64;
let mut b = 0.999_f64;
let mut fa = f(a);
let mut fb = f(b);
if fa * fb > 0.0 {
a = 1e-6;
b = 50.0;
fa = f(a);
fb = f(b);
if fa * fb > 0.0 {
return f64::NAN;
}
}
let mut c = a;
let mut fc = fa;
let mut d = b - a;
let mut e = d;
for _ in 0..max_iter {
if fb * fc > 0.0 {
c = a;
fc = fa;
d = b - a;
e = d;
}
if fc.abs() < fb.abs() {
a = b;
b = c;
c = a;
fa = fb;
fb = fc;
fc = fa;
}
let tol1 = 2.0 * f64::EPSILON * b.abs() + 0.5 * tol;
let xm = 0.5 * (c - b);
if xm.abs() <= tol1 || fb.abs() < f64::EPSILON {
return b;
}
if e.abs() >= tol1 && fa.abs() > fb.abs() {
let s = fb / fa;
let (p, q) = if (a - c).abs() < f64::EPSILON {
(2.0 * xm * s, 1.0 - s)
} else {
let q_val = fa / fc;
let r_val = fb / fc;
(
s * (2.0 * xm * q_val * (q_val - r_val) - (b - a) * (r_val - 1.0)),
(q_val - 1.0) * (r_val - 1.0) * (s - 1.0),
)
};
let (p, q) = if p > 0.0 { (p, -q) } else { (-p, q) };
if 2.0 * p < (3.0 * xm * q - (tol1 * q).abs()) && 2.0 * p < (e * q).abs() {
e = d;
d = p / q;
} else {
d = xm;
e = d;
}
} else {
d = xm;
e = d;
}
a = b;
fa = fb;
b += if d.abs() > tol1 { d } else { tol1.copysign(xm) };
fb = f(b);
}
b
}
pub fn macaulay_duration(ytm: f64, bond: &Bond) -> f64 {
let r = period_rate(ytm, bond);
let c = periodic_coupon(bond);
let n = num_periods(bond);
let m = f64::from(bond.frequency.periods_per_year());
let p = price(ytm, bond);
if p.abs() < f64::EPSILON {
return 0.0;
}
let mut weighted_time = 0.0;
for t in 1..=n {
let cf = if t == n { c + bond.face_value } else { c };
let pv = cf / (1.0 + r).powi(t as i32);
let time_years = t as f64 / m;
weighted_time += time_years * pv;
}
weighted_time / p
}
pub fn modified_duration(ytm: f64, bond: &Bond) -> f64 {
let m = f64::from(bond.frequency.periods_per_year());
macaulay_duration(ytm, bond) / (1.0 + ytm / m)
}
pub fn convexity(ytm: f64, bond: &Bond) -> f64 {
let r = period_rate(ytm, bond);
let c = periodic_coupon(bond);
let n = num_periods(bond);
let m = f64::from(bond.frequency.periods_per_year());
let p = price(ytm, bond);
if p.abs() < f64::EPSILON {
return 0.0;
}
let mut sum = 0.0;
for t in 1..=n {
let cf = if t == n { c + bond.face_value } else { c };
let pv = cf / (1.0 + r).powi(t as i32);
sum += t as f64 * (t as f64 + 1.0) * pv;
}
sum / (p * (1.0 + r).powi(2) * m * m)
}
pub fn dv01(ytm: f64, bond: &Bond) -> f64 {
price(ytm - 0.0001, bond) - price(ytm, bond)
}
pub fn price_change_approximation(ytm: f64, delta_ytm: f64, bond: &Bond) -> f64 {
let d_mod = modified_duration(ytm, bond);
let conv = convexity(ytm, bond);
-d_mod * delta_ytm + 0.5 * conv * delta_ytm * delta_ytm
}
pub fn current_yield(market_price: f64, bond: &Bond) -> f64 {
let annual_coupon = bond.face_value * bond.coupon_rate;
if market_price.abs() < f64::EPSILON {
return 0.0;
}
annual_coupon / market_price
}
pub fn zero_coupon_bond_price(ytm: f64, maturity_years: f64) -> f64 {
(1.0 + ytm).powf(-maturity_years)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bond(coupon_rate: f64, maturity_years: f64, freq: CouponFrequency) -> Bond {
Bond {
face_value: 1000.0,
coupon_rate,
maturity_years,
frequency: freq,
settlement_date: 0,
}
}
#[test]
fn zero_coupon_bond_price_correct() {
let p = zero_coupon_bond_price(0.05, 10.0);
let expected = 1.0 / 1.05_f64.powi(10);
assert!((p - expected).abs() < 1e-12);
}
#[test]
fn par_bond_price_equals_face_value() {
let bond = make_bond(0.06, 10.0, CouponFrequency::SemiAnnual);
let p = price(0.06, &bond);
assert!((p - 1000.0).abs() < 1e-6, "par price = {p}");
}
#[test]
fn par_bond_annual_coupon() {
let bond = make_bond(0.05, 5.0, CouponFrequency::Annual);
let p = price(0.05, &bond);
assert!((p - 1000.0).abs() < 1e-6, "par price = {p}");
}
#[test]
fn ytm_roundtrip() {
let bond = make_bond(0.04, 7.0, CouponFrequency::SemiAnnual);
let ytm_original = 0.06;
let p = price(ytm_original, &bond);
let ytm_solved = yield_to_maturity(p, &bond);
assert!(
(ytm_solved - ytm_original).abs() < 1e-7,
"YTM roundtrip: got {ytm_solved}, expected {ytm_original}"
);
}
#[test]
fn ytm_roundtrip_premium_bond() {
let bond = make_bond(0.08, 5.0, CouponFrequency::Annual);
let ytm_original = 0.05;
let p = price(ytm_original, &bond);
let ytm_solved = yield_to_maturity(p, &bond);
assert!(
(ytm_solved - ytm_original).abs() < 1e-7,
"YTM roundtrip premium: got {ytm_solved}"
);
}
#[test]
fn modified_duration_positive() {
let bond = make_bond(0.05, 10.0, CouponFrequency::SemiAnnual);
let d = modified_duration(0.05, &bond);
assert!(d > 0.0, "modified duration must be positive");
}
#[test]
fn convexity_positive() {
let bond = make_bond(0.05, 10.0, CouponFrequency::SemiAnnual);
let c = convexity(0.05, &bond);
assert!(c > 0.0, "convexity must be positive");
}
#[test]
fn dv01_positive() {
let bond = make_bond(0.05, 10.0, CouponFrequency::SemiAnnual);
let d = dv01(0.05, &bond);
assert!(d > 0.0, "DV01 must be positive, got {d}");
}
#[test]
fn macaulay_duration_less_than_maturity() {
let bond = make_bond(0.05, 10.0, CouponFrequency::Annual);
let d_mac = macaulay_duration(0.05, &bond);
assert!(
d_mac > 0.0 && d_mac <= bond.maturity_years,
"Macaulay duration {d_mac} must be in (0, maturity]"
);
}
#[test]
fn price_change_approximation_sign() {
let bond = make_bond(0.05, 10.0, CouponFrequency::SemiAnnual);
let dp = price_change_approximation(0.05, 0.01, &bond);
assert!(dp < 0.0, "price must fall when yield rises, got {dp}");
}
#[test]
fn current_yield_par_bond() {
let bond = make_bond(0.06, 10.0, CouponFrequency::Annual);
let cy = current_yield(1000.0, &bond);
assert!((cy - 0.06).abs() < 1e-12);
}
#[test]
fn quarterly_par_bond() {
let bond = make_bond(0.08, 3.0, CouponFrequency::Quarterly);
let p = price(0.08, &bond);
assert!((p - 1000.0).abs() < 1e-5, "quarterly par price = {p}");
}
#[test]
fn monthly_par_bond() {
let bond = make_bond(0.06, 2.0, CouponFrequency::Monthly);
let p = price(0.06, &bond);
assert!((p - 1000.0).abs() < 1e-4, "monthly par price = {p}");
}
}