use crate::errors::{BootstrapError, TypeError};
use crate::types::{Date, Daycount};
use super::{CurveSnapshot, InstrumentLike};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fra {
pub start: Date,
pub end: Date,
pub rate: f64,
pub daycount: Daycount,
}
impl Fra {
pub fn new(
start: Date,
end: Date,
rate: f64,
daycount: Daycount,
) -> Result<Self, BootstrapError> {
if !rate.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "fra rate must be finite",
});
}
if start.days_between(end) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "fra start must be strictly before end",
});
}
Ok(Self {
start,
end,
rate,
daycount,
})
}
pub fn accrual(&self) -> Result<f64, TypeError> {
self.daycount.year_fraction(self.start, self.end)
}
pub fn implied_discount(&self, discount_at_start: f64) -> Result<f64, BootstrapError> {
if !discount_at_start.is_finite() || discount_at_start <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "discount factor at start 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_start / growth)
}
}
impl InstrumentLike for Fra {
#[inline]
fn pillar(&self) -> Date {
self.end
}
fn residual(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let tau = self.accrual()?;
let t_start = curve
.daycount
.year_fraction(curve.reference_date, self.start)?;
let t_end = curve
.daycount
.year_fraction(curve.reference_date, self.end)?;
let d_start = curve
.discount_at(t_start)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
let d_end = curve
.discount_at(t_end)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if d_end <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(d_start / d_end - (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_fra() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
assert_eq!(fra.start, d(2024, 7, 2));
assert_eq!(fra.end, d(2024, 10, 1));
assert!((fra.rate - 0.04).abs() < 1e-15);
}
#[test]
fn new_accepts_negative_rate() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), -0.005, Daycount::Act360).unwrap();
assert!(fra.rate < 0.0);
}
#[test]
fn new_rejects_nan_rate() {
let err = Fra::new(d(2024, 7, 2), d(2024, 10, 1), f64::NAN, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_rate() {
let err = Fra::new(
d(2024, 7, 2),
d(2024, 10, 1),
f64::INFINITY,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_neg_inf_rate() {
let err = Fra::new(
d(2024, 7, 2),
d(2024, 10, 1),
f64::NEG_INFINITY,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inverted_dates() {
let err = Fra::new(d(2024, 10, 1), d(2024, 7, 2), 0.04, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_zero_length_period() {
let err = Fra::new(d(2024, 7, 2), d(2024, 7, 2), 0.04, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn accrual_act360_matches_91_days() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
let tau = fra.accrual().unwrap();
assert!((tau - 91.0 / 360.0).abs() < 1e-15);
}
#[test]
fn accrual_propagates_business252_error() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Business252).unwrap();
let err = fra.accrual().unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn implied_discount_basic_formula() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
let d_end = fra.implied_discount(0.98).unwrap();
let tau = 91.0_f64 / 360.0;
let expected = 0.98 / (1.0 + 0.04 * tau);
assert!((d_end - expected).abs() < 1e-15);
}
#[test]
fn implied_discount_with_unit_d_start_equals_simple_discount() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
let tau = fra.accrual().unwrap();
let d_end = fra.implied_discount(1.0).unwrap();
let cont = (-0.04_f64 * tau).exp();
assert!((d_end - cont).abs() > 1e-9);
assert!((d_end - 1.0 / (1.0 + 0.04 * tau)).abs() < 1e-15);
}
#[test]
fn implied_discount_rejects_non_finite_d_start() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
assert!(matches!(
fra.implied_discount(f64::NAN).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
fra.implied_discount(f64::INFINITY).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_d_start() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
assert!(matches!(
fra.implied_discount(0.0).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
fra.implied_discount(-0.5).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_growth() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), -10.0, Daycount::Act360).unwrap();
let err = fra.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 fra_residual_is_zero_on_flat_curve_with_implied_rate() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.04_f64;
let (times, discounts) = flat_curve(reference, daycount, r_c);
let start = Date::from_serial(reference.serial() + 2 * 91);
let end = Date::from_serial(reference.serial() + 3 * 91);
let tau = daycount.year_fraction(start, end).unwrap();
let r_simple = (r_c * tau).exp_m1() / tau;
let fra = Fra::new(start, end, r_simple, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = fra.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"residual on flat curve must be zero to 1e-12, got {residual}",
);
}
#[test]
fn fra_residual_sign_responds_to_rate_perturbation() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.04_f64;
let (times, discounts) = flat_curve(reference, daycount, r_c);
let start = Date::from_serial(reference.serial() + 2 * 91);
let end = Date::from_serial(reference.serial() + 3 * 91);
let tau = daycount.year_fraction(start, end).unwrap();
let r_simple = (r_c * tau).exp_m1() / tau;
let fra = Fra::new(start, end, r_simple + 0.005, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = fra.residual(reference, &snapshot).unwrap();
assert!(residual < -1e-6);
}
#[test]
fn fra_residual_errors_on_empty_curve_snapshot() {
let reference = d(2024, 1, 2);
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[],
discounts: &[],
};
let err = fra.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn fra_residual_propagates_business252_error() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let (times, discounts) = flat_curve(reference, daycount, 0.04);
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Business252).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let err = fra.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::Type(_)));
}
#[test]
fn fra_pillar_is_end_date() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
assert_eq!(fra.pillar(), d(2024, 10, 1));
}
#[test]
fn fra_discount_roundtrip_through_growth_factor() {
let fra = Fra::new(d(2024, 7, 2), d(2024, 10, 1), 0.04, Daycount::Act360).unwrap();
let d_start = 0.9876;
let d_end = fra.implied_discount(d_start).unwrap();
let tau = fra.accrual().unwrap();
assert!((d_start / d_end - (1.0 + 0.04 * tau)).abs() < 1e-15);
}
#[test]
fn fra_par_rate_target_value_just_above_4_pct() {
let tau = 91.0_f64 / 360.0;
let r_simple = (0.04_f64 * tau).exp_m1() / tau;
assert!((r_simple * tau - (0.04_f64 * tau).exp_m1()).abs() < 1e-18);
assert!(r_simple > 0.04);
assert!(r_simple < 0.041);
}
}