use crate::cashflow::{CashFlow, cash_flow_has_occurred};
use crate::event::Event;
use crate::patterns::observable::AsObservable;
use crate::settings::Settings;
use crate::time::date::{Date, SerialNumber};
use crate::time::daycounter::DayCounter;
use crate::types::{Rate, Real, Time};
use crate::errors::QlResult;
#[derive(Clone, Debug)]
pub struct CouponBase {
payment_date: Date,
nominal: Real,
accrual_start_date: Date,
accrual_end_date: Date,
ref_period_start: Date,
ref_period_end: Date,
ex_coupon_date: Option<Date>,
}
impl CouponBase {
pub fn new(
payment_date: Date,
nominal: Real,
accrual_start_date: Date,
accrual_end_date: Date,
ref_period_start: Option<Date>,
ref_period_end: Option<Date>,
ex_coupon_date: Option<Date>,
) -> CouponBase {
CouponBase {
payment_date,
nominal,
accrual_start_date,
accrual_end_date,
ref_period_start: ref_period_start.unwrap_or(accrual_start_date),
ref_period_end: ref_period_end.unwrap_or(accrual_end_date),
ex_coupon_date,
}
}
pub fn payment_date(&self) -> Date {
self.payment_date
}
pub fn ex_coupon_date(&self) -> Option<Date> {
self.ex_coupon_date
}
}
pub trait Coupon: AsObservable {
fn coupon_base(&self) -> &CouponBase;
fn amount(&self) -> QlResult<Real>;
fn rate(&self) -> QlResult<Rate>;
fn day_counter(&self) -> DayCounter;
fn accrued_amount(&self, date: Date) -> QlResult<Real>;
fn nominal(&self) -> Real {
self.coupon_base().nominal
}
fn accrual_start_date(&self) -> Date {
self.coupon_base().accrual_start_date
}
fn accrual_end_date(&self) -> Date {
self.coupon_base().accrual_end_date
}
fn reference_period_start(&self) -> Date {
self.coupon_base().ref_period_start
}
fn reference_period_end(&self) -> Date {
self.coupon_base().ref_period_end
}
fn accrual_period(&self) -> Time {
let base = self.coupon_base();
self.day_counter().year_fraction_ref(
base.accrual_start_date,
base.accrual_end_date,
base.ref_period_start,
base.ref_period_end,
)
}
fn accrual_days(&self) -> SerialNumber {
let base = self.coupon_base();
self.day_counter()
.day_count(base.accrual_start_date, base.accrual_end_date)
}
fn trades_ex_coupon_on(&self, date: Date) -> bool {
self.coupon_base()
.ex_coupon_date()
.is_some_and(|ex_coupon| ex_coupon <= date)
}
fn accrued_period(&self, date: Date) -> Time {
let base = self.coupon_base();
if date <= base.accrual_start_date || date > base.payment_date {
0.0
} else if self.trades_ex_coupon_on(date) {
-self.day_counter().year_fraction_ref(
date,
date.max(base.accrual_end_date),
base.ref_period_start,
base.ref_period_end,
)
} else {
self.day_counter().year_fraction_ref(
base.accrual_start_date,
date.min(base.accrual_end_date),
base.ref_period_start,
base.ref_period_end,
)
}
}
fn accrued_days(&self, date: Date) -> SerialNumber {
let base = self.coupon_base();
if date <= base.accrual_start_date || date > base.payment_date {
0
} else {
self.day_counter()
.day_count(base.accrual_start_date, date.min(base.accrual_end_date))
}
}
}
impl<T: Coupon> Event for T {
fn date(&self) -> Date {
self.coupon_base().payment_date()
}
fn has_occurred(
&self,
settings: &Settings<Date>,
ref_date: Option<Date>,
include_ref_date: Option<bool>,
) -> QlResult<bool> {
cash_flow_has_occurred(
self.coupon_base().payment_date(),
settings,
ref_date,
include_ref_date,
)
}
}
impl<T: Coupon> CashFlow for T {
fn amount(&self) -> QlResult<Real> {
Coupon::amount(self)
}
fn ex_coupon_date(&self) -> Option<Date> {
self.coupon_base().ex_coupon_date()
}
fn as_coupon(&self) -> Option<&dyn Coupon> {
Some(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patterns::observable::Observable;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
struct TestCoupon {
base: CouponBase,
rate: Rate,
day_counter: DayCounter,
observable: Observable,
}
impl TestCoupon {
fn new(base: CouponBase, rate: Rate, day_counter: DayCounter) -> TestCoupon {
TestCoupon {
base,
rate,
day_counter,
observable: Observable::new(),
}
}
}
impl AsObservable for TestCoupon {
fn observable(&self) -> &Observable {
&self.observable
}
}
impl Coupon for TestCoupon {
fn coupon_base(&self) -> &CouponBase {
&self.base
}
fn amount(&self) -> QlResult<Real> {
Ok(self.nominal() * self.rate * self.accrual_period())
}
fn rate(&self) -> QlResult<Rate> {
Ok(self.rate)
}
fn day_counter(&self) -> DayCounter {
self.day_counter.clone()
}
fn accrued_amount(&self, date: Date) -> QlResult<Real> {
Ok(self.nominal() * self.rate * self.accrued_period(date))
}
}
fn start() -> Date {
Date::new(15, Month::January, 2026)
}
fn end() -> Date {
Date::new(15, Month::July, 2026)
}
fn payment() -> Date {
Date::new(20, Month::July, 2026)
}
fn coupon(ex_coupon_date: Option<Date>) -> TestCoupon {
TestCoupon::new(
CouponBase::new(payment(), 100.0, start(), end(), None, None, ex_coupon_date),
0.03,
Actual360::new(),
)
}
#[test]
fn an_absent_reference_period_defaults_to_the_accrual_period() {
let coupon = coupon(None);
assert_eq!(coupon.reference_period_start(), start());
assert_eq!(coupon.reference_period_end(), end());
}
#[test]
fn an_explicit_reference_period_is_kept() {
let ref_start = Date::new(15, Month::December, 2025);
let ref_end = Date::new(15, Month::June, 2026);
let coupon = TestCoupon::new(
CouponBase::new(
payment(),
100.0,
start(),
end(),
Some(ref_start),
Some(ref_end),
None,
),
0.03,
Actual360::new(),
);
assert_eq!(coupon.reference_period_start(), ref_start);
assert_eq!(coupon.reference_period_end(), ref_end);
}
#[test]
fn the_accrual_period_spans_the_accrual_dates() {
let coupon = coupon(None);
assert_eq!(coupon.accrual_days(), 181);
assert!((coupon.accrual_period() - 181.0 / 360.0).abs() < 1e-15);
assert_eq!(coupon.date(), payment());
assert_eq!(coupon.nominal(), 100.0);
}
#[test]
fn nothing_accrues_before_the_accrual_start() {
let coupon = coupon(None);
assert_eq!(coupon.accrued_period(start() - 1), 0.0);
assert_eq!(coupon.accrued_days(start() - 1), 0);
assert_eq!(coupon.accrued_period(start()), 0.0);
assert_eq!(coupon.accrued_days(start()), 0);
assert_eq!(coupon.accrued_amount(start()).unwrap(), 0.0);
}
#[test]
fn nothing_accrues_after_the_payment_date() {
let coupon = coupon(None);
assert_eq!(coupon.accrued_period(payment() + 1), 0.0);
assert_eq!(coupon.accrued_days(payment() + 1), 0);
}
#[test]
fn accrual_grows_from_the_accrual_start_and_is_capped_at_the_accrual_end() {
let coupon = coupon(None);
let mid = Date::new(15, Month::April, 2026);
assert_eq!(coupon.accrued_days(mid), 90);
assert!((coupon.accrued_period(mid) - 90.0 / 360.0).abs() < 1e-15);
assert!((coupon.accrued_amount(mid).unwrap() - 100.0 * 0.03 * 90.0 / 360.0).abs() < 1e-13);
assert_eq!(coupon.accrued_days(end()), 181);
assert_eq!(coupon.accrued_days(payment()), 181);
assert!((coupon.accrued_period(payment()) - 181.0 / 360.0).abs() < 1e-15);
}
#[test]
fn the_accrued_period_goes_negative_ex_coupon_while_the_days_keep_counting() {
let ex_coupon = Date::new(1, Month::July, 2026);
let coupon = coupon(Some(ex_coupon));
let after = Date::new(5, Month::July, 2026);
assert!(coupon.accrued_period(ex_coupon - 1) > 0.0);
assert_eq!(coupon.accrued_days(ex_coupon - 1), 166);
assert!((coupon.accrued_period(ex_coupon) + 14.0 / 360.0).abs() < 1e-15);
assert_eq!(coupon.accrued_days(ex_coupon), 167);
assert!((coupon.accrued_period(after) + 10.0 / 360.0).abs() < 1e-15);
assert_eq!(coupon.accrued_days(after), 171);
assert!(
(coupon.accrued_amount(ex_coupon).unwrap() + 100.0 * 0.03 * 14.0 / 360.0).abs() < 1e-13
);
}
#[test]
fn the_negative_accrual_returns_to_zero_at_the_accrual_end() {
let coupon = coupon(Some(Date::new(1, Month::July, 2026)));
assert_eq!(coupon.accrued_period(end()), 0.0);
assert_eq!(coupon.accrued_period(payment()), 0.0);
assert_eq!(coupon.accrued_days(end()), 181);
assert_eq!(coupon.accrued_days(payment()), 181);
}
#[test]
fn an_ex_coupon_date_after_the_payment_date_never_bites() {
let coupon = coupon(Some(payment() + 1));
assert!((coupon.accrued_period(payment()) - 181.0 / 360.0).abs() < 1e-15);
}
#[test]
fn the_reference_period_reaches_the_day_counter() {
let coupon = TestCoupon::new(
CouponBase::new(payment(), 100.0, start(), end(), None, None, None),
0.03,
Thirty360::with_convention(Convention::BondBasis),
);
assert_eq!(coupon.accrual_days(), 180);
assert!((coupon.accrual_period() - 0.5).abs() < 1e-15);
}
}