use super::{CdsMaturityConvention, DateGenerationRule, ScheduleCalendar, ScheduleError};
use crate::calendar::{BusinessDayConvention, EndpointInclusion};
use crate::time::{Date, DateError};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CdsCouponDates {
unadjusted_start: Date,
unadjusted_end: Date,
payment_date: Date,
accrual_start: Date,
accrual_end: Date,
accrual_end_inclusion: EndpointInclusion,
accrual_day_count: u32,
}
impl CdsCouponDates {
#[must_use]
pub const fn unadjusted_start(&self) -> Date {
self.unadjusted_start
}
#[must_use]
pub const fn unadjusted_end(&self) -> Date {
self.unadjusted_end
}
#[must_use]
pub const fn payment_date(&self) -> Date {
self.payment_date
}
#[must_use]
pub const fn accrual_start(&self) -> Date {
self.accrual_start
}
#[must_use]
pub const fn accrual_end(&self) -> Date {
self.accrual_end
}
#[must_use]
pub const fn accrual_end_inclusion(&self) -> EndpointInclusion {
self.accrual_end_inclusion
}
#[must_use]
pub const fn accrual_day_count(&self) -> u32 {
self.accrual_day_count
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CdsDatePlan {
trade_date: Date,
payment_selection_cutoff: Date,
unadjusted_maturity: Date,
calendar: ScheduleCalendar,
convention: CdsMaturityConvention,
coupons: Vec<CdsCouponDates>,
}
impl CdsDatePlan {
pub const MIN_COUPONS: u32 = 1;
pub const MAX_COUPONS: u32 = 39_996;
pub fn standard_following(
trade_date: Date,
unadjusted_maturity: Date,
calendar: ScheduleCalendar,
convention: CdsMaturityConvention,
max_coupons: u32,
) -> Result<Self, ScheduleError> {
if !(Self::MIN_COUPONS..=Self::MAX_COUPONS).contains(&max_coupons) {
return Err(ScheduleError::InvalidCdsCouponLimit {
requested: max_coupons,
minimum: Self::MIN_COUPONS,
maximum: Self::MAX_COUPONS,
});
}
if trade_date >= unadjusted_maturity {
return Err(ScheduleError::InvalidRange {
start: trade_date,
end: unadjusted_maturity,
});
}
if unadjusted_maturity.day() != 20 || !unadjusted_maturity.month().is_multiple_of(3) {
return Err(ScheduleError::InvalidCdsMaturity {
maturity: unadjusted_maturity,
convention,
});
}
let context = Context {
trade: trade_date,
maturity: unadjusted_maturity,
calendar: &calendar,
};
context.guard(trade_date, unadjusted_maturity, "original coverage")?;
let cutoff = trade_date
.checked_add_days(1)
.map_err(|cause| context.date_error("payment cutoff", cause))?;
let cutoff_month = month_coordinate(cutoff) - i64::from(cutoff.day() < 20);
let quarter_month = cutoff_month - (cutoff_month - 2).rem_euclid(3);
let quarter = context.quarter(quarter_month, "cutoff quarter")?;
context.guard(
trade_date.min(quarter),
unadjusted_maturity,
"cutoff quarter coverage",
)?;
let quarter_payment = context.adjust(quarter, None, "cutoff quarter adjustment")?;
let first_month = if quarter_payment <= cutoff {
if quarter == unadjusted_maturity {
return Err(ScheduleError::NoEligibleCdsPayment {
trade_date,
payment_selection_cutoff: cutoff,
unadjusted_maturity,
final_payment: quarter_payment,
});
}
quarter_month + 3
} else {
quarter_month
};
let required = bounded_count((month_coordinate(unadjusted_maturity) - first_month) / 3 + 1);
if required > max_coupons {
return Err(ScheduleError::CdsCouponLimitExceeded {
maximum: max_coupons,
required,
});
}
let mut previous_raw = if first_month > quarter_month {
quarter
} else {
context.quarter(first_month - 3, "initial accrual precursor")?
};
let span_start = trade_date.min(previous_raw);
context.guard(span_start, unadjusted_maturity, "precursor coverage")?;
let mut previous_adjusted = if previous_raw == quarter {
quarter_payment
} else {
context.adjust(previous_raw, None, "precursor adjustment")?
};
let first_raw = if first_month == quarter_month {
quarter
} else {
context.quarter(first_month, "coupon quarter")?
};
let first_payment = if first_raw == quarter {
quarter_payment
} else {
context.adjust(first_raw, Some(0), "coupon adjustment")?
};
check_order(0, previous_raw, first_raw, previous_adjusted, first_payment)?;
let count = required as usize;
let mut coupons = Vec::new();
coupons
.try_reserve_exact(count)
.map_err(|_| ScheduleError::AllocationFailed {
phase: "CDS coupon records",
requested: count,
})?;
for position in 0..required {
let coordinate = first_month + 3 * i64::from(position);
let raw = if position == 0 {
first_raw
} else {
context.quarter(coordinate, "coupon quarter")?
};
let adjusted = if position == 0 {
first_payment
} else {
context.adjust(raw, Some(position as usize), "coupon adjustment")?
};
if position > 0 {
check_order(
position as usize,
previous_raw,
raw,
previous_adjusted,
adjusted,
)?;
}
let final_coupon = raw == unadjusted_maturity;
let accrual_end = if final_coupon { raw } else { adjusted };
let day_count =
i64::from(accrual_end.days_since(previous_adjusted)) + i64::from(final_coupon);
coupons.push(CdsCouponDates {
unadjusted_start: previous_raw,
unadjusted_end: raw,
payment_date: adjusted,
accrual_start: previous_adjusted,
accrual_end,
accrual_end_inclusion: if final_coupon {
EndpointInclusion::Include
} else {
EndpointInclusion::Exclude
},
accrual_day_count: bounded_count(day_count),
});
previous_raw = raw;
previous_adjusted = adjusted;
}
context.guard(span_start, previous_adjusted, "final coverage")?;
Ok(Self {
trade_date,
payment_selection_cutoff: cutoff,
unadjusted_maturity,
calendar,
convention,
coupons,
})
}
#[must_use]
pub const fn trade_date(&self) -> Date {
self.trade_date
}
#[must_use]
pub const fn payment_selection_cutoff(&self) -> Date {
self.payment_selection_cutoff
}
#[must_use]
pub const fn unadjusted_maturity(&self) -> Date {
self.unadjusted_maturity
}
#[must_use]
pub const fn calendar(&self) -> &ScheduleCalendar {
&self.calendar
}
#[must_use]
pub const fn maturity_convention(&self) -> CdsMaturityConvention {
self.convention
}
#[must_use]
pub const fn rule(&self) -> DateGenerationRule {
match self.convention {
CdsMaturityConvention::Quarterly2009 => DateGenerationRule::Cds,
CdsMaturityConvention::Semiannual2015 => DateGenerationRule::Cds2015,
}
}
#[must_use]
pub fn coupon_dates(&self) -> &[CdsCouponDates] {
&self.coupons
}
}
struct Context<'a> {
trade: Date,
maturity: Date,
calendar: &'a ScheduleCalendar,
}
impl Context<'_> {
fn date_error(&self, role: &'static str, cause: DateError) -> ScheduleError {
ScheduleError::CdsDate {
trade_date: self.trade,
unadjusted_maturity: self.maturity,
role,
input: self.trade,
cause,
}
}
fn quarter(&self, coordinate: i64, role: &'static str) -> Result<Date, ScheduleError> {
let month = self
.trade
.checked_add_months(coordinate - month_coordinate(self.trade))
.map_err(|cause| self.date_error(role, cause))?;
Date::from_ymd(month.year(), month.month(), 20)
.map_err(|cause| self.date_error(role, cause))
}
fn guard(&self, start: Date, end: Date, phase: &'static str) -> Result<(), ScheduleError> {
if let ScheduleCalendar::Target(calendar) = self.calendar {
calendar
.business_days_between(
start,
end,
EndpointInclusion::Include,
EndpointInclusion::Include,
)
.map_err(|cause| ScheduleError::CdsCalendar {
trade_date: self.trade,
unadjusted_maturity: self.maturity,
phase,
index: None,
start,
end,
cause,
})?;
}
Ok(())
}
fn adjust(
&self,
raw: Date,
index: Option<usize>,
phase: &'static str,
) -> Result<Date, ScheduleError> {
let result = match self.calendar {
ScheduleCalendar::Plain(calendar) => {
calendar.adjust(raw, BusinessDayConvention::Following)
}
ScheduleCalendar::Target(calendar) => {
calendar.adjust(raw, BusinessDayConvention::Following)
}
};
result.map_err(|cause| ScheduleError::CdsCalendar {
trade_date: self.trade,
unadjusted_maturity: self.maturity,
phase,
index,
start: raw,
end: raw,
cause,
})
}
}
fn month_coordinate(date: Date) -> i64 {
i64::from(date.year() - 1) * 12 + i64::from(date.month() - 1)
}
fn check_order(
index: usize,
previous_contractual: Date,
contractual: Date,
previous_adjusted: Date,
adjusted: Date,
) -> Result<(), ScheduleError> {
if previous_adjusted >= adjusted {
return Err(ScheduleError::CdsAdjustedOrder {
index,
previous_contractual,
contractual,
previous_adjusted,
adjusted,
});
}
Ok(())
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "positive coupon count <= 39996 or actual accrual days <= civil range + 1"
)]
fn bounded_count(value: i64) -> u32 {
value as u32
}