use crate::calendar::{Calendar, CalendarError, EndpointInclusion, TargetCalendar};
use crate::error::{IdentifierError, ValueError};
use crate::identifier::{Entry, Identifiers};
use crate::time::Date;
use std::error::Error as StdError;
use std::fmt;
use std::str::FromStr;
mod afb;
mod icma;
mod us;
pub use icma::{ActualActualIcma, DeterminationPeriod, IcmaError};
pub use us::Thirty360Us;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DayCount {
Actual360,
Actual365Fixed,
ActualActualIsda,
ActualActualAfb,
ThirtyE360,
Thirty360BondBasis,
}
impl DayCount {
pub const ALL: [Self; 6] = [
Self::Actual360,
Self::Actual365Fixed,
Self::ActualActualIsda,
Self::ActualActualAfb,
Self::ThirtyE360,
Self::Thirty360BondBasis,
];
#[must_use]
pub const fn canonical_identifier(self) -> &'static str {
match self {
Self::Actual360 => "Actual/360",
Self::Actual365Fixed => "Actual/365 (Fixed)",
Self::ActualActualIsda => "Actual/Actual (ISDA)",
Self::ActualActualAfb => "Actual/Actual (AFB)",
Self::ThirtyE360 => "30E/360",
Self::Thirty360BondBasis => "30/360 (Bond Basis)",
}
}
#[must_use]
pub fn day_count(self, start: Date, end: Date) -> i32 {
match self {
Self::Actual360
| Self::Actual365Fixed
| Self::ActualActualIsda
| Self::ActualActualAfb => end.days_since(start),
Self::ThirtyE360 => european_days(start, end),
Self::Thirty360BondBasis => {
if start > end {
-bond_basis_forward(end, start)
} else {
bond_basis_forward(start, end)
}
}
}
}
pub fn year_fraction(self, start: Date, end: Date) -> Result<f64, DayCountError> {
let fraction = match self {
Self::Actual360 => f64::from(end.days_since(start)) / 360.0,
Self::Actual365Fixed => f64::from(end.days_since(start)) / 365.0,
Self::ThirtyE360 => f64::from(european_days(start, end)) / 360.0,
Self::Thirty360BondBasis => f64::from(self.day_count(start, end)) / 360.0,
Self::ActualActualIsda => {
if start > end {
-isda_forward(end, start)
} else {
isda_forward(start, end)
}
}
Self::ActualActualAfb => {
if start > end {
-afb::forward(end, start)
} else {
afb::forward(start, end)
}
}
};
crate::numeric::finite_result("year fraction", fraction).map_err(DayCountError::from)
}
}
const IDENTIFIERS: Identifiers<DayCount> = Identifiers::new(
"day-count convention",
&[
Entry {
value: DayCount::Actual360,
canonical: "Actual/360",
aliases: &["Act/360", "A/360"],
},
Entry {
value: DayCount::Actual365Fixed,
canonical: "Actual/365 (Fixed)",
aliases: &["Act/365 (Fixed)", "A/365 (Fixed)", "A/365F"],
},
Entry {
value: DayCount::ActualActualIsda,
canonical: "Actual/Actual (ISDA)",
aliases: &["Act/Act (ISDA)", "Actual/Actual (Historical)"],
},
Entry {
value: DayCount::ActualActualAfb,
canonical: "Actual/Actual (AFB)",
aliases: &["Actual/Actual (Euro)"],
},
Entry {
value: DayCount::ThirtyE360,
canonical: "30E/360",
aliases: &["30/360 (Eurobond Basis)"],
},
Entry {
value: DayCount::Thirty360BondBasis,
canonical: "30/360 (Bond Basis)",
aliases: &["30/360 ISDA", "360/360"],
},
],
&[],
);
const ACTUAL_ACTUAL_CANDIDATES: &[&str] = &[
"Actual/Actual (ISDA)",
"Actual/Actual (ICMA)",
"Actual/Actual (AFB)",
];
const ACTUAL_365_CANDIDATES: &[&str] = &["Actual/365 (Fixed)", "Actual/Actual (ISDA)"];
const THIRTY_360_CANDIDATES: &[&str] = &[
"30/360 (Bond Basis)",
"30/360 (US)",
"30E/360",
"30E/360 (ISDA)",
];
impl fmt::Display for DayCount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.canonical_identifier())
}
}
impl FromStr for DayCount {
type Err = IdentifierError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let candidates = match text {
"Actual/Actual" | "Act/Act" | "ACT/ACT" => Some(ACTUAL_ACTUAL_CANDIDATES),
"Actual/365" => Some(ACTUAL_365_CANDIDATES),
"30/360" => Some(THIRTY_360_CANDIDATES),
_ => None,
};
if let Some(candidates) = candidates {
Err(IdentifierError::Ambiguous {
concept: "day-count convention",
text: text.to_owned(),
candidates,
})
} else {
IDENTIFIERS.parse(text)
}
}
}
fn isda_forward(start: Date, end: Date) -> f64 {
let start_year = start.year();
let end_year = end.year();
let start_length: u32 = if Date::is_leap_year(start_year) {
366
} else {
365
};
if start_year == end_year {
return f64::from(end.days_since(start)) / f64::from(start_length);
}
let end_length: u32 = if Date::is_leap_year(end_year) {
366
} else {
365
};
let first_days = start_length - start.day_of_year() + 1;
let last_days = end.day_of_year() - 1;
let first_fraction = f64::from(first_days) / f64::from(start_length);
let full_years = f64::from(end_year - start_year - 1);
let last_fraction = f64::from(last_days) / f64::from(end_length);
(first_fraction + full_years) + last_fraction
}
#[expect(
clippy::cast_possible_truncation,
reason = "valid Date components bound the absolute European day count to 3,599,639, which fits i32"
)]
fn european_days(start: Date, end: Date) -> i32 {
let (start_year, start_month, start_day) = start.to_ymd();
let (end_year, end_month, end_day) = end.to_ymd();
let count = 360 * (i64::from(end_year) - i64::from(start_year))
+ 30 * (i64::from(end_month) - i64::from(start_month))
+ i64::from(end_day.min(30))
- i64::from(start_day.min(30));
count as i32
}
fn bond_basis_forward(start: Date, end: Date) -> i32 {
let first_day = start.day().min(30);
let last_day = if end.day() == 31 && first_day == 30 {
30
} else {
end.day()
};
adjusted_days(start, end, first_day, last_day)
}
#[expect(
clippy::cast_possible_truncation,
reason = "valid Date components and adjusted days 1..31 bound the absolute result to 3,599,640, which fits i32"
)]
fn adjusted_days(start: Date, end: Date, first_day: u32, last_day: u32) -> i32 {
let count = 360 * (i64::from(end.year()) - i64::from(start.year()))
+ 30 * (i64::from(end.month()) - i64::from(start.month()))
+ i64::from(last_day)
- i64::from(first_day);
count as i32
}
#[doc(alias = "30E/360 (ISDA)")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ThirtyE360Isda {
termination: Date,
}
impl ThirtyE360Isda {
#[must_use]
pub const fn new(termination_date: Date) -> Self {
Self {
termination: termination_date,
}
}
#[must_use]
pub const fn termination_date(self) -> Date {
self.termination
}
#[must_use]
pub const fn canonical_identifier(self) -> &'static str {
"30E/360 (ISDA)"
}
#[must_use]
pub fn day_count(self, start: Date, end: Date) -> i32 {
if start == end {
return 0;
}
if start > end {
return -self.forward(end, start);
}
self.forward(start, end)
}
pub fn year_fraction(self, start: Date, end: Date) -> Result<f64, DayCountError> {
crate::numeric::finite_result(
"year fraction",
f64::from(self.day_count(start, end)) / 360.0,
)
.map_err(DayCountError::from)
}
fn forward(self, start: Date, end: Date) -> i32 {
let last_february = |date: Date| date.month() == 2 && date.is_end_of_month();
let first_day = if start.day() == 31 || last_february(start) {
30
} else {
start.day()
};
let last_day = if end.day() == 31 || (last_february(end) && end != self.termination) {
30
} else {
end.day()
};
adjusted_days(start, end, first_day, last_day)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Business252 {
source: CalendarSource,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum CalendarSource {
Rules(Calendar),
Target(TargetCalendar),
}
impl Business252 {
#[must_use]
pub fn new(calendar: Calendar) -> Self {
Self {
source: CalendarSource::Rules(calendar),
}
}
#[must_use]
pub fn with_target(calendar: TargetCalendar) -> Self {
Self {
source: CalendarSource::Target(calendar),
}
}
#[must_use]
pub const fn calendar(&self) -> Option<&Calendar> {
match &self.source {
CalendarSource::Rules(calendar) => Some(calendar),
CalendarSource::Target(_) => None,
}
}
#[must_use]
pub const fn target_calendar(&self) -> Option<&TargetCalendar> {
match &self.source {
CalendarSource::Rules(_) => None,
CalendarSource::Target(calendar) => Some(calendar),
}
}
pub fn day_count(&self, start: Date, end: Date) -> Result<i32, DayCountError> {
let reversed = start > end;
let (start, end) = if reversed { (end, start) } else { (start, end) };
let count = match &self.source {
CalendarSource::Rules(calendar) => calendar.business_days_between(
start,
end,
EndpointInclusion::Include,
EndpointInclusion::Exclude,
),
CalendarSource::Target(calendar) => calendar.business_days_between(
start,
end,
EndpointInclusion::Include,
EndpointInclusion::Exclude,
)?,
};
if reversed { Ok(-count) } else { Ok(count) }
}
pub fn year_fraction(&self, start: Date, end: Date) -> Result<f64, DayCountError> {
crate::numeric::finite_result(
"year fraction",
f64::from(self.day_count(start, end)?) / 252.0,
)
.map_err(DayCountError::from)
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum DayCountError {
Value(ValueError),
Calendar(CalendarError),
}
impl DayCountError {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Value(_) => "daycount.value",
Self::Calendar(_) => "daycount.calendar",
}
}
}
impl fmt::Display for DayCountError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Value(error) => fmt::Display::fmt(error, f),
Self::Calendar(error) => fmt::Display::fmt(error, f),
}
}
}
impl StdError for DayCountError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::Value(error) => Some(error),
Self::Calendar(error) => Some(error),
}
}
}
impl From<ValueError> for DayCountError {
fn from(error: ValueError) -> Self {
Self::Value(error)
}
}
impl From<CalendarError> for DayCountError {
fn from(error: CalendarError) -> Self {
Self::Calendar(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::NonFinite;
use std::collections::HashSet;
#[test]
fn supported_identifier_table_is_exhaustive_and_well_formed() {
assert_eq!(IDENTIFIERS.check(), Ok(()));
assert_eq!(
IDENTIFIERS.check_round_trip(&DayCount::ALL, |value| value.to_string()),
Ok(())
);
}
#[test]
fn ambiguities_list_distinct_named_candidates_without_accepting_unimplemented_values() {
for candidates in [
ACTUAL_ACTUAL_CANDIDATES,
ACTUAL_365_CANDIDATES,
THIRTY_360_CANDIDATES,
] {
assert!(candidates.len() >= 2);
let unique: HashSet<_> = candidates.iter().copied().collect();
assert_eq!(unique.len(), candidates.len());
for &candidate in candidates {
assert!(!candidate.is_empty());
assert_eq!(candidate.trim(), candidate);
assert!(!candidate.chars().any(char::is_control));
}
}
for (text, candidates) in [
("Actual/Actual", ACTUAL_ACTUAL_CANDIDATES),
("Act/Act", ACTUAL_ACTUAL_CANDIDATES),
("ACT/ACT", ACTUAL_ACTUAL_CANDIDATES),
("Actual/365", ACTUAL_365_CANDIDATES),
("30/360", THIRTY_360_CANDIDATES),
] {
assert_eq!(
text.parse::<DayCount>(),
Err(IdentifierError::Ambiguous {
concept: "day-count convention",
text: text.to_owned(),
candidates,
})
);
}
}
#[test]
fn numeric_error_conversion_preserves_context_and_source() {
let cause = crate::numeric::finite_result("year fraction", f64::INFINITY)
.expect_err("non-finite result");
assert_eq!(
cause,
ValueError::ResultNotFinite {
quantity: "year fraction",
value: NonFinite::PositiveInfinity
}
);
let error = DayCountError::from(cause.clone());
assert_eq!(error.kind(), "daycount.value");
assert_eq!(error.clone(), error);
assert_eq!(error.to_string(), cause.to_string());
assert_eq!(
error
.source()
.expect("wrapped cause")
.downcast_ref::<ValueError>(),
Some(&cause)
);
assert!(error.source().expect("wrapped cause").source().is_none());
}
}