use crate::errors::{BootstrapError, TypeError};
use crate::types::{Date, Daycount};
use super::{CurveSnapshot, InstrumentLike};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Deposit {
pub fixing: Date,
pub payment: Date,
pub rate: f64,
pub daycount: Daycount,
}
impl Deposit {
pub fn new(
fixing: Date,
payment: Date,
rate: f64,
daycount: Daycount,
) -> Result<Self, BootstrapError> {
if !rate.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "deposit rate must be finite",
});
}
if fixing.days_between(payment) < 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "deposit fixing must be on or before payment",
});
}
Ok(Self {
fixing,
payment,
rate,
daycount,
})
}
pub fn year_fraction_to_maturity(&self, reference_date: Date) -> Result<f64, TypeError> {
self.daycount.year_fraction(reference_date, self.payment)
}
pub fn accrual(&self) -> Result<f64, TypeError> {
self.daycount.year_fraction(self.fixing, self.payment)
}
pub fn implied_discount(&self, discount_at_fixing: f64) -> Result<f64, BootstrapError> {
if !discount_at_fixing.is_finite() || discount_at_fixing <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "discount factor at fixing must be finite and positive",
});
}
let tau = self.accrual()?;
let growth = 1.0 + self.rate * tau;
if !growth.is_finite() || growth <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive accrual factor (1 + rate * tau)",
});
}
Ok(discount_at_fixing / growth)
}
}
impl InstrumentLike for Deposit {
#[inline]
fn pillar(&self) -> Date {
self.payment
}
fn residual(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let tau = self.accrual()?;
let t_fixing = curve
.daycount
.year_fraction(curve.reference_date, self.fixing)?;
let t_payment = curve
.daycount
.year_fraction(curve.reference_date, self.payment)?;
let d_fixing = curve
.discount_at(t_fixing)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
let d_payment = curve
.discount_at(t_payment)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if d_payment <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(d_fixing / d_payment - (1.0 + self.rate * tau))
}
}
#[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_deposit() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
assert_eq!(dep.fixing, d(2024, 1, 2));
assert_eq!(dep.payment, d(2024, 4, 2));
assert!((dep.rate - 0.05).abs() < 1e-15);
}
#[test]
fn new_accepts_negative_rate() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), -0.005, Daycount::Act360).unwrap();
assert!(dep.rate < 0.0);
}
#[test]
fn new_accepts_zero_accrual_at_fixing_equals_payment() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 1, 2), 0.05, Daycount::Act360).unwrap();
assert!((dep.accrual().unwrap() - 0.0).abs() < 1e-15);
}
#[test]
fn new_rejects_nan_rate() {
let err =
Deposit::new(d(2024, 1, 2), d(2024, 4, 2), f64::NAN, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_rate() {
let err = Deposit::new(
d(2024, 1, 2),
d(2024, 4, 2),
f64::INFINITY,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inverted_dates() {
let err = Deposit::new(d(2024, 4, 2), d(2024, 1, 2), 0.05, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn accrual_matches_isda_worked_example() {
let dep = Deposit::new(d(2003, 11, 1), d(2004, 5, 1), 0.04, Daycount::Act360).unwrap();
let tau = dep.accrual().unwrap();
assert!((tau - 182.0 / 360.0).abs() < 1e-15);
}
#[test]
fn year_fraction_to_maturity_matches_daycount() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let tau = dep.year_fraction_to_maturity(d(2024, 1, 2)).unwrap();
assert!((tau - 91.0 / 360.0).abs() < 1e-15);
}
#[test]
fn year_fraction_to_maturity_propagates_business252_error() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Business252).unwrap();
let err = dep.year_fraction_to_maturity(d(2024, 1, 2)).unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn implied_discount_basic_formula() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let d_pay = dep.implied_discount(1.0).unwrap();
let tau = 91.0_f64 / 360.0;
let expected = 1.0 / (1.0 + 0.05 * tau);
assert!((d_pay - expected).abs() < 1e-15);
}
#[test]
fn implied_discount_matches_flat_curve_value() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let tau = dep.accrual().unwrap();
let d_pay = dep.implied_discount(1.0).unwrap();
let cont_continuous = (-0.05_f64 * tau).exp();
assert!((d_pay - cont_continuous).abs() > 1e-9);
assert!((d_pay - 1.0 / (1.0 + 0.05 * tau)).abs() < 1e-15);
}
#[test]
fn implied_discount_rejects_non_finite_d_fixing() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
assert!(matches!(
dep.implied_discount(f64::NAN).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
dep.implied_discount(f64::INFINITY).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_d_fixing() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
assert!(matches!(
dep.implied_discount(0.0).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
dep.implied_discount(-0.5).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_growth() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), -10.0, Daycount::Act360).unwrap();
let err = dep.implied_discount(1.0).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)
}
#[test]
fn deposit_residual_is_zero_on_flat_curve_with_implied_rate() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.05_f64;
let (times, discounts) = flat_curve(reference, daycount, r_c);
let fixing = d(2024, 1, 2); let payment = d(2024, 4, 2); let tau = daycount.year_fraction(fixing, payment).unwrap();
let r_simple = (r_c * tau).exp_m1() / tau;
let dep = Deposit::new(fixing, payment, r_simple, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = dep.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"residual on flat curve must be zero to 1e-12, got {residual}",
);
}
#[test]
fn deposit_residual_sign_responds_to_rate_perturbation() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.05_f64;
let (times, discounts) = flat_curve(reference, daycount, r_c);
let fixing = d(2024, 1, 2);
let payment = d(2024, 4, 2);
let tau = daycount.year_fraction(fixing, payment).unwrap();
let r_simple = (r_c * tau).exp_m1() / tau;
let dep = Deposit::new(fixing, payment, r_simple + 0.005, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = dep.residual(reference, &snapshot).unwrap();
assert!(residual < -1e-6);
}
#[test]
fn deposit_residual_errors_on_empty_curve_snapshot() {
let reference = d(2024, 1, 2);
let dep = Deposit::new(reference, d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[],
discounts: &[],
};
let err = dep.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn deposit_pillar_is_payment_date() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
assert_eq!(dep.pillar(), d(2024, 4, 2));
}
#[test]
fn deposit_discount_roundtrip_through_growth_factor() {
let dep = Deposit::new(d(2024, 1, 2), d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let d_fix = 0.9876;
let d_pay = dep.implied_discount(d_fix).unwrap();
let tau = dep.accrual().unwrap();
assert!((d_fix / d_pay - (1.0 + 0.05 * tau)).abs() < 1e-15);
}
}