use std::error::Error as StdError;
use std::fmt;
use std::sync::Arc;
use crate::error::{Interval, ValueError};
use crate::math::{SumError, exact_sum};
use crate::numeric;
use crate::rates::{AccrualConvention, InterestRate, RateError};
use crate::schedule::Schedule;
use crate::time::Date;
pub(crate) mod product;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[expect(
clippy::exhaustive_enums,
reason = "cutoff equality has exactly two choices"
)]
pub enum PaymentDateInclusion {
Include,
Exclude,
}
impl PaymentDateInclusion {
pub const ALL: [Self; 2] = [Self::Include, Self::Exclude];
pub(crate) fn eligible(self, payment: Date, cutoff: Date) -> bool {
payment > cutoff || (payment == cutoff && self == Self::Include)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimpleCashFlow {
payment_date: Date,
amount: f64,
}
impl SimpleCashFlow {
pub fn new(payment_date: Date, amount: f64) -> Result<Self, CashFlowError> {
let amount = numeric::finite("amount", amount)
.map_err(|cause| value_error(None, "simple amount", cause))?;
Ok(Self {
payment_date,
amount: canonical_zero(amount),
})
}
#[must_use]
pub const fn payment_date(self) -> Date {
self.payment_date
}
#[must_use]
pub const fn amount(self) -> f64 {
self.amount
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FixedCouponRate {
rate: f64,
accrual: Arc<AccrualConvention>,
}
impl FixedCouponRate {
pub fn new(rate: f64, accrual: impl Into<AccrualConvention>) -> Result<Self, CashFlowError> {
let rate = numeric::finite("coupon rate", rate)
.map_err(|cause| value_error(None, "coupon quote", cause))?;
Ok(Self {
rate: canonical_zero(rate),
accrual: Arc::new(accrual.into()),
})
}
#[must_use]
pub const fn rate(&self) -> f64 {
self.rate
}
#[must_use]
pub fn accrual(&self) -> &AccrualConvention {
&self.accrual
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FixedRateCoupon {
start: Date,
end: Date,
payment_date: Date,
notional: f64,
coupon_rate: FixedCouponRate,
accrual_fraction: f64,
amount: f64,
}
impl FixedRateCoupon {
pub fn new(
start: Date,
end: Date,
payment_date: Date,
notional: f64,
coupon_rate: FixedCouponRate,
) -> Result<Self, CashFlowError> {
if start >= end {
return Err(CashFlowError::InvalidAccrualPeriod { start, end });
}
let notional = numeric::finite("notional", notional)
.map_err(|cause| value_error(None, "coupon notional", cause))?;
let accrual_fraction = checked_fraction(&coupon_rate, start, end, None, "coupon accrual")?;
let amount = product::product(
"coupon amount",
notional,
coupon_rate.rate,
accrual_fraction,
)
.map_err(|cause| value_error(None, "coupon amount", cause))?;
Ok(Self::from_parts(
start,
end,
payment_date,
canonical_zero(notional),
coupon_rate,
accrual_fraction,
amount,
))
}
pub(crate) const fn from_parts(
start: Date,
end: Date,
payment_date: Date,
notional: f64,
coupon_rate: FixedCouponRate,
accrual_fraction: f64,
amount: f64,
) -> Self {
Self {
start,
end,
payment_date,
notional,
coupon_rate,
accrual_fraction,
amount,
}
}
#[must_use]
pub const fn start(&self) -> Date {
self.start
}
#[must_use]
pub const fn end(&self) -> Date {
self.end
}
#[must_use]
pub const fn payment_date(&self) -> Date {
self.payment_date
}
#[must_use]
pub const fn notional(&self) -> f64 {
self.notional
}
#[must_use]
pub const fn coupon_rate(&self) -> &FixedCouponRate {
&self.coupon_rate
}
#[must_use]
pub const fn accrual_fraction(&self) -> f64 {
self.accrual_fraction
}
#[must_use]
pub const fn amount(&self) -> f64 {
self.amount
}
pub fn accrued_amount(
&self,
as_of: Date,
inclusion: PaymentDateInclusion,
) -> Result<f64, CashFlowError> {
self.accrued(as_of, inclusion, None, None)
}
fn accrued(
&self,
as_of: Date,
inclusion: PaymentDateInclusion,
index: Option<usize>,
work: Option<&mut AccrualWork>,
) -> Result<f64, CashFlowError> {
if !inclusion.eligible(self.payment_date, as_of) || as_of <= self.start {
return Ok(0.0);
}
if as_of >= self.end {
return Ok(self.amount);
}
if let Some(work) = work {
work.charge(
self.coupon_rate.accrual(),
self.start,
as_of,
index,
"partial accrual",
)?;
}
let fraction = checked_fraction(
&self.coupon_rate,
self.start,
as_of,
index,
"partial accrual",
)?;
product::product(
"accrued amount",
self.notional,
self.coupon_rate.rate,
fraction,
)
.map_err(|cause| value_error(index, "accrued amount", cause))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum CashFlow {
Simple(SimpleCashFlow),
FixedRateCoupon(FixedRateCoupon),
}
impl CashFlow {
#[must_use]
pub const fn payment_date(&self) -> Date {
match self {
Self::Simple(flow) => flow.payment_date,
Self::FixedRateCoupon(flow) => flow.payment_date,
}
}
#[must_use]
pub const fn amount(&self) -> f64 {
match self {
Self::Simple(flow) => flow.amount,
Self::FixedRateCoupon(flow) => flow.amount,
}
}
pub fn accrued_amount(
&self,
as_of: Date,
inclusion: PaymentDateInclusion,
) -> Result<f64, CashFlowError> {
match self {
Self::Simple(_) => Ok(0.0),
Self::FixedRateCoupon(flow) => flow.accrued_amount(as_of, inclusion),
}
}
}
impl From<SimpleCashFlow> for CashFlow {
fn from(flow: SimpleCashFlow) -> Self {
Self::Simple(flow)
}
}
impl From<FixedRateCoupon> for CashFlow {
fn from(flow: FixedRateCoupon) -> Self {
Self::FixedRateCoupon(flow)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Leg {
flows: Vec<CashFlow>,
}
impl Leg {
pub const MAX_FLOWS: u32 = 3_652_059;
pub fn new(flows: Vec<CashFlow>, max_flows: u32) -> Result<Self, CashFlowError> {
check_flow_count(flows.len(), max_flows)?;
for (index, pair) in flows.windows(2).enumerate() {
let previous = pair[0].payment_date();
let current = pair[1].payment_date();
if previous > current {
return Err(CashFlowError::PaymentOrder {
index: index + 1,
previous,
current,
});
}
}
Ok(Self { flows })
}
pub fn fixed_rate(
schedule: &Schedule,
notional: f64,
coupon_rate: &FixedCouponRate,
max_flows: u32,
) -> Result<Self, CashFlowError> {
let raw = schedule.contractual_dates();
let count = raw.len() - 1; check_flow_count(count, max_flows)?;
let mut flows = reserved(count, "fixed-rate leg")?;
for (index, pair) in raw.windows(2).enumerate() {
let coupon = FixedRateCoupon::new(
pair[0],
pair[1],
schedule.adjusted_dates()[index + 1],
notional,
coupon_rate.clone(),
)
.map_err(|error| error.at_index(index))?;
flows.push(coupon.into());
}
Ok(Self { flows }) }
#[must_use]
pub fn flows(&self) -> &[CashFlow] {
&self.flows
}
pub fn net_present_value(
&self,
valuation_date: Date,
discount_rate: &InterestRate,
inclusion: PaymentDateInclusion,
max_accrual_work: u64,
) -> Result<f64, CashFlowError> {
let mut work = AccrualWork {
maximum: max_accrual_work,
used: 0,
};
work.charge(
discount_rate.accrual(),
valuation_date,
valuation_date,
None,
"valuation anchor",
)?;
discount_rate
.discount_factor_between(valuation_date, valuation_date)
.map_err(|cause| rate_error(None, "valuation anchor", cause))?;
let first = self.first_eligible(valuation_date, inclusion);
let mut terms = reserved(self.flows.len() - first, "NPV terms")?;
for (index, flow) in self.flows.iter().enumerate().skip(first) {
work.charge(
discount_rate.accrual(),
valuation_date,
flow.payment_date(),
Some(index),
"discount horizon",
)?;
let pv = discount_rate
.present_value_between(flow.amount(), valuation_date, flow.payment_date())
.map_err(|cause| rate_error(Some(index), "discount horizon", cause))?;
terms.push(pv);
}
exact_sum(&terms).map_err(|cause| CashFlowError::Sum {
phase: "NPV reduction",
cause,
})
}
pub fn accrued_amount(
&self,
as_of: Date,
inclusion: PaymentDateInclusion,
max_accrual_work: u64,
) -> Result<f64, CashFlowError> {
let first = self.first_eligible(as_of, inclusion);
let mut terms = reserved(self.flows.len() - first, "accrued terms")?;
let mut work = AccrualWork {
maximum: max_accrual_work,
used: 0,
};
for (index, flow) in self.flows.iter().enumerate().skip(first) {
if let CashFlow::FixedRateCoupon(coupon) = flow {
terms.push(coupon.accrued(as_of, inclusion, Some(index), Some(&mut work))?);
}
}
exact_sum(&terms).map_err(|cause| CashFlowError::Sum {
phase: "accrued reduction",
cause,
})
}
fn first_eligible(&self, cutoff: Date, inclusion: PaymentDateInclusion) -> usize {
self.flows
.partition_point(|flow| !inclusion.eligible(flow.payment_date(), cutoff))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum CashFlowError {
#[non_exhaustive]
InvalidAccrualPeriod {
start: Date,
end: Date,
},
#[non_exhaustive]
InvalidFlowLimit {
requested: u32,
maximum: u32,
},
#[non_exhaustive]
FlowLimitExceeded {
maximum: u32,
required: usize,
},
#[non_exhaustive]
PaymentOrder {
index: usize,
previous: Date,
current: Date,
},
#[non_exhaustive]
AllocationFailed {
phase: &'static str,
requested: usize,
},
#[non_exhaustive]
AccrualWorkLimitExceeded {
index: Option<usize>,
phase: &'static str,
maximum: u64,
used: u64,
required_at_least: u64,
},
#[non_exhaustive]
Value {
index: Option<usize>,
phase: &'static str,
cause: ValueError,
},
#[non_exhaustive]
Rate {
index: Option<usize>,
phase: &'static str,
cause: RateError,
},
#[non_exhaustive]
Sum {
phase: &'static str,
cause: SumError,
},
}
impl CashFlowError {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::InvalidAccrualPeriod { .. } => "cashflow.invalid_accrual_period",
Self::InvalidFlowLimit { .. } => "cashflow.invalid_flow_limit",
Self::FlowLimitExceeded { .. } => "cashflow.flow_limit_exceeded",
Self::PaymentOrder { .. } => "cashflow.payment_order",
Self::AllocationFailed { .. } => "cashflow.allocation_failed",
Self::AccrualWorkLimitExceeded { .. } => "cashflow.accrual_work_limit_exceeded",
Self::Value { .. } => "cashflow.value",
Self::Rate { .. } => "cashflow.rate",
Self::Sum { .. } => "cashflow.sum",
}
}
fn at_index(self, index: usize) -> Self {
match self {
Self::Value { phase, cause, .. } => Self::Value {
index: Some(index),
phase,
cause,
},
Self::Rate { phase, cause, .. } => Self::Rate {
index: Some(index),
phase,
cause,
},
other => other,
}
}
}
impl fmt::Display for CashFlowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidAccrualPeriod { start, end } => {
write!(f, "coupon accrual requires start {start} before end {end}")
}
Self::InvalidFlowLimit { requested, maximum } => write!(
f,
"flow limit {requested} exceeds supported maximum {maximum}"
),
Self::FlowLimitExceeded { maximum, required } => {
write!(f, "flow count {required} exceeds caller limit {maximum}")
}
Self::PaymentOrder {
index,
previous,
current,
} => write!(
f,
"flow {index} payment {current} precedes prior payment {previous}"
),
Self::AllocationFailed { phase, requested } => {
write!(f, "{phase}: cannot reserve {requested} elements")
}
Self::AccrualWorkLimitExceeded {
index,
phase,
maximum,
used,
required_at_least,
} => write!(
f,
"{phase} at flow {index:?}: work limit {maximum}, used {used}, requires at least {required_at_least}"
),
Self::Value {
index,
phase,
cause,
} => write!(f, "{phase} at flow {index:?}: {cause}"),
Self::Rate {
index,
phase,
cause,
} => write!(f, "{phase} at flow {index:?}: {cause}"),
Self::Sum { phase, cause } => write!(f, "{phase}: {cause}"),
}
}
}
impl StdError for CashFlowError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::Value { cause, .. } => Some(cause),
Self::Rate { cause, .. } => Some(cause),
Self::Sum { cause, .. } => Some(cause),
_ => None,
}
}
}
fn checked_fraction(
quote: &FixedCouponRate,
start: Date,
end: Date,
index: Option<usize>,
phase: &'static str,
) -> Result<f64, CashFlowError> {
let fraction = quote
.accrual
.year_fraction(start, end)
.map_err(|cause| rate_error(index, phase, cause))?;
numeric::result_within("accrual fraction", fraction, Interval::at_least(0.0))
.map(canonical_zero)
.map_err(|cause| value_error(index, phase, cause))
}
fn check_flow_count(count: usize, maximum: u32) -> Result<(), CashFlowError> {
if maximum > Leg::MAX_FLOWS {
return Err(CashFlowError::InvalidFlowLimit {
requested: maximum,
maximum: Leg::MAX_FLOWS,
});
}
if count > maximum as usize {
return Err(CashFlowError::FlowLimitExceeded {
maximum,
required: count,
});
}
Ok(())
}
fn reserved<T>(count: usize, phase: &'static str) -> Result<Vec<T>, CashFlowError> {
let mut values = Vec::new();
values
.try_reserve_exact(count)
.map_err(|_| CashFlowError::AllocationFailed {
phase,
requested: count,
})?;
Ok(values)
}
fn canonical_zero(value: f64) -> f64 {
if value == 0.0 { 0.0 } else { value }
}
fn value_error(index: Option<usize>, phase: &'static str, cause: ValueError) -> CashFlowError {
CashFlowError::Value {
index,
phase,
cause,
}
}
fn rate_error(index: Option<usize>, phase: &'static str, cause: RateError) -> CashFlowError {
CashFlowError::Rate {
index,
phase,
cause,
}
}
pub(crate) fn query_work_units(convention: &AccrualConvention, start: Date, end: Date) -> u64 {
let extra = match convention {
AccrualConvention::Icma(context) => {
if start == end {
0
} else {
let lower = start.min(end);
let upper = start.max(end);
let first = context
.periods()
.partition_point(|period| period.end() <= lower);
let last = context
.periods()
.partition_point(|period| period.start() < upper);
(last - first) as u64
}
}
AccrualConvention::Business252(_) => u64::from(end.days_since(start).unsigned_abs()),
AccrualConvention::Scalar(_)
| AccrualConvention::ThirtyE360Isda(_)
| AccrualConvention::Thirty360Us(_) => 0,
};
1 + extra
}
struct AccrualWork {
maximum: u64,
used: u64,
}
impl AccrualWork {
fn charge(
&mut self,
convention: &AccrualConvention,
start: Date,
end: Date,
index: Option<usize>,
phase: &'static str,
) -> Result<(), CashFlowError> {
let required_at_least = self.used + query_work_units(convention, start, end);
if required_at_least > self.maximum {
return Err(CashFlowError::AccrualWorkLimitExceeded {
index,
phase,
maximum: self.maximum,
used: self.used,
required_at_least,
});
}
self.used = required_at_least;
Ok(())
}
}