use crate::errors::BootstrapError;
use crate::types::{Date, Daycount, Frequency, Tenor};
use super::{CurveSnapshot, InstrumentLike, SwapSchedule};
#[derive(Debug, Clone, PartialEq)]
pub struct BasisLeg {
pub start: Date,
pub maturity: Date,
pub freq: Frequency,
pub daycount: Daycount,
pub index_tenor: Tenor,
pub schedule: SwapSchedule,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BasisSwap {
pub leg_a: BasisLeg,
pub leg_b: BasisLeg,
pub spread: f64,
}
impl BasisLeg {
pub fn new(
start: Date,
maturity: Date,
freq: Frequency,
daycount: Daycount,
index_tenor: Tenor,
) -> Result<Self, BootstrapError> {
if start.days_between(maturity) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "basis leg start must be strictly before maturity",
});
}
if index_tenor.count <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "basis leg index_tenor.count must be strictly positive",
});
}
let schedule = SwapSchedule::from_regular(start, maturity, freq)?;
Ok(Self {
start,
maturity,
freq,
daycount,
index_tenor,
schedule,
})
}
pub(crate) fn float_pv_single_curve(
&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 <= 0.0 || d_maturity <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(d_start - d_maturity)
}
pub(crate) fn annuity(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let mut a = 0.0_f64;
for i in 0..self.schedule.len() {
let p_start = self.schedule.period_start(i);
let p_end = self.schedule.period_end(i);
let tau = self.daycount.year_fraction(p_start, p_end)?;
let t_pay = curve.daycount.year_fraction(curve.reference_date, p_end)?;
let d_pay = curve
.discount_at(t_pay)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if d_pay <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
a += tau * d_pay;
}
Ok(a)
}
}
impl BasisSwap {
pub fn new(leg_a: BasisLeg, leg_b: BasisLeg, spread: f64) -> Result<Self, BootstrapError> {
if !spread.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "basis swap spread must be finite",
});
}
if leg_a.start != leg_b.start {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "basis swap legs must share the same start date",
});
}
Ok(Self {
leg_a,
leg_b,
spread,
})
}
#[must_use]
#[inline]
pub fn pillar(&self) -> Date {
if self.leg_a.maturity.serial() >= self.leg_b.maturity.serial() {
self.leg_a.maturity
} else {
self.leg_b.maturity
}
}
pub(crate) fn single_curve_residual(
&self,
reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let pv_a = self.leg_a.float_pv_single_curve(reference_date, curve)?;
let pv_b = self.leg_b.float_pv_single_curve(reference_date, curve)?;
let annuity_a = self.leg_a.annuity(reference_date, curve)?;
Ok(pv_a + self.spread * annuity_a - pv_b)
}
}
impl InstrumentLike for BasisSwap {
#[inline]
fn pillar(&self) -> Date {
BasisSwap::pillar(self)
}
fn residual(
&self,
reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
self.single_curve_residual(reference_date, curve)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instruments::CurveSnapshot;
use crate::types::TenorUnit;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
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 make_leg(start: Date, maturity: Date, freq: Frequency, tenor_months: i32) -> BasisLeg {
BasisLeg::new(
start,
maturity,
freq,
Daycount::Act360,
Tenor::new(tenor_months, TenorUnit::Months),
)
.unwrap()
}
#[test]
fn new_accepts_3m_vs_6m_5y_basis_swap() {
let s = d(2024, 1, 2);
let m = d(2029, 1, 2);
let leg_a = make_leg(s, m, Frequency::Quarterly, 3);
let leg_b = make_leg(s, m, Frequency::SemiAnnual, 6);
let bs = BasisSwap::new(leg_a, leg_b, 0.0010).unwrap();
assert_eq!(bs.leg_a.schedule.len(), 20);
assert_eq!(bs.leg_b.schedule.len(), 10);
assert!((bs.spread - 0.0010).abs() < 1e-15);
}
#[test]
fn new_rejects_non_finite_spread() {
let s = d(2024, 1, 2);
let m = d(2029, 1, 2);
let leg_a = make_leg(s, m, Frequency::Quarterly, 3);
let leg_b = make_leg(s, m, Frequency::SemiAnnual, 6);
let err = BasisSwap::new(leg_a.clone(), leg_b.clone(), f64::NAN).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
let err = BasisSwap::new(leg_a, leg_b, f64::INFINITY).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_mismatched_leg_starts() {
let s_a = d(2024, 1, 2);
let s_b = d(2024, 1, 3);
let m = d(2029, 1, 2);
let leg_a = make_leg(s_a, m, Frequency::Quarterly, 3);
let leg_b = BasisLeg {
start: s_b,
maturity: m,
freq: Frequency::SemiAnnual,
daycount: Daycount::Act360,
index_tenor: Tenor::new(6, TenorUnit::Months),
schedule: SwapSchedule::from_dates(&[s_b, d(2024, 7, 3), d(2025, 1, 3)]).unwrap(),
};
let err = BasisSwap::new(leg_a, leg_b, 0.0).unwrap_err();
assert!(matches!(
err,
BootstrapError::InvalidInstrument {
reason: r,
..
} if r.contains("same start"),
));
}
#[test]
fn basis_leg_rejects_inverted_dates() {
let err = BasisLeg::new(
d(2029, 1, 2),
d(2024, 1, 2),
Frequency::Quarterly,
Daycount::Act360,
Tenor::new(3, TenorUnit::Months),
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn basis_leg_rejects_zero_index_tenor() {
let err = BasisLeg::new(
d(2024, 1, 2),
d(2029, 1, 2),
Frequency::Quarterly,
Daycount::Act360,
Tenor::new(0, TenorUnit::Months),
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn basis_leg_rejects_negative_index_tenor() {
let err = BasisLeg::new(
d(2024, 1, 2),
d(2029, 1, 2),
Frequency::Quarterly,
Daycount::Act360,
Tenor::new(-3, TenorUnit::Months),
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn pillar_returns_max_leg_maturity() {
let s = d(2024, 1, 2);
let m_a = d(2028, 1, 2);
let m_b = d(2029, 1, 2);
let leg_a = make_leg(s, m_a, Frequency::Quarterly, 3);
let leg_b = make_leg(s, m_b, Frequency::SemiAnnual, 6);
let bs = BasisSwap::new(leg_a, leg_b, 0.0).unwrap();
assert_eq!(bs.pillar(), m_b);
assert_eq!(InstrumentLike::pillar(&bs), m_b);
}
#[test]
fn pillar_returns_leg_a_when_longer() {
let s = d(2024, 1, 2);
let m_a = d(2030, 1, 2);
let m_b = d(2029, 1, 2);
let leg_a = make_leg(s, m_a, Frequency::SemiAnnual, 3);
let leg_b = make_leg(s, m_b, Frequency::SemiAnnual, 6);
let bs = BasisSwap::new(leg_a, leg_b, 0.0).unwrap();
assert_eq!(bs.pillar(), m_a);
}
#[test]
fn float_pv_single_curve_telescopes_to_d_start_minus_d_maturity() {
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 snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let leg = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let pv = leg.float_pv_single_curve(reference, &snapshot).unwrap();
let t_start = daycount.year_fraction(reference, reference).unwrap();
let t_mat = daycount.year_fraction(reference, d(2029, 1, 2)).unwrap();
let expected = (-r_c * t_start).exp() - (-r_c * t_mat).exp();
assert!((pv - expected).abs() < 1e-12);
}
#[test]
fn single_curve_residual_collapses_to_spread_times_annuity() {
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 snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let start = reference;
let maturity = d(2029, 1, 2);
let leg_a = make_leg(start, maturity, Frequency::Quarterly, 3);
let leg_b = make_leg(start, maturity, Frequency::Quarterly, 3);
let spread = 0.0010_f64;
let bs = BasisSwap::new(leg_a.clone(), leg_b, spread).unwrap();
let annuity_a = leg_a.annuity(reference, &snapshot).unwrap();
let residual = bs.single_curve_residual(reference, &snapshot).unwrap();
let expected = spread * annuity_a;
assert!(
(residual - expected).abs() < 1e-12,
"residual {residual} != spread * annuity_a {expected}",
);
}
#[test]
fn instrument_like_residual_matches_single_curve_residual() {
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 snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let leg_a = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let leg_b = make_leg(reference, d(2029, 1, 2), Frequency::SemiAnnual, 6);
let bs = BasisSwap::new(leg_a, leg_b, 0.0010).unwrap();
let via_trait = InstrumentLike::residual(&bs, reference, &snapshot).unwrap();
let direct = bs.single_curve_residual(reference, &snapshot).unwrap();
assert!((via_trait - direct).abs() < 1e-15);
}
#[test]
fn zero_spread_basis_swap_residual_is_zero_with_matched_maturities() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let r_c = 0.03_f64;
let (times, discounts) = flat_curve(reference, daycount, r_c);
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let leg_a = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let leg_b = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let bs = BasisSwap::new(leg_a, leg_b, 0.0).unwrap();
let residual = bs.single_curve_residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"matched-leg zero-spread residual must be zero, got {residual}",
);
}
#[test]
fn residual_errors_on_empty_curve_snapshot() {
let reference = d(2024, 1, 2);
let leg_a = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let leg_b = make_leg(reference, d(2029, 1, 2), Frequency::SemiAnnual, 6);
let bs = BasisSwap::new(leg_a, leg_b, 0.0010).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[],
discounts: &[],
};
let err = bs.single_curve_residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn annuity_matches_direct_sum_over_periods() {
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 snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let leg = make_leg(reference, d(2029, 1, 2), Frequency::Quarterly, 3);
let computed = leg.annuity(reference, &snapshot).unwrap();
let mut manual = 0.0_f64;
for i in 0..leg.schedule.len() {
let p_start = leg.schedule.period_start(i);
let p_end = leg.schedule.period_end(i);
let tau = daycount.year_fraction(p_start, p_end).unwrap();
let t = daycount.year_fraction(reference, p_end).unwrap();
let d_pay = snapshot.discount_at(t).unwrap();
manual += tau * d_pay;
}
assert!((computed - manual).abs() < 1e-15);
assert!(computed > 0.0 && computed < 5.0);
}
#[test]
fn debug_clone_eq_round_trip() {
let s = d(2024, 1, 2);
let m = d(2029, 1, 2);
let leg = make_leg(s, m, Frequency::Quarterly, 3);
let leg_clone = leg.clone();
assert_eq!(leg, leg_clone);
assert!(format!("{leg:?}").contains("BasisLeg"));
let bs = BasisSwap::new(leg.clone(), leg, 0.0010).unwrap();
let bs_clone = bs.clone();
assert_eq!(bs, bs_clone);
assert!(format!("{bs:?}").contains("BasisSwap"));
}
}