#![allow(clippy::unwrap_used, clippy::expect_used)]
use core::str::FromStr;
use fasti::{
Act360, Act365Fixed, ActActICMA, ActActISDA, BusinessDayConvention, Calendar, CalendarBuilder,
Date, DateRange, DayCount, EasterMethod, EasterOffset, FixedDate, Fraction, Frequency,
Generation, LastWeekday, Month, NthWeekday, OneOff, Ordinal, Period, Rule, Schedule,
ScheduleBuilder, Thirty360Bond, Thirty360European, Thirty360ISDA, Thirty360US, TimeError,
Weekday, Weekend, Year, YearRange, calendars, easter_monday, easter_sunday,
};
#[test]
fn end_to_end_coupon_accrual() -> Result<(), TimeError> {
let schedule = ScheduleBuilder::new(
Date::from_ymd(2025, Month::Jan, 15)?,
Date::from_ymd(2027, Month::Jan, 15)?,
Period::Months(6),
calendars::us::SETTLEMENT,
)
.backwards()
.with_convention(BusinessDayConvention::ModifiedFollowing)
.with_termination_convention(BusinessDayConvention::Unadjusted)
.build()?;
assert_eq!(schedule.dates().len(), 5);
assert_eq!(schedule.periods().count(), 4);
let mut total = Fraction::new(0, 1).expect("0/1 is a valid fraction");
let mut cursor = schedule.dates()[0];
for period in schedule.periods() {
assert_eq!(period.start, cursor);
let yf = ActActISDA.year_fraction(period.start, period.end);
assert!(!yf.is_negative() && !yf.is_zero());
total = total.checked_add(yf).expect("accruals do not overflow i64");
cursor = period.end;
}
assert_eq!(cursor, *schedule.dates().last().expect("non-empty"));
let whole = ActActISDA.year_fraction(schedule.dates()[0], cursor);
assert_eq!(total.cmp_cross(whole), core::cmp::Ordering::Equal);
let (num, den) = whole.parts();
let notional: i128 = 1_000_000_000;
assert_eq!(
notional * i128::from(num) / i128::from(den),
notional * 2 );
Ok(())
}
#[test]
fn every_day_count_is_reachable() -> Result<(), TimeError> {
let start = Date::from_ymd(2025, Month::Jan, 1)?;
let end = Date::from_ymd(2025, Month::Jul, 1)?;
let simple: [&dyn DayCount; 6] = [
&Act360,
&Act365Fixed,
&ActActISDA,
&Thirty360Bond,
&Thirty360US,
&Thirty360European,
];
for dc in simple {
assert!(!dc.name().is_empty(), "every convention names itself");
let yf = dc.year_fraction(start, end);
assert!(!yf.is_negative());
assert!(yf.numerator() > 0 && yf.denominator() > 0);
}
let isda = Thirty360ISDA::new(Date::from_ymd(2030, Month::Jan, 1)?);
assert_eq!(isda.termination(), Date::from_ymd(2030, Month::Jan, 1)?);
assert!(!isda.year_fraction(start, end).is_negative());
let schedule = ScheduleBuilder::new(
start,
Date::from_ymd(2026, Month::Jan, 1)?,
Period::Months(6),
calendars::WEEKENDS_ONLY,
)
.build()?;
let icma = ActActICMA::new(Frequency::Semiannual);
assert_eq!(icma.frequency(), Frequency::Semiannual);
let bound = icma.bind(&schedule)?;
assert_eq!(bound.schedule().dates(), schedule.dates());
assert_eq!(bound.year_fraction(start, end).parts(), (1, 2));
Ok(())
}
#[test]
fn built_in_calendars_are_const_and_distinct() -> Result<(), TimeError> {
const CALENDARS: [(&str, Calendar<'static>); 11] = [
("TARGET", calendars::TARGET),
("uk::SETTLEMENT", calendars::uk::SETTLEMENT),
("us::SETTLEMENT", calendars::us::SETTLEMENT),
("us::NYSE", calendars::us::NYSE),
("us::FEDERAL_RESERVE", calendars::us::FEDERAL_RESERVE),
("us::GOVERNMENT_BOND", calendars::us::GOVERNMENT_BOND),
("us::SOFR", calendars::us::SOFR),
("us::NERC", calendars::us::NERC),
("france::SETTLEMENT", calendars::france::SETTLEMENT),
("france::EXCHANGE", calendars::france::EXCHANGE),
("WEEKENDS_ONLY", calendars::WEEKENDS_ONLY),
];
let year = Date::from_ymd(2026, Month::Jan, 1)?..Date::from_ymd(2027, Month::Jan, 1)?;
for (name, cal) in CALENDARS {
let holidays = cal.holidays(year.clone()).count();
if name == "WEEKENDS_ONLY" {
assert_eq!(holidays, 0, "the baseline has no holidays by definition");
} else {
assert!(holidays > 0, "{name} declared no 2026 holidays");
}
}
assert_eq!(calendars::NULL_CALENDAR.holidays(year.clone()).count(), 0);
assert_eq!(
calendars::NULL_CALENDAR.business_days(year.clone()).count(),
365
);
Ok(())
}
#[test]
fn calendar_builder_composes_every_rule_variant() -> Result<(), TimeError> {
fn is_leap_day(d: Date) -> bool {
d.month() == Month::Feb && d.day() == 29
}
let owned = CalendarBuilder::new("Test Market", Weekend::SAT_SUN)
.with_rule(Rule::Fixed(FixedDate::new(Month::Jan, 1)))
.with_rule(Rule::NthWeekday(NthWeekday::new(
Ordinal::Third,
Weekday::Mon,
Month::Jan,
)))
.with_rule(Rule::LastWeekday(LastWeekday::new(
Weekday::Mon,
Month::May,
)))
.with_rule(Rule::Easter(EasterOffset::new(0)))
.with_rule(Rule::OneOff(OneOff::new(Date::from_ymd(
2026,
Month::Sep,
14,
)?)))
.with_rule(Rule::Custom(is_leap_day))
.union(calendars::us::SETTLEMENT);
let cal = owned.view();
assert_eq!(cal.name, "Test Market + US settlement");
assert!(cal.is_holiday(Date::from_ymd(2026, Month::Jan, 1)?));
assert!(cal.is_holiday(Date::from_ymd(2026, Month::Jan, 19)?)); assert!(cal.is_holiday(Date::from_ymd(2026, Month::May, 25)?)); assert!(cal.is_holiday(Date::from_ymd(2026, Month::Sep, 14)?)); assert!(cal.is_holiday(Date::from_ymd(2028, Month::Feb, 29)?)); assert!(cal.is_holiday(Date::from_ymd(2026, Month::Jul, 3)?));
Ok(())
}
#[test]
fn business_day_conventions_round_a_holiday() -> Result<(), TimeError> {
let cal = calendars::us::SETTLEMENT;
let saturday = Date::from_ymd(2026, Month::Jul, 4)?;
assert_eq!(saturday.weekday(), Weekday::Sat);
assert!(cal.is_holiday(Date::from_ymd(2026, Month::Jul, 3)?));
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::Following)?,
Date::from_ymd(2026, Month::Jul, 6)?
);
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::Preceding)?,
Date::from_ymd(2026, Month::Jul, 2)?
);
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::ModifiedFollowing)?,
Date::from_ymd(2026, Month::Jul, 6)?
);
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::ModifiedPreceding)?,
Date::from_ymd(2026, Month::Jul, 2)?
);
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::Unadjusted)?,
saturday
);
let advanced = cal.advance(
Date::from_ymd(2026, Month::Jul, 1)?,
Period::Days(2),
BusinessDayConvention::Following,
false,
)?;
assert!(cal.is_business_day(advanced));
Ok(())
}
#[test]
fn date_primitives_from_outside() -> Result<(), TimeError> {
let d = Date::from_str("2026-02-28")?;
assert_eq!(d, Date::from_ymd(2026, Month::Feb, 28)?);
assert_eq!(d.year(), Year::new(2026)?);
assert_eq!(d.month(), Month::Feb);
assert_eq!(d.day(), 28);
assert_eq!(d.weekday(), Weekday::Sat);
assert!(d.is_end_of_month());
assert_eq!(d.add_days(1)?, Date::from_ymd(2026, Month::Mar, 1)?);
assert_eq!(d.add_months(1)?, Date::from_ymd(2026, Month::Mar, 28)?);
assert_eq!(
d.advance(Period::Months(1), true)?,
Date::from_ymd(2026, Month::Mar, 31)?
);
assert!(Date::from_ymd(1900, Month::Dec, 31).is_err());
assert!(Date::from_ymd(2026, Month::Feb, 29).is_err());
assert!(Date::from_str("not-a-date").is_err());
assert_eq!(Year::new(2200), Err(TimeError::YearOutOfRange));
let range = Date::from_ymd(2026, Month::Jan, 1)?..Date::from_ymd(2026, Month::Feb, 1)?;
assert_eq!(range.days(), 31);
assert_eq!(range.dates().count(), 31);
let overlap = Date::from_ymd(2026, Month::Jan, 20)?..Date::from_ymd(2026, Month::Mar, 1)?;
assert_eq!(
range.intersect(&overlap),
Some(Date::from_ymd(2026, Month::Jan, 20)?..Date::from_ymd(2026, Month::Feb, 1)?)
);
Ok(())
}
#[test]
fn supporting_types_are_reachable() -> Result<(), TimeError> {
assert_eq!(
easter_sunday(Year::new(2026)?, EasterMethod::Western),
Date::from_ymd(2026, Month::Apr, 5)?.day_of_year()
);
assert_eq!(
easter_monday(Year::new(2026)?, EasterMethod::Western),
Date::from_ymd(2026, Month::Apr, 6)?.day_of_year()
);
assert_ne!(
easter_sunday(Year::new(2024)?, EasterMethod::Western),
easter_sunday(Year::new(2024)?, EasterMethod::Orthodox)
);
assert_eq!(Period::Months(12).normalized(), Period::Years(1));
assert_eq!(Period::Months(6).checked_mul(2), Some(Period::Months(12)));
assert_eq!(Frequency::Quarterly.per_year(), 4);
assert!(Weekend::SAT_SUN.contains(Weekday::Sat));
assert!(!Weekend::SAT_SUN.contains(Weekday::Fri));
assert!(Weekend::from_weekdays(&[Weekday::Fri, Weekday::Sat]).contains(Weekday::Fri));
assert!(!Weekend::NONE.contains(Weekday::Sun));
let years = YearRange::try_between(Year::new(2000)?, Year::new(2030)?)?;
assert!(years.contains(Year::new(2026)?));
assert!(!years.contains(Year::new(2031)?));
assert_eq!(Ordinal::try_from_u8(3)?, Ordinal::Third);
assert_eq!(Month::try_from_u8(12)?, Month::Dec);
assert_eq!(Weekday::try_from_u8(1)?, Weekday::Mon);
Ok(())
}
#[test]
fn schedule_exposes_its_generation() -> Result<(), TimeError> {
let start = Date::from_ymd(2025, Month::Mar, 31)?;
let schedule: Schedule = ScheduleBuilder::new(
start,
Date::from_ymd(2026, Month::Mar, 31)?,
Period::Months(3),
calendars::WEEKENDS_ONLY,
)
.with_end_of_month(true)
.build()?;
let generation: Generation = schedule.generation().expect("a generated schedule");
assert_eq!(generation.tenor, Period::Months(3));
assert!(generation.end_of_month);
assert_eq!(
generation.step(start, 1)?,
Date::from_ymd(2025, Month::Jun, 30)?
);
assert_eq!(
schedule.reference_periods().count(),
schedule.periods().count()
);
Ok(())
}
#[derive(Debug)]
#[allow(dead_code)]
enum DownstreamError {
Time(TimeError),
}
fn rendered(err: TimeError) -> String {
use core::fmt::Write as _;
let mut s = String::new();
write!(s, "{err}").expect("writing to a String cannot fail");
s
}
#[test]
fn errors_are_usable_downstream() {
let err = Date::from_ymd(2026, Month::Feb, 30).unwrap_err();
assert_eq!(err, TimeError::DayOutOfRange);
assert!(!rendered(err).is_empty());
assert_eq!(err, Date::from_ymd(2026, Month::Feb, 30).unwrap_err());
let wrapped = DownstreamError::Time(err);
assert!(matches!(wrapped, DownstreamError::Time(_)));
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips_through_the_public_types() -> Result<(), TimeError> {
let date = Date::from_ymd(2026, Month::Jul, 4)?;
let json = serde_json::to_string(&date).expect("Date serializes");
let back: Date = serde_json::from_str(&json).expect("Date deserializes");
assert_eq!(date, back);
let period = Period::Months(6);
let back: Period =
serde_json::from_str(&serde_json::to_string(&period).expect("ser")).expect("de");
assert_eq!(period, back);
Ok(())
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_conversions_work_at_the_boundary() -> Result<(), TimeError> {
use chrono::NaiveDate;
let naive = NaiveDate::from_ymd_opt(2026, 7, 4).expect("a real date");
let date: Date = naive.try_into()?;
assert_eq!(date, Date::from_ymd(2026, Month::Jul, 4)?);
assert_eq!(NaiveDate::from(date), naive);
let too_early = NaiveDate::from_ymd_opt(1899, 1, 1).expect("a real date");
assert!(Date::try_from(too_early).is_err());
Ok(())
}