use crate::errors::BootstrapError;
use crate::types::{Date, Daycount, Frequency};
use super::schedule::SwapSchedule;
use super::{CurveSnapshot, InstrumentLike};
#[derive(Debug, Clone, PartialEq)]
pub struct OisSwap {
pub start: Date,
pub maturity: Date,
pub rate: f64,
pub freq: Frequency,
pub daycount: Daycount,
pub schedule: SwapSchedule,
}
impl OisSwap {
pub fn new(
start: Date,
maturity: Date,
rate: f64,
freq: Frequency,
daycount: Daycount,
) -> Result<Self, BootstrapError> {
if !rate.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "OIS swap rate must be finite",
});
}
if start.days_between(maturity) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "OIS swap start must precede maturity",
});
}
let schedule = SwapSchedule::from_regular(start, maturity, freq)?;
Ok(Self {
start,
maturity,
rate,
freq,
daycount,
schedule,
})
}
pub fn with_schedule(
start: Date,
maturity: Date,
rate: f64,
freq: Frequency,
daycount: Daycount,
schedule: SwapSchedule,
) -> Result<Self, BootstrapError> {
if !rate.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "OIS swap rate must be finite",
});
}
if start.days_between(maturity) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "OIS swap start must precede maturity",
});
}
if schedule.start() != start || schedule.maturity() != maturity {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule endpoints must match (start, maturity)",
});
}
Ok(Self {
start,
maturity,
rate,
freq,
daycount,
schedule,
})
}
pub(crate) fn fixed_leg_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.rate * annuity)
}
pub(crate) fn float_leg_pv(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let t_start = curve
.daycount
.year_fraction(curve.reference_date, self.start)?;
let t_maturity = curve
.daycount
.year_fraction(curve.reference_date, self.maturity)?;
let d_start = curve
.discount_at(t_start)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
let d_maturity =
curve
.discount_at(t_maturity)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if !d_start.is_finite() || d_start <= 0.0 || !d_maturity.is_finite() || d_maturity <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(d_start - d_maturity)
}
}
impl InstrumentLike for OisSwap {
#[inline]
fn pillar(&self) -> Date {
self.maturity
}
fn residual(
&self,
reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let fixed = self.fixed_leg_pv(reference_date, curve)?;
let float = self.float_leg_pv(reference_date, curve)?;
Ok(fixed - float)
}
}
#[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_5y_annual_swap() {
let start = d(2024, 1, 2);
let maturity = d(2029, 1, 2);
let swap =
OisSwap::new(start, maturity, 0.03, Frequency::Annual, Daycount::Act360).unwrap();
assert_eq!(swap.start, start);
assert_eq!(swap.maturity, maturity);
assert!((swap.rate - 0.03).abs() < 1e-15);
assert_eq!(swap.freq, Frequency::Annual);
assert_eq!(swap.daycount, Daycount::Act360);
assert_eq!(swap.schedule.len(), 5);
}
#[test]
fn new_accepts_6m_once_at_maturity() {
let start = d(2024, 1, 2);
let maturity = d(2024, 7, 2);
let swap = OisSwap::new(
start,
maturity,
0.025,
Frequency::OnceAtMaturity,
Daycount::Act360,
)
.unwrap();
assert_eq!(swap.schedule.len(), 1);
assert_eq!(swap.schedule.period_start(0), start);
assert_eq!(swap.schedule.period_end(0), maturity);
}
#[test]
fn new_accepts_negative_rate() {
let start = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let swap =
OisSwap::new(start, maturity, -0.002, Frequency::Annual, Daycount::Act360).unwrap();
assert!(swap.rate < 0.0);
}
#[test]
fn new_rejects_nan_rate() {
let err = OisSwap::new(
d(2024, 1, 2),
d(2029, 1, 2),
f64::NAN,
Frequency::Annual,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_rate() {
let err = OisSwap::new(
d(2024, 1, 2),
d(2029, 1, 2),
f64::INFINITY,
Frequency::Annual,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inverted_dates() {
let err = OisSwap::new(
d(2029, 1, 2),
d(2024, 1, 2),
0.03,
Frequency::Annual,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_equal_start_and_maturity() {
let s = d(2024, 1, 2);
let err = OisSwap::new(s, s, 0.03, Frequency::Annual, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_propagates_irregular_schedule_error() {
let err = OisSwap::new(
d(2024, 1, 2),
d(2025, 2, 2),
0.03,
Frequency::SemiAnnual,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn with_schedule_accepts_matching_schedule() {
let start = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let sch = SwapSchedule::from_regular(start, maturity, Frequency::Annual).unwrap();
let swap = OisSwap::with_schedule(
start,
maturity,
0.03,
Frequency::Annual,
Daycount::Act360,
sch,
)
.unwrap();
assert_eq!(swap.schedule.len(), 2);
}
#[test]
fn with_schedule_rejects_mismatched_endpoints() {
let start = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let other = d(2027, 1, 2);
let sch = SwapSchedule::from_regular(start, other, Frequency::Annual).unwrap();
let err = OisSwap::with_schedule(
start,
maturity,
0.03,
Frequency::Annual,
Daycount::Act360,
sch,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn with_schedule_rejects_nan_rate() {
let start = d(2024, 1, 2);
let maturity = d(2026, 1, 2);
let sch = SwapSchedule::from_regular(start, maturity, Frequency::Annual).unwrap();
let err = OisSwap::with_schedule(
start,
maturity,
f64::NAN,
Frequency::Annual,
Daycount::Act360,
sch,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn pillar_is_maturity_date() {
let start = d(2024, 1, 2);
let maturity = d(2029, 1, 2);
let swap =
OisSwap::new(start, maturity, 0.03, Frequency::Annual, Daycount::Act360).unwrap();
assert_eq!(swap.pillar(), maturity);
}
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_ois_rate_flat(swap: &OisSwap, reference: Date, r_c: f64) -> f64 {
let dc = swap.daycount;
let t0 = dc.year_fraction(reference, swap.start).unwrap();
let tn = dc.year_fraction(reference, swap.maturity).unwrap();
let numerator = (-r_c * t0).exp() - (-r_c * tn).exp();
let mut annuity = 0.0_f64;
for i in 0..swap.schedule.len() {
let s = swap.schedule.period_start(i);
let e = swap.schedule.period_end(i);
let tau = dc.year_fraction(s, e).unwrap();
let t_pay = dc.year_fraction(reference, e).unwrap();
annuity += tau * (-r_c * t_pay).exp();
}
numerator / annuity
}
#[test]
fn par_ois_rate_zeroes_residual_5y_annual() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let start = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let placeholder = OisSwap::new(start, maturity, 0.0, Frequency::Annual, daycount).unwrap();
let r_par = par_ois_rate_flat(&placeholder, reference, r_c);
let swap = OisSwap::new(start, maturity, r_par, Frequency::Annual, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = swap.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"OIS residual on flat curve must be zero to 1e-10, got {residual}",
);
}
#[test]
fn par_ois_rate_zeroes_residual_6m_once_at_maturity() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.025_f64;
let start = reference;
let maturity = d(2024, 7, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let placeholder =
OisSwap::new(start, maturity, 0.0, Frequency::OnceAtMaturity, daycount).unwrap();
let r_par = par_ois_rate_flat(&placeholder, reference, r_c);
let swap =
OisSwap::new(start, maturity, r_par, Frequency::OnceAtMaturity, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = swap.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"short-OIS residual on flat curve must be zero to 1e-10, got {residual}",
);
}
#[test]
fn residual_sign_responds_to_rate_perturbation() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let start = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let placeholder = OisSwap::new(start, maturity, 0.0, Frequency::Annual, daycount).unwrap();
let r_par = par_ois_rate_flat(&placeholder, reference, r_c);
let swap =
OisSwap::new(start, maturity, r_par + 0.005, Frequency::Annual, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = swap.residual(reference, &snapshot).unwrap();
assert!(residual > 1e-6);
}
#[test]
fn fixed_and_float_legs_match_at_par() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let start = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let placeholder = OisSwap::new(start, maturity, 0.0, Frequency::Annual, daycount).unwrap();
let r_par = par_ois_rate_flat(&placeholder, reference, r_c);
let swap = OisSwap::new(start, maturity, r_par, Frequency::Annual, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let fixed = swap.fixed_leg_pv(reference, &snapshot).unwrap();
let float = swap.float_leg_pv(reference, &snapshot).unwrap();
assert!((fixed - float).abs() < 1e-10);
}
#[test]
fn float_leg_telescopes_to_d_start_minus_d_maturity() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let start = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let swap = OisSwap::new(start, maturity, 0.03, Frequency::Annual, daycount).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let float = swap.float_leg_pv(reference, &snapshot).unwrap();
let t0 = daycount.year_fraction(reference, start).unwrap();
let tn = daycount.year_fraction(reference, maturity).unwrap();
let expected = (-r_c * t0).exp() - (-r_c * tn).exp();
assert!((float - expected).abs() < 1e-12);
}
#[test]
fn residual_errors_on_empty_curve_snapshot() {
let reference = d(2024, 1, 2);
let swap = OisSwap::new(
reference,
d(2029, 1, 2),
0.03,
Frequency::Annual,
Daycount::Act360,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[],
discounts: &[],
};
let err = swap.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn residual_linear_in_rate() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let start = reference;
let maturity = d(2029, 1, 2);
let (times, discounts) = flat_curve(reference, daycount, r_c);
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let placeholder = OisSwap::new(start, maturity, 0.0, Frequency::Annual, daycount).unwrap();
let r_par = par_ois_rate_flat(&placeholder, reference, r_c);
let swap_a = OisSwap::new(start, maturity, r_par, Frequency::Annual, daycount).unwrap();
let swap_b =
OisSwap::new(start, maturity, r_par + 0.01, Frequency::Annual, daycount).unwrap();
let r_a = swap_a.residual(reference, &snapshot).unwrap();
let r_b = swap_b.residual(reference, &snapshot).unwrap();
let mut annuity = 0.0_f64;
for i in 0..swap_a.schedule.len() {
let s = swap_a.schedule.period_start(i);
let e = swap_a.schedule.period_end(i);
let tau = daycount.year_fraction(s, e).unwrap();
let t_pay = daycount.year_fraction(reference, e).unwrap();
annuity += tau * (-r_c * t_pay).exp();
}
assert!(((r_b - r_a) - 0.01 * annuity).abs() < 1e-12);
}
}