use yield_curves::bond::{self, CashFlow};
use yield_curves::{
discount_factor, easter, forward_rate, third_wednesday, Brazil, BusinessDayConvention,
Calendar, Compounding, CubicSplineCurve, Date, DateGeneration, DayCount, JoinCalendar,
JoinRule, LinearCurve, Period, Schedule, StubConvention, Target2, Unit, Weekday, WeekendsOnly,
YieldCurveInterpolator,
};
fn approx(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() < eps
}
#[test]
fn brazilian_bus252_curve_workflow() {
let cal = Brazil;
let trade = Date::new(2025, 1, 2).unwrap(); assert!(cal.is_business_day(trade));
let tenors = [
Period::months(3),
Period::months(6),
Period::years(1),
Period::years(2),
Period::years(5),
];
let rates = [0.1315, 0.1330, 0.1345, 0.1360, 0.1380];
let mut points = Vec::new();
let mut last_t = 0.0;
for (tenor, &rate) in tenors.iter().zip(&rates) {
let maturity = cal.adjust(trade + *tenor, BusinessDayConvention::Following);
let t = cal.year_fraction_252(trade, maturity);
assert!(t > last_t, "t not increasing: {t} <= {last_t}");
last_t = t;
points.push((t, rate));
}
let curve = CubicSplineCurve::fit(&points).unwrap();
let mid_t = 0.5 * (points[1].0 + points[2].0);
let r_mid = curve.rate_at(mid_t);
assert!((0.13..=0.14).contains(&r_mid), "mid rate off: {r_mid}");
let df_short = discount_factor(points[0].1, points[0].0, Compounding::annual());
let df_long = discount_factor(points[4].1, points[4].0, Compounding::annual());
assert!(df_short > 0.0 && df_short < 1.0);
assert!(
df_long > 0.0 && df_long < df_short,
"discounts not monotone"
);
}
#[test]
fn bond_coupon_schedule_then_pricing() {
let sched = Schedule::builder(
Date::new(2025, 1, 15).unwrap(),
Date::new(2027, 1, 15).unwrap(),
Period::months(6),
)
.calendar(Box::new(WeekendsOnly))
.convention(BusinessDayConvention::ModifiedFollowing)
.build()
.unwrap();
assert_eq!(sched.len(), 5);
let settle = sched.effective_date();
let dc = DayCount::Act365Fixed;
let notional = 100.0;
let coupon = 5.0;
let n = sched.len();
let flows: Vec<CashFlow> = sched
.dates()
.iter()
.enumerate()
.skip(1) .map(|(idx, &date)| {
let amount = if idx == n - 1 {
coupon + notional
} else {
coupon
};
CashFlow {
t_years: dc.year_fraction(settle, date),
amount,
}
})
.collect();
assert_eq!(flows.len(), 4);
let ytm = 0.10;
let comp = Compounding::semi_annual();
let price = bond::price(&flows, ytm, comp).unwrap();
assert!((95.0..=105.0).contains(&price), "price off par: {price}");
let mac = bond::macaulay_duration(&flows, ytm, comp).unwrap();
let modd = bond::modified_duration(&flows, ytm, comp).unwrap();
let cvx = bond::convexity(&flows, ytm, comp).unwrap();
assert!(mac > 0.0 && mac < 2.0, "macaulay out of range: {mac}");
assert!(modd < mac, "modified should be < macaulay for periodic");
assert!(cvx > 0.0, "convexity should be positive");
}
#[test]
fn multicurrency_joint_calendar() {
let joint = JoinCalendar::new(
vec![Box::new(Brazil), Box::new(Target2)],
JoinRule::JoinHolidays,
);
let corpus = Date::new(2025, 6, 19).unwrap();
assert!(Target2.is_business_day(corpus));
assert!(
!joint.is_business_day(corpus),
"joint must drop Corpus Christi"
);
let rolled = joint.adjust(corpus, BusinessDayConvention::Following);
assert!(joint.is_business_day(rolled));
assert_eq!(rolled, Date::new(2025, 6, 20).unwrap());
let prev = Date::new(2025, 6, 18).unwrap();
assert!(joint.is_business_day(prev));
assert_eq!(joint.advance(prev, 1), Date::new(2025, 6, 20).unwrap());
}
#[test]
fn daycount_telescopes_over_schedule() {
let sched = Schedule::builder(
Date::new(2023, 3, 31).unwrap(),
Date::new(2026, 3, 31).unwrap(),
Period::months(6),
)
.convention(BusinessDayConvention::Unadjusted)
.termination_convention(BusinessDayConvention::Unadjusted)
.build()
.unwrap();
let dates = sched.dates();
let whole_start = sched.effective_date();
let whole_end = sched.termination_date();
for dc in [
DayCount::Act365Fixed,
DayCount::ActActIsda,
DayCount::Act360,
] {
let piecewise: f64 = dates.windows(2).map(|w| dc.year_fraction(w[0], w[1])).sum();
let whole = dc.year_fraction(whole_start, whole_end);
assert!(
approx(piecewise, whole, 1e-12),
"{} not additive: {piecewise} vs {whole}",
dc.name()
);
}
}
#[test]
fn forward_rate_from_fitted_curve() {
let points = [(0.5, 0.12), (1.0, 0.125), (2.0, 0.13), (5.0, 0.135)];
let curve = LinearCurve::fit(&points).unwrap();
let (t1, t2) = (1.0, 2.0);
let r1 = curve.rate_at(t1);
let r2 = curve.rate_at(t2);
let fwd = forward_rate(r1, t1, r2, t2, Compounding::Continuous).unwrap();
assert!(fwd > r2, "forward {fwd} should exceed long zero {r2}");
assert!(approx(fwd, (r2 * t2 - r1 * t1) / (t2 - t1), 1e-12));
}
#[test]
fn imm_third_wednesday_schedule() {
let sched = Schedule::builder(
Date::new(2025, 3, 19).unwrap(),
Date::new(2026, 3, 18).unwrap(),
Period::months(3),
)
.rule(DateGeneration::ThirdWednesday)
.convention(BusinessDayConvention::Unadjusted)
.termination_convention(BusinessDayConvention::Unadjusted)
.stub(StubConvention::ShortFront)
.build()
.unwrap();
for &date in sched.dates() {
assert_eq!(date.weekday(), Weekday::Wednesday, "{date} not a Wednesday");
assert_eq!(date, third_wednesday(date.year(), date.month()));
}
}
#[test]
fn public_api_surface_smoke() {
let d = Date::new(2024, 2, 29).unwrap();
assert!(d.is_leap_year());
assert_eq!((d + Period::years(1)).ymd(), (2025, 2, 28)); assert_eq!(Period::weeks(1).unit, Unit::Weeks);
assert_eq!(d.weekday(), Weekday::Thursday);
assert_eq!(easter(2025), Date::new(2025, 4, 20).unwrap());
let (a, b) = (
Date::new(2025, 1, 1).unwrap(),
Date::new(2025, 7, 1).unwrap(),
);
for dc in [
DayCount::Act360,
DayCount::Act365Fixed,
DayCount::ActActIsda,
DayCount::Thirty360Us,
DayCount::ThirtyE360,
] {
assert!(dc.year_fraction(a, b) > 0.0);
}
}