use super::{
DateGenerationRule, PeriodRegularity, Rolling, Schedule, ScheduleBuilder, ScheduleError,
validate_limit,
};
use crate::error::IdentifierError;
use crate::identifier::{Entry, Identifiers};
use crate::time::{Date, DateError, Month, Period, TimeUnit, Weekday};
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[expect(
clippy::exhaustive_enums,
reason = "closed four-rule analytical civil roll contract"
)]
pub enum SpecialRollRule {
ThirdWednesday,
ThirdWednesdayInclusive,
Twentieth,
TwentiethIMM,
}
impl SpecialRollRule {
pub const ALL: [Self; 4] = [
Self::ThirdWednesday,
Self::ThirdWednesdayInclusive,
Self::Twentieth,
Self::TwentiethIMM,
];
#[must_use]
pub const fn canonical_identifier(self) -> &'static str {
match self {
Self::ThirdWednesday => "ThirdWednesday",
Self::ThirdWednesdayInclusive => "ThirdWednesdayInclusive",
Self::Twentieth => "Twentieth",
Self::TwentiethIMM => "TwentiethIMM",
}
}
fn is_wednesday(self) -> bool {
matches!(self, Self::ThirdWednesday | Self::ThirdWednesdayInclusive)
}
}
const ROLL_IDS: Identifiers<SpecialRollRule> = Identifiers::new(
"special roll rule",
&[
Entry {
value: SpecialRollRule::ThirdWednesday,
canonical: "ThirdWednesday",
aliases: &[],
},
Entry {
value: SpecialRollRule::ThirdWednesdayInclusive,
canonical: "ThirdWednesdayInclusive",
aliases: &[],
},
Entry {
value: SpecialRollRule::Twentieth,
canonical: "Twentieth",
aliases: &[],
},
Entry {
value: SpecialRollRule::TwentiethIMM,
canonical: "TwentiethIMM",
aliases: &[],
},
],
&[],
);
impl fmt::Display for SpecialRollRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.canonical_identifier())
}
}
impl FromStr for SpecialRollRule {
type Err = IdentifierError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
ROLL_IDS.parse(text)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RollProvenance {
requested_start: Date,
requested_end: Date,
resolved_start: Date,
resolved_end: Date,
rule: SpecialRollRule,
tenor_length: i32,
tenor_unit: TimeUnit,
anchor_year: i32,
anchor_month: Month,
}
impl RollProvenance {
#[must_use]
pub const fn requested_start(&self) -> Date {
self.requested_start
}
#[must_use]
pub const fn requested_end(&self) -> Date {
self.requested_end
}
#[must_use]
pub const fn resolved_start(&self) -> Date {
self.resolved_start
}
#[must_use]
pub const fn resolved_end(&self) -> Date {
self.resolved_end
}
#[must_use]
pub const fn rule(&self) -> SpecialRollRule {
self.rule
}
#[must_use]
pub const fn tenor(&self) -> Period {
Period::new(self.tenor_length, self.tenor_unit)
}
#[must_use]
pub const fn anchor_year(&self) -> i32 {
self.anchor_year
}
#[must_use]
pub const fn anchor_month(&self) -> Month {
self.anchor_month
}
pub(super) fn reference_anchor(self) -> Result<Date, DateError> {
roll_point(self.anchor_year, self.anchor_month.number(), self.rule)
}
pub(super) fn reference_date(
self,
anchor: Date,
step: u32,
forward: bool,
) -> Result<Date, DateError> {
let stride = i64::from(self.tenor_length)
* if self.tenor_unit == TimeUnit::Years {
12
} else {
1
};
let magnitude = stride * i64::from(step);
let offset = if forward { magnitude } else { -magnitude };
checked_roll_date(anchor, offset, self.rule)
}
}
impl ScheduleBuilder {
pub fn build_roll(
&self,
tenor: Period,
rule: SpecialRollRule,
max_boundaries: u32,
) -> Result<Schedule, ScheduleError> {
validate_limit(max_boundaries)?;
let stride = match tenor.unit() {
TimeUnit::Months => i64::from(tenor.length()),
TimeUnit::Years => 12 * i64::from(tenor.length()),
TimeUnit::Days | TimeUnit::Weeks => 0,
};
if stride <= 0 || (rule == SpecialRollRule::TwentiethIMM && stride % 3 != 0) {
return Err(ScheduleError::InvalidRollTenor { rule, tenor });
}
self.calendar.validate_span(self.start, self.end)?;
let (start, end, anchor) = match rule {
SpecialRollRule::ThirdWednesday => (
self.start,
self.end,
roll_date(self.start, 0, rule, tenor, "anchor")?,
),
SpecialRollRule::ThirdWednesdayInclusive => {
let start = roll_date(self.start, 0, rule, tenor, "start")?;
let end = roll_date(self.end, 0, rule, tenor, "end")?;
(start, end, start)
}
SpecialRollRule::Twentieth | SpecialRollRule::TwentiethIMM => {
let anchor = twentieth_on_or_after(self.start, rule, tenor, "anchor")?;
let end = twentieth_on_or_after(self.end, rule, tenor, "end")?;
(self.start, end, anchor)
}
};
if start >= end {
return Err(ScheduleError::ResolvedRollRange {
rule,
tenor,
requested_start: self.start,
requested_end: self.end,
resolved_start: start,
resolved_end: end,
});
}
self.calendar.validate_span_for(
self.start.min(start),
self.end.max(end),
"roll coverage",
)?;
let anchor_month =
Month::try_from(anchor.month()).map_err(|cause| ScheduleError::RollDate {
rule,
tenor,
role: "anchor",
input: self.start,
cause,
})?;
let provenance = RollProvenance {
requested_start: self.start,
requested_end: self.end,
resolved_start: start,
resolved_end: end,
rule,
tenor_length: tenor.length(),
tenor_unit: tenor.unit(),
anchor_year: anchor.year(),
anchor_month,
};
let anchor_coordinate = month_coordinate(anchor);
let end_coordinate = month_coordinate(end);
let mut coordinate = anchor_coordinate + if rule.is_wednesday() { stride } else { 0 };
let mut rolling = Rolling::new(start)?;
let mut previous = start;
while coordinate <= end_coordinate {
let candidate = roll_date(
anchor,
coordinate - anchor_coordinate,
rule,
tenor,
"anchor",
)?;
if candidate >= end {
break;
}
if candidate > start {
rolling.push_limited(
candidate,
regularity(previous, candidate, anchor_coordinate, stride, rule),
max_boundaries,
)?;
previous = candidate;
}
coordinate += stride;
}
rolling.push_limited(
end,
regularity(previous, end, anchor_coordinate, stride, rule),
max_boundaries,
)?;
let mut schedule = self.finish(
rolling,
DateGenerationRule::Roll(rule),
Some(tenor),
None,
None,
None,
)?;
schedule.roll_provenance = Some(provenance);
Ok(schedule)
}
}
pub(super) fn month_coordinate(date: Date) -> i64 {
12 * i64::from(date.year() - 1) + i64::from(date.month() - 1)
}
fn roll_date(
input: Date,
months: i64,
rule: SpecialRollRule,
tenor: Period,
role: &'static str,
) -> Result<Date, ScheduleError> {
checked_roll_date(input, months, rule).map_err(|cause| ScheduleError::RollDate {
rule,
tenor,
role,
input,
cause,
})
}
pub(super) fn checked_roll_date(
input: Date,
months: i64,
rule: SpecialRollRule,
) -> Result<Date, DateError> {
input
.checked_add_months(months)
.and_then(|month| roll_point(month.year(), month.month(), rule))
}
fn roll_point(year: i32, month: u32, rule: SpecialRollRule) -> Result<Date, DateError> {
if rule.is_wednesday() {
Date::nth_weekday(year, month, 3, Weekday::Wednesday)
} else {
Date::from_ymd(year, month, 20)
}
}
fn twentieth_on_or_after(
input: Date,
rule: SpecialRollRule,
tenor: Period,
role: &'static str,
) -> Result<Date, ScheduleError> {
let months = twentieth_months_ahead(input, rule == SpecialRollRule::TwentiethIMM);
roll_date(input, months, rule, tenor, role)
}
pub(super) fn twentieth_months_ahead(input: Date, quarterly: bool) -> i64 {
let mut months = i64::from(input.day() > 20);
if quarterly {
months += (3 - (i64::from(input.month()) + months) % 3) % 3;
}
months
}
pub(super) fn regularity(
left: Date,
right: Date,
anchor: i64,
stride: i64,
rule: SpecialRollRule,
) -> PeriodRegularity {
let left_month = month_coordinate(left);
let right_month = month_coordinate(right);
let on_roll = |date: Date| {
if rule.is_wednesday() {
(15..=21).contains(&date.day()) && date.weekday() == Weekday::Wednesday
} else {
date.day() == 20
&& (rule != SpecialRollRule::TwentiethIMM || date.month().is_multiple_of(3))
}
};
if right_month - left_month == stride
&& (left_month - anchor).rem_euclid(stride) == 0
&& on_roll(left)
&& on_roll(right)
{
PeriodRegularity::Regular
} else {
PeriodRegularity::Stub
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calendar::{BusinessDayConvention, Calendar};
use crate::schedule::ScheduleCalendar;
#[test]
fn roll_and_generation_identifier_tables_are_complete_and_well_formed() {
assert_eq!(ROLL_IDS.check(), Ok(()));
assert_eq!(
ROLL_IDS.check_round_trip(&SpecialRollRule::ALL, |rule| rule.to_string()),
Ok(())
);
assert_eq!(super::super::RULE_IDS.check(), Ok(()));
assert_eq!(
super::super::RULE_IDS
.check_round_trip(&DateGenerationRule::ALL, |rule| rule.to_string()),
Ok(())
);
}
#[test]
fn supplied_end_month_roll_can_be_an_off_stride_stub() -> Result<(), crate::error::Error> {
let start = Date::from_ymd(2024, 1, 20)?;
let requested_end = Date::from_ymd(2024, 6, 21)?;
let schedule = ScheduleBuilder::new(
start,
requested_end,
ScheduleCalendar::Plain(Calendar::null()),
BusinessDayConvention::Unadjusted,
BusinessDayConvention::Unadjusted,
)?
.build_roll(
Period::new(3, TimeUnit::Months),
SpecialRollRule::Twentieth,
4,
)?;
assert_eq!(
schedule.contractual_dates(),
[
start,
Date::from_ymd(2024, 4, 20)?,
Date::from_ymd(2024, 7, 20)?
]
);
assert_eq!(schedule.regularity(), [PeriodRegularity::Regular; 2]);
let off_stride = ScheduleBuilder::new(
start,
Date::from_ymd(2024, 5, 21)?,
ScheduleCalendar::Plain(Calendar::null()),
BusinessDayConvention::Unadjusted,
BusinessDayConvention::Unadjusted,
)?
.build_roll(
Period::new(3, TimeUnit::Months),
SpecialRollRule::Twentieth,
3,
)?;
assert_eq!(
off_stride.contractual_dates(),
[
start,
Date::from_ymd(2024, 4, 20)?,
Date::from_ymd(2024, 6, 20)?
]
);
assert_eq!(
off_stride.regularity(),
[PeriodRegularity::Regular, PeriodRegularity::Stub]
);
Ok(())
}
}