use crate::error::GeomError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compounding {
Annual,
SemiAnnual,
Quarterly,
Monthly,
Continuous,
}
impl Compounding {
#[must_use]
pub fn periods_per_year(self) -> Option<f64> {
match self {
Compounding::Annual => Some(1.0),
Compounding::SemiAnnual => Some(2.0),
Compounding::Quarterly => Some(4.0),
Compounding::Monthly => Some(12.0),
Compounding::Continuous => None,
}
}
}
pub fn discount_factor(rate: f64, t: f64, compounding: Compounding) -> Result<f64, GeomError> {
if t < 0.0 || !t.is_finite() || !rate.is_finite() {
return Err(GeomError::InvalidArgument("discount_factor: bad rate or time"));
}
match compounding.periods_per_year() {
None => Ok((-rate * t).exp()),
Some(m) => {
let growth = 1.0 + rate / m;
if growth <= 0.0 {
return Err(GeomError::Degenerate("the periodic growth factor is not positive"));
}
Ok(growth.powf(-m * t))
}
}
}
pub fn equivalent_rate(
rate: f64,
from: Compounding,
to: Compounding,
) -> Result<f64, GeomError> {
if !rate.is_finite() {
return Err(GeomError::InvalidArgument("equivalent_rate: the rate is not finite"));
}
let annual_growth = match from.periods_per_year() {
None => rate.exp(),
Some(m) => {
let growth = 1.0 + rate / m;
if growth <= 0.0 {
return Err(GeomError::Degenerate("the periodic growth factor is not positive"));
}
growth.powf(m)
}
};
Ok(match to.periods_per_year() {
None => annual_growth.ln(),
Some(m) => m * (annual_growth.powf(1.0 / m) - 1.0),
})
}
pub fn npv(rate: f64, cashflows: &[f64]) -> Result<f64, GeomError> {
if cashflows.is_empty() || cashflows.iter().any(|c| !c.is_finite()) {
return Err(GeomError::InvalidArgument("npv: bad cashflows"));
}
if !(rate > -1.0) || !rate.is_finite() {
return Err(GeomError::InvalidArgument("npv: the rate is at or below -100%"));
}
let growth = 1.0 + rate;
Ok(cashflows.iter().enumerate().map(|(k, c)| c / growth.powi(k as i32)).sum())
}
pub fn irr(cashflows: &[f64]) -> Result<Option<f64>, GeomError> {
if cashflows.len() < 2 || cashflows.iter().any(|c| !c.is_finite()) {
return Err(GeomError::InvalidArgument("irr: bad cashflows"));
}
let signs: Vec<f64> = cashflows.iter().copied().filter(|c| *c != 0.0).collect();
let changes = signs.windows(2).filter(|p| p[0] * p[1] < 0.0).count();
if changes != 1 {
return Ok(None);
}
let value = |rate: f64| npv(rate, cashflows).unwrap_or(f64::NAN);
bracket_and_solve(&value)
}
fn bracket_and_solve(f: &dyn Fn(f64) -> f64) -> Result<Option<f64>, GeomError> {
let mut low = -0.5;
let mut at_low = f(low);
for _ in 0..60 {
let next = 0.5 * (low - 1.0);
let value = f(next);
if !value.is_finite() {
break;
}
low = next;
at_low = value;
if at_low * f(0.0) <= 0.0 {
break;
}
}
if !at_low.is_finite() {
return Ok(None);
}
let mut high = 0.0;
let mut at_high = f(high);
let mut attempts = 0;
while at_low * at_high > 0.0 {
high = if high == 0.0 { 0.1 } else { high * 2.0 };
if high > 1e6 {
return Ok(None);
}
at_high = f(high);
if !at_high.is_finite() {
return Ok(None);
}
attempts += 1;
if attempts > 200 {
return Ok(None);
}
}
for _ in 0..200 {
let mid = 0.5 * (low + high);
if f(mid) * at_low > 0.0 {
low = mid;
} else {
high = mid;
}
if high - low < 1e-14 * (1.0 + low.abs()) {
break;
}
}
Ok(Some(0.5 * (low + high)))
}
pub fn xirr(times: &[f64], cashflows: &[f64]) -> Result<Option<f64>, GeomError> {
if times.len() < 2 || times.len() != cashflows.len() {
return Err(GeomError::InvalidArgument("xirr: mismatched or too few flows"));
}
if times[0] != 0.0 || times.windows(2).any(|p| p[1] <= p[0]) {
return Err(GeomError::InvalidArgument("xirr: the times must start at zero and increase"));
}
if times.iter().chain(cashflows.iter()).any(|x| !x.is_finite()) {
return Err(GeomError::InvalidArgument("xirr: a value is not finite"));
}
let signs: Vec<f64> = cashflows.iter().copied().filter(|c| *c != 0.0).collect();
if signs.windows(2).filter(|p| p[0] * p[1] < 0.0).count() != 1 {
return Ok(None);
}
let value = |rate: f64| -> f64 {
let growth = 1.0 + rate;
if growth <= 0.0 {
return f64::NAN;
}
times.iter().zip(cashflows.iter()).map(|(t, c)| c * growth.powf(-t)).sum()
};
bracket_and_solve(&value)
}
pub fn bond_price(face: f64, coupon: f64, ytm: f64, periods: usize) -> Result<f64, GeomError> {
if periods == 0 || periods > 10_000 {
return Err(GeomError::InvalidArgument("bond_price: bad period count"));
}
if !face.is_finite() || !coupon.is_finite() || !(ytm > -1.0) || !ytm.is_finite() {
return Err(GeomError::InvalidArgument("bond_price: bad face, coupon or yield"));
}
let growth = 1.0 + ytm;
let mut total = 0.0;
for period in 1..=periods {
let discount = growth.powi(-(period as i32));
total += coupon * discount;
if period == periods {
total += face * discount;
}
}
Ok(total)
}
pub fn ytm_solve(
price: f64,
face: f64,
coupon: f64,
periods: usize,
) -> Result<f64, GeomError> {
if !(price > 0.0) || !price.is_finite() {
return Err(GeomError::InvalidArgument("ytm_solve: the price must be positive"));
}
if face < 0.0 || coupon < 0.0 || !(face + coupon > 0.0) {
return Err(GeomError::InvalidArgument("ytm_solve: the cashflows must be non-negative"));
}
let residual = |ytm: f64| bond_price(face, coupon, ytm, periods).map_or(f64::NAN, |p| p - price);
bracket_and_solve(&residual)?
.ok_or(GeomError::Degenerate("no yield reproduces that price"))
}
pub fn duration_macaulay(
face: f64,
coupon: f64,
ytm: f64,
periods: usize,
) -> Result<f64, GeomError> {
let price = bond_price(face, coupon, ytm, periods)?;
if !(price > 0.0) {
return Err(GeomError::Degenerate("the bond has no positive price to weight against"));
}
let growth = 1.0 + ytm;
let mut weighted = 0.0;
for period in 1..=periods {
let discount = growth.powi(-(period as i32));
let flow = coupon + if period == periods { face } else { 0.0 };
weighted += period as f64 * flow * discount;
}
Ok(weighted / price)
}
pub fn duration_modified(
face: f64,
coupon: f64,
ytm: f64,
periods: usize,
) -> Result<f64, GeomError> {
Ok(duration_macaulay(face, coupon, ytm, periods)? / (1.0 + ytm))
}
pub fn convexity(face: f64, coupon: f64, ytm: f64, periods: usize) -> Result<f64, GeomError> {
let price = bond_price(face, coupon, ytm, periods)?;
if !(price > 0.0) {
return Err(GeomError::Degenerate("the bond has no positive price to weight against"));
}
let growth = 1.0 + ytm;
let mut total = 0.0;
for period in 1..=periods {
let flow = coupon + if period == periods { face } else { 0.0 };
let n = period as f64;
total += n * (n + 1.0) * flow * growth.powi(-(period as i32 + 2));
}
Ok(total / price)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CurveBond {
pub maturity: f64,
pub coupon: f64,
pub price: f64,
pub frequency: f64,
}
pub fn bootstrap_zero_curve(bonds: &[CurveBond]) -> Result<Vec<(f64, f64)>, GeomError> {
if bonds.is_empty() {
return Err(GeomError::InvalidArgument("bootstrap_zero_curve: no bonds"));
}
if bonds.windows(2).any(|p| p[1].maturity <= p[0].maturity) {
return Err(GeomError::InvalidArgument("the maturities must strictly increase"));
}
let mut curve: Vec<(f64, f64)> = Vec::with_capacity(bonds.len());
for bond in bonds {
if !(bond.maturity > 0.0) || !(bond.price > 0.0) || !(bond.frequency > 0.0) {
return Err(GeomError::InvalidArgument("bootstrap_zero_curve: bad bond"));
}
let periods = bond.maturity * bond.frequency;
if (periods - periods.round()).abs() > 1e-9 || periods.round() < 1.0 {
return Err(GeomError::InvalidArgument(
"a maturity is not a whole number of coupon periods",
));
}
let periods = periods.round() as usize;
let mut discounted_coupons = 0.0;
for period in 1..periods {
let t = period as f64 / bond.frequency;
let zero = interpolate_zero(&curve, t)?;
discounted_coupons += bond.coupon * (-zero * t).exp();
}
let final_flow = 1.0 + bond.coupon;
let remaining = bond.price - discounted_coupons;
if !(remaining > 0.0) {
return Err(GeomError::Degenerate(
"the quotes leave no positive value for the final cashflow",
));
}
let discount = remaining / final_flow;
if !(discount > 0.0) {
return Err(GeomError::Degenerate("the implied discount factor is not positive"));
}
curve.push((bond.maturity, -discount.ln() / bond.maturity));
}
Ok(curve)
}
fn interpolate_zero(curve: &[(f64, f64)], t: f64) -> Result<f64, GeomError> {
if curve.is_empty() {
return Err(GeomError::Degenerate("a coupon falls before any zero rate is known"));
}
if t <= curve[0].0 {
return Ok(curve[0].1);
}
if t >= curve[curve.len() - 1].0 {
return Ok(curve[curve.len() - 1].1);
}
for pair in curve.windows(2) {
if t <= pair[1].0 {
let span = pair[1].0 - pair[0].0;
let weight = (t - pair[0].0) / span;
return Ok(pair[0].1 * (1.0 - weight) + pair[1].1 * weight);
}
}
Ok(curve[curve.len() - 1].1)
}
pub fn forward_rate(z1: f64, t1: f64, z2: f64, t2: f64) -> Result<f64, GeomError> {
if !(t1 >= 0.0) || !(t2 > t1) || !z1.is_finite() || !z2.is_finite() {
return Err(GeomError::InvalidArgument("forward_rate: bad maturities or rates"));
}
Ok((z2 * t2 - z1 * t1) / (t2 - t1))
}
pub fn nelson_siegel(t: f64, b0: f64, b1: f64, b2: f64, tau: f64) -> Result<f64, GeomError> {
if !(tau > 0.0) || t < 0.0 || ![b0, b1, b2, t].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("nelson_siegel: bad parameters"));
}
if t == 0.0 {
return Ok(b0 + b1);
}
let x = t / tau;
let decay = (-x).exp();
let slope = -(-x).exp_m1() / x;
Ok(b0 + (b1 + b2) * slope - b2 * decay)
}
pub fn ns_fit(maturities: &[f64], yields: &[f64]) -> Result<(f64, f64, f64, f64), GeomError> {
if maturities.len() < 4 || maturities.len() != yields.len() {
return Err(GeomError::InvalidArgument("ns_fit needs at least four matched points"));
}
if maturities.iter().any(|t| !(*t > 0.0)) || yields.iter().any(|y| !y.is_finite()) {
return Err(GeomError::InvalidArgument("ns_fit: bad observations"));
}
let longest = maturities.iter().fold(0.0f64, |a, b| a.max(*b));
let grid = 400usize;
let spacing = longest / grid as f64;
let mut best: Option<(f64, [f64; 3], f64)> = None;
let mut candidates: Vec<f64> = (1..=grid).map(|k| longest * k as f64 / grid as f64).collect();
for _ in 0..2 {
for tau in candidates.clone() {
let basis = |t: f64| -> [f64; 3] {
let x = t / tau;
let decay = (-x).exp();
let slope = -(-x).exp_m1() / x;
[1.0, slope, slope - decay]
};
let mut matrix = [[0.0f64; 3]; 3];
let mut rhs = [0.0f64; 3];
for (t, y) in maturities.iter().zip(yields.iter()) {
let row = basis(*t);
for i in 0..3 {
rhs[i] += row[i] * y;
for j in 0..3 {
matrix[i][j] += row[i] * row[j];
}
}
}
let Some(beta) = solve3(&matrix, &rhs) else { continue };
let error: f64 = maturities
.iter()
.zip(yields.iter())
.map(|(t, y)| {
let row = basis(*t);
(row[0] * beta[0] + row[1] * beta[1] + row[2] * beta[2] - y).powi(2)
})
.sum();
if best.is_none_or(|(_, _, e)| error < e) {
best = Some((tau, beta, error));
}
}
let Some((centre, _, _)) = best else { break };
let width = spacing;
candidates = (0..=40)
.map(|k| centre - width + 2.0 * width * k as f64 / 40.0)
.filter(|t| *t > 1e-6)
.collect();
}
let (tau, beta, _) =
best.ok_or(GeomError::Degenerate("no decay parameter gave a solvable fit"))?;
Ok((beta[0], beta[1], beta[2], tau))
}
fn solve3(matrix: &[[f64; 3]; 3], rhs: &[f64; 3]) -> Option<[f64; 3]> {
let mut a = [
[matrix[0][0], matrix[0][1], matrix[0][2], rhs[0]],
[matrix[1][0], matrix[1][1], matrix[1][2], rhs[1]],
[matrix[2][0], matrix[2][1], matrix[2][2], rhs[2]],
];
let scale = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(1.0);
for column in 0..3 {
let pivot = (column..3).max_by(|i, j| {
a[*i][column]
.abs()
.partial_cmp(&a[*j][column].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})?;
a.swap(column, pivot);
if a[column][column].abs() < 1e-12 * scale {
return None;
}
for row in 0..3 {
if row == column {
continue;
}
let factor = a[row][column] / a[column][column];
for entry in column..4 {
a[row][entry] -= factor * a[column][entry];
}
}
}
Some([a[0][3] / a[0][0], a[1][3] / a[1][1], a[2][3] / a[2][2]])
}
pub fn vasicek_bond_price(
r0: f64,
kappa: f64,
theta: f64,
sigma: f64,
t: f64,
) -> Result<f64, GeomError> {
if !(kappa > 0.0) || sigma < 0.0 || t < 0.0 || ![r0, theta, sigma, t].iter().all(|x| x.is_finite())
{
return Err(GeomError::InvalidArgument("vasicek_bond_price: bad parameters"));
}
if t == 0.0 {
return Ok(1.0);
}
let b = (1.0 - (-kappa * t).exp()) / kappa;
let long_run = theta - sigma * sigma / (2.0 * kappa * kappa);
let log_a = long_run * (b - t) - sigma * sigma * b * b / (4.0 * kappa);
Ok((log_a - b * r0).exp())
}
pub fn cir_bond_price(
r0: f64,
kappa: f64,
theta: f64,
sigma: f64,
t: f64,
) -> Result<f64, GeomError> {
if r0 < 0.0 || !(kappa > 0.0) || !(theta > 0.0) || sigma < 0.0 || t < 0.0 {
return Err(GeomError::InvalidArgument("cir_bond_price: bad parameters"));
}
if ![r0, kappa, theta, sigma, t].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("cir_bond_price: a parameter is not finite"));
}
if t == 0.0 {
return Ok(1.0);
}
if sigma == 0.0 {
return vasicek_bond_price(r0, kappa, theta, 0.0, t);
}
let gamma = (kappa * kappa + 2.0 * sigma * sigma).sqrt();
let expanded = (gamma * t).exp() - 1.0;
let denominator = (gamma + kappa) * expanded + 2.0 * gamma;
if !(denominator > 0.0) {
return Err(GeomError::Degenerate("the CIR denominator vanished"));
}
let b = 2.0 * expanded / denominator;
let a = (2.0 * gamma * ((kappa + gamma) * t / 2.0).exp() / denominator)
.powf(2.0 * kappa * theta / (sigma * sigma));
Ok(a * (-b * r0).exp())
}
#[must_use]
pub fn cir_feller_condition(kappa: f64, theta: f64, sigma: f64) -> bool {
2.0 * kappa * theta >= sigma * sigma
}
pub fn mortgage_payment(principal: f64, rate: f64, n: usize) -> Result<f64, GeomError> {
if !(principal > 0.0) || !principal.is_finite() || n == 0 || n > 100_000 {
return Err(GeomError::InvalidArgument("mortgage_payment: bad principal or term"));
}
if !(rate > -1.0) || !rate.is_finite() {
return Err(GeomError::InvalidArgument("mortgage_payment: bad rate"));
}
if rate == 0.0 {
return Ok(principal / n as f64);
}
let factor = (1.0 + rate).powi(-(n as i32));
Ok(principal * rate / (1.0 - factor))
}
pub fn amortization_schedule(
principal: f64,
rate: f64,
n: usize,
) -> Result<Vec<(f64, f64, f64, f64)>, GeomError> {
let payment = mortgage_payment(principal, rate, n)?;
let mut balance = principal;
let mut schedule = Vec::with_capacity(n);
for period in 1..=n {
let interest = balance * rate;
let mut repaid = payment - interest;
if period == n {
repaid = balance;
}
balance -= repaid;
schedule.push((interest + repaid, interest, repaid, balance.max(0.0)));
}
if let Some(last) = schedule.last_mut() {
last.3 = 0.0;
}
Ok(schedule)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_compounding_convention_changes_the_number_and_not_the_money() {
assert!((discount_factor(0.1, 1.0, Compounding::Annual).unwrap() - 1.0 / 1.1).abs() < 1e-15);
assert!(
(discount_factor(0.1, 1.0, Compounding::SemiAnnual).unwrap() - 1.0 / 1.1025).abs()
< 1e-15
);
assert!(
(discount_factor(0.1, 1.0, Compounding::Continuous).unwrap() - (-0.1f64).exp()).abs()
< 1e-15
);
let mut previous = f64::INFINITY;
for convention in [
Compounding::Annual,
Compounding::SemiAnnual,
Compounding::Quarterly,
Compounding::Monthly,
Compounding::Continuous,
] {
let factor = discount_factor(0.1, 1.0, convention).unwrap();
assert!(factor < previous, "{convention:?} did not discount harder");
previous = factor;
}
let annual = discount_factor(0.1, 30.0, Compounding::Annual).unwrap();
let continuous = discount_factor(0.1, 30.0, Compounding::Continuous).unwrap();
let ratio = annual / continuous;
assert!((1.15..1.16).contains(&ratio), "the ratio was {ratio}");
}
#[test]
fn converting_a_rate_between_conventions_leaves_the_growth_factor_alone() {
let conventions = [
Compounding::Annual,
Compounding::SemiAnnual,
Compounding::Quarterly,
Compounding::Monthly,
Compounding::Continuous,
];
for from in conventions {
for to in conventions {
for rate in [-0.02f64, 0.001, 0.05, 0.35, 1.5] {
let moved = equivalent_rate(rate, from, to).unwrap();
let back = equivalent_rate(moved, to, from).unwrap();
assert!((back - rate).abs() < 1e-12, "{from:?}->{to:?} at {rate} gave {back}");
for t in [0.5f64, 1.0, 7.0] {
let here = discount_factor(rate, t, from).unwrap();
let there = discount_factor(moved, t, to).unwrap();
assert!(
(here - there).abs() < 1e-12,
"{from:?}->{to:?}: {here} against {there}"
);
}
}
}
}
let continuous =
equivalent_rate(0.1, Compounding::SemiAnnual, Compounding::Continuous).unwrap();
assert!((continuous - 2.0 * 1.05f64.ln()).abs() < 1e-15, "got {continuous}");
assert!((continuous - 0.097_580_328_338_864_0).abs() < 1e-15, "got {continuous}");
}
#[test]
fn the_internal_rate_of_return_is_the_rate_that_zeroes_the_value() {
let flows = [-1000.0, 300.0, 400.0, 500.0];
let rate = irr(&flows).unwrap().expect("one sign change, so one rate");
assert!((rate - 0.088_963_394_693).abs() < 1e-9, "the rate came out at {rate}");
assert!(npv(rate, &flows).unwrap().abs() < 1e-9, "the value at its own rate is not zero");
let mut previous = f64::INFINITY;
for r in [-0.5f64, 0.0, 0.05, 0.2, 1.0, 5.0] {
let value = npv(r, &flows).unwrap();
assert!(value < previous, "the value rose at {r}");
previous = value;
}
}
#[test]
fn cashflows_that_change_sign_twice_get_no_single_rate() {
let alternating = [-100.0, 230.0, -132.0];
assert_eq!(irr(&alternating).unwrap(), None);
assert!(npv(0.1, &alternating).unwrap().abs() < 1e-12, "0.1 is a root");
assert!(npv(0.2, &alternating).unwrap().abs() < 1e-12, "0.2 is a root");
assert_eq!(irr(&[100.0, 200.0, 300.0]).unwrap(), None);
assert_eq!(irr(&[-100.0, -200.0]).unwrap(), None);
assert!(irr(&[100.0]).is_err());
assert!(irr(&[100.0, f64::NAN]).is_err());
assert!(npv(-1.0, &[1.0, 2.0]).is_err());
assert!(npv(0.1, &[]).is_err());
}
#[test]
fn irregular_dates_need_the_fractional_discounting_xirr_does() {
let times = [0.0, 0.5, 1.2, 2.0];
let flows = [-1000.0, 300.0, 400.0, 500.0];
let rate = xirr(×, &flows).unwrap().expect("one sign change");
let value: f64 =
times.iter().zip(flows.iter()).map(|(t, c)| c * (1.0 + rate).powf(-t)).sum();
assert!(value.abs() < 1e-9, "the value at its own rate is {value}");
let annual = irr(&flows).unwrap().unwrap();
assert!(rate > annual, "{rate} against the whole-period {annual}");
let whole = xirr(&[0.0, 1.0, 2.0, 3.0], &flows).unwrap().unwrap();
assert!((whole - annual).abs() < 1e-9, "{whole} against {annual}");
assert!(xirr(&[0.0, 1.0], &[1.0]).is_err());
assert!(xirr(&[1.0, 2.0], &[-1.0, 2.0]).is_err(), "times must start at zero");
assert!(xirr(&[0.0, 1.0, 1.0], &[-1.0, 1.0, 1.0]).is_err(), "times must increase");
}
#[test]
fn a_bond_prices_at_par_when_its_coupon_equals_its_yield() {
for rate in [0.001f64, 0.025, 0.07, 0.3] {
for periods in [1usize, 5, 10, 60] {
let price = bond_price(100.0, 100.0 * rate, rate, periods).unwrap();
assert!((price - 100.0).abs() < 1e-10, "at {rate} over {periods} it was {price}");
}
}
assert!(bond_price(100.0, 4.0, 0.025, 10).unwrap() > 100.0);
assert!(bond_price(100.0, 1.0, 0.025, 10).unwrap() < 100.0);
let zero = bond_price(100.0, 0.0, 0.03, 10).unwrap();
assert!((zero - 100.0 * 1.03f64.powi(-10)).abs() < 1e-12);
}
#[test]
fn solving_for_the_yield_inverts_the_price_it_was_given() {
for coupon in [0.0f64, 1.0, 3.0, 12.0] {
for periods in [1usize, 4, 20, 100] {
for ytm in [-0.02f64, 0.001, 0.045, 0.25] {
let price = bond_price(100.0, coupon, ytm, periods).unwrap();
let recovered = ytm_solve(price, 100.0, coupon, periods).unwrap();
assert!(
(recovered - ytm).abs() < 1e-9,
"coupon {coupon} over {periods}: {recovered} not {ytm}"
);
}
}
}
let mut previous = f64::INFINITY;
for ytm in [-0.05f64, 0.0, 0.02, 0.1, 0.5, 2.0] {
let price = bond_price(100.0, 3.0, ytm, 20).unwrap();
assert!(price < previous, "the price rose at {ytm}");
previous = price;
}
assert!(ytm_solve(0.0, 100.0, 3.0, 10).is_err());
assert!(ytm_solve(100.0, 0.0, 0.0, 10).is_err());
assert!(bond_price(100.0, 3.0, 0.02, 0).is_err());
assert!(bond_price(100.0, 3.0, -1.0, 10).is_err());
}
#[test]
fn a_zero_coupon_bonds_duration_is_exactly_its_maturity() {
for periods in [1usize, 5, 30] {
for ytm in [0.0f64, 0.03, 0.15] {
let zero = duration_macaulay(100.0, 0.0, ytm, periods).unwrap();
assert!((zero - periods as f64).abs() < 1e-10, "got {zero} for {periods}");
}
}
let mut previous = f64::INFINITY;
for coupon in [0.0f64, 1.0, 3.0, 8.0, 20.0] {
let duration = duration_macaulay(100.0, coupon, 0.04, 30).unwrap();
assert!(duration < previous, "a coupon of {coupon} did not shorten duration");
assert!(duration > 0.0 && duration <= 30.0);
previous = duration;
}
}
#[test]
fn duration_and_convexity_are_the_derivatives_they_claim_to_be() {
for (coupon, ytm, periods) in
[(3.0f64, 0.025f64, 10usize), (0.0, 0.05, 30), (8.0, 0.12, 5), (1.0, 0.001, 40)]
{
let price = |y: f64| bond_price(100.0, coupon, y, periods).unwrap();
let base = price(ytm);
let h = 1e-5;
let modified = duration_modified(100.0, coupon, ytm, periods).unwrap();
let expected = -(price(ytm + h) - price(ytm - h)) / (2.0 * h) / base;
assert!(
(modified - expected).abs() < 1e-6,
"modified duration {modified} against {expected}"
);
let macaulay = duration_macaulay(100.0, coupon, ytm, periods).unwrap();
assert!((macaulay - modified * (1.0 + ytm)).abs() < 1e-12);
let hc = 1e-3;
let second = |h: f64| (price(ytm + h) - 2.0 * base + price(ytm - h)) / (h * h) / base;
let extrapolated = (4.0 * second(0.5 * hc) - second(hc)) / 3.0;
let convex = convexity(100.0, coupon, ytm, periods).unwrap();
assert!(convex > 0.0, "convexity was not positive");
assert!(
(convex - extrapolated).abs() < 1e-4 * convex,
"convexity {convex} against {extrapolated}"
);
}
}
#[test]
fn duration_alone_is_pessimistic_in_both_directions() {
let (face, coupon, ytm, periods) = (100.0, 3.0, 0.04, 30);
let base = bond_price(face, coupon, ytm, periods).unwrap();
let modified = duration_modified(face, coupon, ytm, periods).unwrap();
let convex = convexity(face, coupon, ytm, periods).unwrap();
for shift in [-0.02f64, -0.01, 0.01, 0.02] {
let actual = bond_price(face, coupon, ytm + shift, periods).unwrap();
let linear = base * (1.0 - modified * shift);
assert!(actual > linear, "the linear estimate beat the price at {shift}");
let quadratic = base * (1.0 - modified * shift + 0.5 * convex * shift * shift);
assert!(
(quadratic - actual).abs() < 0.2 * (linear - actual).abs(),
"the convexity term did not improve the estimate at {shift}"
);
}
}
fn price_from_curve(zero: &dyn Fn(f64) -> f64, coupon: f64, years: usize) -> f64 {
let mut price = 0.0;
for period in 1..=years {
let t = period as f64;
price += coupon * (-zero(t) * t).exp();
}
price + (-zero(years as f64) * years as f64).exp()
}
#[test]
fn bootstrapping_recovers_the_curve_the_bonds_were_priced_from() {
let truth = |t: f64| 0.02 + 0.015 * (1.0 - (-t / 2.0).exp());
for coupon in [0.0f64, 0.01, 0.03, 0.09] {
let bonds: Vec<CurveBond> = (1..=6)
.map(|years| CurveBond {
maturity: years as f64,
coupon,
price: price_from_curve(&truth, coupon, years),
frequency: 1.0,
})
.collect();
let curve = bootstrap_zero_curve(&bonds).unwrap();
assert_eq!(curve.len(), 6);
for (t, zero) in &curve {
assert!(
(zero - truth(*t)).abs() < 1e-12,
"coupon {coupon} at t={t}: {zero} against {}",
truth(*t)
);
}
}
}
#[test]
fn a_flat_curve_bootstraps_flat_whatever_the_coupons() {
let flat = 0.04;
for coupon in [0.0f64, 0.04, 0.15] {
let bonds: Vec<CurveBond> = (1..=8)
.map(|years| CurveBond {
maturity: years as f64,
coupon,
price: price_from_curve(&|_| flat, coupon, years),
frequency: 1.0,
})
.collect();
for (_, zero) in bootstrap_zero_curve(&bonds).unwrap() {
assert!((zero - flat).abs() < 1e-12, "got {zero} on a flat curve");
}
}
}
#[test]
fn bootstrapping_refuses_quotes_that_would_imply_an_arbitrage() {
let sound = CurveBond { maturity: 1.0, coupon: 0.03, price: 1.0, frequency: 1.0 };
assert!(bootstrap_zero_curve(&[sound]).is_ok());
assert!(bootstrap_zero_curve(&[]).is_err());
let second = CurveBond { maturity: 0.5, ..sound };
assert!(bootstrap_zero_curve(&[sound, second]).is_err());
assert!(bootstrap_zero_curve(&[CurveBond { maturity: 1.5, ..sound }]).is_err());
assert!(bootstrap_zero_curve(&[CurveBond { price: 0.0, ..sound }]).is_err());
assert!(bootstrap_zero_curve(&[CurveBond { frequency: 0.0, ..sound }]).is_err());
let cheap = [
CurveBond { maturity: 1.0, coupon: 0.5, price: 1.4, frequency: 1.0 },
CurveBond { maturity: 2.0, coupon: 0.5, price: 0.4, frequency: 1.0 },
];
assert!(bootstrap_zero_curve(&cheap).is_err());
}
#[test]
fn a_forward_rate_is_what_stops_the_curve_from_arbitraging_itself() {
for (z1, t1, z2, t2) in
[(0.02f64, 1.0f64, 0.03f64, 2.0f64), (0.05, 0.25, 0.045, 10.0), (0.0, 0.0, 0.04, 5.0)]
{
let forward = forward_rate(z1, t1, z2, t2).unwrap();
let rolled = (-z1 * t1).exp() * (-forward * (t2 - t1)).exp();
let direct = (-z2 * t2).exp();
assert!((rolled - direct).abs() < 1e-14, "{rolled} against {direct}");
}
let forward = forward_rate(0.02, 1.0, 0.03, 2.0).unwrap();
assert!(forward > 0.03, "the forward {forward} did not exceed the longer zero rate");
assert!((forward - 0.04).abs() < 1e-14);
assert!((forward_rate(0.035, 2.0, 0.035, 7.0).unwrap() - 0.035).abs() < 1e-15);
assert!(forward_rate(0.02, 2.0, 0.03, 1.0).is_err());
assert!(forward_rate(0.02, 1.0, 0.03, 1.0).is_err());
}
#[test]
fn nelson_siegel_starts_at_the_short_rate_and_ends_at_the_long_one() {
let (b0, b1, b2, tau) = (0.045, -0.02, 0.03, 2.5);
assert!((nelson_siegel(0.0, b0, b1, b2, tau).unwrap() - (b0 + b1)).abs() < 1e-15);
assert!((nelson_siegel(1e-9, b0, b1, b2, tau).unwrap() - (b0 + b1)).abs() < 1e-8);
assert!((nelson_siegel(1e6, b0, b1, b2, tau).unwrap() - b0).abs() < 1e-4);
assert!((nelson_siegel(1e9, b0, b1, b2, tau).unwrap() - b0).abs() < 1e-7);
let humped = nelson_siegel(tau, b0, b1, b2, tau).unwrap();
assert!(humped > b0 + b1 && humped < b0, "the hump sat at {humped}");
assert!(nelson_siegel(1.0, b0, b1, b2, 0.0).is_err());
assert!(nelson_siegel(-1.0, b0, b1, b2, tau).is_err());
}
#[test]
fn the_nelson_siegel_fit_recovers_the_curve_it_was_shown() {
let (b0, b1, b2, tau) = (0.045, -0.02, 0.03, 2.5);
let maturities = [0.25f64, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 20.0, 30.0];
let yields: Vec<f64> =
maturities.iter().map(|t| nelson_siegel(*t, b0, b1, b2, tau).unwrap()).collect();
let (f0, f1, f2, ftau) = ns_fit(&maturities, &yields).unwrap();
assert!((f0 - b0).abs() < 1e-4, "level {f0}");
assert!((f1 - b1).abs() < 1e-3, "slope {f1}");
assert!((f2 - b2).abs() < 1e-3, "curvature {f2}");
assert!((ftau - tau).abs() < 0.05, "decay {ftau}");
for t in [0.1f64, 0.75, 4.0, 15.0, 25.0, 40.0] {
let want = nelson_siegel(t, b0, b1, b2, tau).unwrap();
let got = nelson_siegel(t, f0, f1, f2, ftau).unwrap();
assert!((got - want).abs() < 1e-5, "at t={t}: {got} against {want}");
}
assert!(ns_fit(&[1.0, 2.0], &[0.02, 0.03]).is_err());
assert!(ns_fit(&[1.0, 2.0, 3.0, 4.0], &[0.02, 0.03, 0.03]).is_err());
assert!(ns_fit(&[0.0, 2.0, 3.0, 4.0], &[0.02; 4]).is_err());
}
#[test]
fn a_short_rate_model_with_no_diffusion_is_just_deterministic_discounting() {
let (r0, kappa, theta) = (0.03, 0.5, 0.04);
for t in [0.5f64, 5.0, 30.0] {
let integral =
theta * t + (r0 - theta) * (1.0 - (-kappa * t).exp()) / kappa;
let expected = (-integral).exp();
let vasicek = vasicek_bond_price(r0, kappa, theta, 0.0, t).unwrap();
assert!((vasicek - expected).abs() < 1e-13, "Vasicek gave {vasicek} not {expected}");
let cir = cir_bond_price(r0, kappa, theta, 0.0, t).unwrap();
assert!((cir - expected).abs() < 1e-13, "CIR gave {cir} not {expected}");
}
}
#[test]
fn volatility_makes_a_bond_worth_more_than_its_average_rate_would_say() {
let (r0, kappa, theta) = (0.04, 0.5, 0.04);
let mut previous = 0.0;
for sigma in [0.0f64, 0.005, 0.01, 0.02, 0.04] {
let price = vasicek_bond_price(r0, kappa, theta, sigma, 10.0).unwrap();
assert!(price > previous, "volatility {sigma} did not raise the price");
previous = price;
}
let sigma = 0.02;
let long = 60.0;
let yield_at_long =
-vasicek_bond_price(theta, kappa, theta, sigma, long).unwrap().ln() / long;
let expected = theta - sigma * sigma / (2.0 * kappa * kappa);
assert!((yield_at_long - expected).abs() < 2e-3, "{yield_at_long} against {expected}");
let mut previous = 0.0;
for sigma in [0.0f64, 0.02, 0.05, 0.1] {
let price = cir_bond_price(r0, kappa, theta, sigma, 10.0).unwrap();
assert!(price > previous, "CIR volatility {sigma} did not raise the price");
previous = price;
}
}
#[test]
fn both_short_rate_models_price_a_bond_the_way_a_bond_behaves() {
for (r0, kappa, theta, sigma) in
[(0.03f64, 0.5f64, 0.04f64, 0.01f64), (0.001, 2.0, 0.05, 0.03), (0.08, 0.2, 0.02, 0.02)]
{
assert!((vasicek_bond_price(r0, kappa, theta, sigma, 0.0).unwrap() - 1.0).abs() < 1e-15);
assert!((cir_bond_price(r0, kappa, theta, sigma, 0.0).unwrap() - 1.0).abs() < 1e-15);
let mut previous = 1.0;
for t in [0.1f64, 1.0, 5.0, 20.0, 50.0] {
for price in [
vasicek_bond_price(r0, kappa, theta, sigma, t).unwrap(),
cir_bond_price(r0, kappa, theta, sigma, t).unwrap(),
] {
assert!(price > 0.0 && price < 1.0, "at t={t} the price was {price}");
}
let price = cir_bond_price(r0, kappa, theta, sigma, t).unwrap();
assert!(price < previous, "the CIR price rose at t={t}");
previous = price;
}
}
assert!(cir_feller_condition(0.5, 0.04, 0.05), "2*0.5*0.04 = 0.04 exceeds 0.0025");
assert!(!cir_feller_condition(0.5, 0.04, 0.3), "0.04 does not reach 0.09");
assert!(cir_bond_price(0.03, 0.0, 0.04, 0.01, 1.0).is_err());
assert!(cir_bond_price(0.03, 0.5, 0.0, 0.01, 1.0).is_err());
assert!(cir_bond_price(-0.01, 0.5, 0.04, 0.01, 1.0).is_err());
assert!(vasicek_bond_price(0.03, -1.0, 0.04, 0.01, 1.0).is_err());
assert!(vasicek_bond_price(0.03, 0.5, 0.04, -0.01, 1.0).is_err());
}
#[test]
fn a_level_payment_repays_the_loan_exactly_and_no_more() {
for rate in [0.0f64, 0.0001, 0.05 / 12.0, 0.02] {
for n in [1usize, 12, 360] {
let principal = 300_000.0;
let payment = mortgage_payment(principal, rate, n).unwrap();
let schedule = amortization_schedule(principal, rate, n).unwrap();
assert_eq!(schedule.len(), n);
assert!((schedule.last().unwrap().3).abs() < 1e-9, "a balance was left over");
let repaid: f64 = schedule.iter().map(|row| row.2).sum();
assert!(
(repaid - principal).abs() < 1e-6,
"at rate {rate} over {n} it repaid {repaid}"
);
for row in &schedule {
assert!((row.0 - payment).abs() < 1e-6, "a payment was {} not {payment}", row.0);
assert!((row.1 + row.2 - row.0).abs() < 1e-9);
assert!(row.1 >= -1e-12 && row.2 > -1e-12);
}
let mut previous = principal;
for row in &schedule {
assert!(row.3 <= previous + 1e-9, "the balance rose");
previous = row.3;
}
}
}
assert!(mortgage_payment(0.0, 0.01, 12).is_err());
assert!(mortgage_payment(1000.0, 0.01, 0).is_err());
assert!(mortgage_payment(1000.0, -1.0, 12).is_err());
assert!((mortgage_payment(1200.0, 0.0, 12).unwrap() - 100.0).abs() < 1e-12);
}
#[test]
fn a_mortgage_is_mostly_interest_until_past_its_halfway_point() {
let schedule = amortization_schedule(300_000.0, 0.05 / 12.0, 360).unwrap();
let crossover =
schedule.iter().position(|row| row.2 > row.1).expect("principal overtakes eventually");
assert!(crossover > 180, "principal overtook interest at period {}", crossover + 1);
assert!(crossover < 240, "it should not take that long: {}", crossover + 1);
let interest: f64 = schedule.iter().map(|row| row.1).sum();
assert!(interest > 0.9 * 300_000.0, "the interest was only {interest}");
assert!(interest < 300_000.0, "the interest was {interest}");
let dearer = amortization_schedule(300_000.0, 0.09 / 12.0, 360).unwrap();
let later = dearer.iter().position(|row| row.2 > row.1).unwrap();
assert!(later > crossover, "a higher rate did not delay the crossover");
}
}