use crate::cashflow::{CashFlow, Leg};
use crate::cashflows::fixedratecoupon::FixedRateCoupon;
use crate::errors::QlResult;
use crate::interestrate::{Compounding, InterestRate};
use crate::require;
use crate::shared::{Shared, shared};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::schedule::Schedule;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Rate, Real};
#[must_use]
pub struct FixedRateLeg {
schedule: Schedule,
notionals: Vec<Real>,
coupon_rates: Vec<InterestRate>,
first_period_day_counter: Option<DayCounter>,
last_period_day_counter: Option<DayCounter>,
payment_calendar: Calendar,
payment_adjustment: BusinessDayConvention,
payment_lag: Integer,
ex_coupon_period: Option<Period>,
ex_coupon_calendar: Calendar,
ex_coupon_adjustment: BusinessDayConvention,
ex_coupon_end_of_month: bool,
}
impl FixedRateLeg {
pub fn new(schedule: Schedule) -> FixedRateLeg {
let payment_calendar = schedule.calendar().clone();
FixedRateLeg {
schedule,
notionals: Vec::new(),
coupon_rates: Vec::new(),
first_period_day_counter: None,
last_period_day_counter: None,
payment_calendar,
payment_adjustment: BusinessDayConvention::Following,
payment_lag: 0,
ex_coupon_period: None,
ex_coupon_calendar: NullCalendar::new(),
ex_coupon_adjustment: BusinessDayConvention::Following,
ex_coupon_end_of_month: false,
}
}
pub fn with_notional(self, notional: Real) -> FixedRateLeg {
self.with_notionals(vec![notional])
}
pub fn with_notionals(mut self, notionals: Vec<Real>) -> FixedRateLeg {
self.notionals = notionals;
self
}
pub fn with_coupon_rate(
self,
rate: Rate,
day_counter: DayCounter,
compounding: Compounding,
frequency: Frequency,
) -> QlResult<FixedRateLeg> {
self.with_coupon_rates(vec![rate], day_counter, compounding, frequency)
}
pub fn with_coupon_rates(
self,
rates: Vec<Rate>,
day_counter: DayCounter,
compounding: Compounding,
frequency: Frequency,
) -> QlResult<FixedRateLeg> {
let coupon_rates = rates
.into_iter()
.map(|rate| InterestRate::new(rate, day_counter.clone(), compounding, frequency))
.collect::<QlResult<Vec<_>>>()?;
Ok(self.with_interest_rates(coupon_rates))
}
pub fn with_interest_rate(self, interest_rate: InterestRate) -> FixedRateLeg {
self.with_interest_rates(vec![interest_rate])
}
pub fn with_interest_rates(mut self, interest_rates: Vec<InterestRate>) -> FixedRateLeg {
self.coupon_rates = interest_rates;
self
}
pub fn with_payment_adjustment(mut self, convention: BusinessDayConvention) -> FixedRateLeg {
self.payment_adjustment = convention;
self
}
pub fn with_payment_calendar(mut self, calendar: Calendar) -> FixedRateLeg {
self.payment_calendar = calendar;
self
}
pub fn with_payment_lag(mut self, lag: Integer) -> FixedRateLeg {
self.payment_lag = lag;
self
}
pub fn with_first_period_day_counter(mut self, day_counter: DayCounter) -> FixedRateLeg {
self.first_period_day_counter = Some(day_counter);
self
}
pub fn with_last_period_day_counter(mut self, day_counter: DayCounter) -> FixedRateLeg {
self.last_period_day_counter = Some(day_counter);
self
}
pub fn with_ex_coupon_period(
mut self,
period: Period,
calendar: Calendar,
convention: BusinessDayConvention,
end_of_month: bool,
) -> FixedRateLeg {
self.ex_coupon_period = Some(period);
self.ex_coupon_calendar = calendar;
self.ex_coupon_adjustment = convention;
self.ex_coupon_end_of_month = end_of_month;
self
}
pub fn coupons(&self) -> QlResult<Vec<Shared<FixedRateCoupon>>> {
require!(!self.coupon_rates.is_empty(), "no coupon rates given");
require!(!self.notionals.is_empty(), "no notional given");
let size = self.schedule.len();
require!(size >= 2, "schedule with {size} date(s) spans no period");
let mut coupons = Vec::with_capacity(size - 1);
let mut start = self.schedule.date(0);
let mut end = self.schedule.date(1);
let stub = self.schedule.has_tenor()
&& self.schedule.has_is_regular()
&& !self.schedule.is_regular_at(1);
let reference_start = if stub {
self.schedule.calendar().advance_by_period(
end,
-self.schedule.tenor(),
self.schedule.business_day_convention(),
self.schedule_end_of_month()?,
)
} else {
start
};
coupons.push(self.coupon(
0,
start,
end,
reference_start,
end,
self.first_period_day_counter.as_ref(),
)?);
for i in 2..size - 1 {
start = end;
end = self.schedule.date(i);
coupons.push(self.coupon(i - 1, start, end, start, end, None)?);
}
if size > 2 {
start = end;
end = self.schedule.date(size - 1);
let regular = (self.schedule.has_is_regular() && self.schedule.is_regular_at(size - 1))
|| !self.schedule.has_tenor();
let reference_end = if regular {
end
} else {
self.schedule.calendar().advance_by_period(
start,
self.schedule.tenor(),
self.schedule.business_day_convention(),
self.schedule_end_of_month()?,
)
};
coupons.push(self.coupon(
size - 2,
start,
end,
start,
reference_end,
self.last_period_day_counter.as_ref(),
)?);
}
Ok(coupons)
}
pub fn build(&self) -> QlResult<Leg> {
Ok(self
.coupons()?
.into_iter()
.map(|coupon| coupon as Shared<dyn CashFlow>)
.collect())
}
fn schedule_end_of_month(&self) -> QlResult<bool> {
require!(
self.schedule.has_end_of_month(),
"schedule carries no end-of-month flag, which its stub period needs"
);
Ok(self.schedule.end_of_month())
}
fn coupon(
&self,
index: usize,
start: Date,
end: Date,
reference_start: Date,
reference_end: Date,
day_counter: Option<&DayCounter>,
) -> QlResult<Shared<FixedRateCoupon>> {
let payment_date = self.payment_calendar.advance(
end,
self.payment_lag,
TimeUnit::Days,
self.payment_adjustment,
false,
);
let ex_coupon_date = self.ex_coupon_period.map(|period| {
self.ex_coupon_calendar.advance_by_period(
payment_date,
-period,
self.ex_coupon_adjustment,
self.ex_coupon_end_of_month,
)
});
let rate = at(&self.coupon_rates, index);
let rate = match day_counter {
Some(day_counter) => InterestRate::new(
rate.rate(),
day_counter.clone(),
rate.compounding(),
rate.frequency(),
)?,
None => rate,
};
Ok(shared(FixedRateCoupon::new(
payment_date,
at(&self.notionals, index),
rate,
start,
end,
Some(reference_start),
Some(reference_end),
ex_coupon_date,
)))
}
}
fn at<T: Clone>(values: &[T], index: usize) -> T {
values
.get(index)
.unwrap_or_else(|| values.last().expect("non-empty by precondition"))
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cashflows::coupon::Coupon;
use crate::time::calendars::target::Target;
use crate::time::calendars::unitedstates::{Market, UnitedStates};
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actualactual::{ActualActual, Convention};
use crate::time::timeunit::TimeUnit;
fn simple(rate: Rate, day_counter: DayCounter) -> InterestRate {
InterestRate::new(rate, day_counter, Compounding::Simple, Frequency::Annual).unwrap()
}
#[test]
fn a_zero_length_ex_coupon_period_puts_the_date_on_the_payment_date() {
let schedule = crate::time::schedule::MakeSchedule::new()
.from(Date::new(15, Month::January, 2026))
.to(Date::new(15, Month::July, 2026))
.with_frequency(Frequency::Semiannual)
.with_convention(BusinessDayConvention::Unadjusted)
.build();
let coupons = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.03, Actual360::new()))
.with_ex_coupon_period(
Period::new(0, TimeUnit::Days),
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
)
.coupons()
.unwrap();
let payment_date = crate::event::Event::date(coupons[0].as_ref());
assert_eq!(coupons[0].ex_coupon_date(), Some(payment_date));
assert!(coupons[0].trades_ex_coupon_on(payment_date));
assert_eq!(coupons[0].accrued_amount(payment_date).unwrap(), 0.0);
}
#[test]
fn a_stub_without_an_end_of_month_flag_is_an_error_not_a_panic() {
let schedule = Schedule::with_metadata(
vec![
Date::new(15, Month::January, 2026),
Date::new(1, Month::April, 2026),
Date::new(1, Month::October, 2026),
],
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
None,
Some(Period::new(6, TimeUnit::Months)),
None,
None,
vec![false, true],
);
assert!(
FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.03, Actual360::new()))
.coupons()
.is_err()
);
}
#[test]
fn a_leg_spanning_the_evaluation_date_has_a_running_accrual() {
let today = Date::new(7, Month::July, 2026);
let schedule = crate::time::schedule::MakeSchedule::new()
.from(Date::new(7, Month::May, 2026))
.to(Date::new(7, Month::November, 2026))
.with_frequency(Frequency::Semiannual)
.with_calendar(Target::new())
.with_convention(BusinessDayConvention::Unadjusted)
.backwards()
.build();
let coupons = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.03, Actual360::new()))
.with_payment_calendar(Target::new())
.with_payment_adjustment(BusinessDayConvention::Following)
.coupons()
.unwrap();
assert_eq!(coupons.len(), 1);
assert!(coupons[0].accrued_period(today) > 0.0);
assert!(coupons[0].accrued_days(today) > 0);
assert!(coupons[0].accrued_amount(today).unwrap() > 0.0);
}
#[test]
fn the_ex_coupon_dates_are_measured_back_from_each_payment_date() {
let today = Date::new(15, Month::June, 2026);
let schedule = crate::time::schedule::MakeSchedule::new()
.from(today)
.to(Date::new(15, Month::June, 2031))
.with_frequency(Frequency::Monthly)
.with_calendar(Target::new())
.with_convention(BusinessDayConvention::Following)
.build();
let leg = |schedule| {
FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.03, Actual360::new()))
};
for coupon in leg(schedule.clone()).coupons().unwrap() {
assert_eq!(coupon.ex_coupon_date(), None);
}
let calendar_days = leg(schedule.clone())
.with_ex_coupon_period(
Period::new(2, TimeUnit::Days),
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
)
.coupons()
.unwrap();
assert!(!calendar_days.is_empty());
for coupon in calendar_days {
assert_eq!(coupon.ex_coupon_date(), Some(coupon.accrual_end_date() - 2));
}
let business_days = leg(schedule)
.with_ex_coupon_period(
Period::new(2, TimeUnit::Days),
Target::new(),
BusinessDayConvention::Preceding,
false,
)
.coupons()
.unwrap();
for coupon in business_days {
let expected = Target::new().advance(
coupon.accrual_end_date(),
-2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
assert_eq!(coupon.ex_coupon_date(), Some(expected));
}
}
#[test]
fn an_irregular_first_coupon_references_the_end_of_the_month() {
let schedule = crate::time::schedule::MakeSchedule::new()
.from(Date::new(17, Month::January, 2017))
.to(Date::new(28, Month::February, 2018))
.with_frequency(Frequency::Semiannual)
.with_convention(BusinessDayConvention::Unadjusted)
.end_of_month(true)
.backwards()
.build();
let coupons = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.01, Actual360::new()))
.coupons()
.unwrap();
assert_eq!(
coupons[0].reference_period_start(),
Date::new(31, Month::August, 2016)
);
}
#[test]
fn an_irregular_first_coupon_references_the_end_of_the_calendar_month() {
let schedule = crate::time::schedule::MakeSchedule::new()
.with_calendar(UnitedStates::new(Market::GovernmentBond))
.from(Date::new(30, Month::September, 2017))
.to(Date::new(30, Month::September, 2022))
.with_tenor(Period::new(6, TimeUnit::Months))
.with_convention(BusinessDayConvention::Unadjusted)
.with_termination_date_convention(BusinessDayConvention::Unadjusted)
.with_first_date(Date::new(31, Month::March, 2018))
.with_next_to_last_date(Date::new(31, Month::March, 2022))
.end_of_month(true)
.backwards()
.build();
let coupons = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(
0.01875,
ActualActual::with_convention(Convention::ISMA),
))
.coupons()
.unwrap();
assert_eq!(
coupons[0].reference_period_start(),
Date::new(30, Month::September, 2017)
);
assert!((coupons[0].amount().unwrap() - 0.9375).abs() < 1e-4);
}
#[test]
fn an_irregular_last_coupon_references_the_end_of_the_month() {
let schedule = crate::time::schedule::MakeSchedule::new()
.from(Date::new(17, Month::January, 2017))
.to(Date::new(15, Month::September, 2018))
.with_next_to_last_date(Date::new(28, Month::February, 2018))
.with_frequency(Frequency::Semiannual)
.with_convention(BusinessDayConvention::Unadjusted)
.end_of_month(true)
.backwards()
.build();
let coupons = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(0.01, Actual360::new()))
.coupons()
.unwrap();
assert_eq!(
coupons.last().unwrap().reference_period_end(),
Date::new(31, Month::August, 2018)
);
}
#[test]
fn a_date_based_schedule_reconstructs_the_reference_periods_only_with_its_metadata() {
let schedule = crate::time::schedule::MakeSchedule::new()
.from(Date::new(15, Month::September, 2017))
.to(Date::new(30, Month::September, 2020))
.with_next_to_last_date(Date::new(25, Month::September, 2020))
.with_frequency(Frequency::Semiannual)
.backwards()
.build();
let with_metadata = Schedule::with_metadata(
schedule.dates().to_vec(),
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
Some(BusinessDayConvention::Unadjusted),
Some(Period::new(6, TimeUnit::Months)),
None,
Some(schedule.end_of_month()),
schedule.is_regular().to_vec(),
);
let without_metadata = Schedule::from_dates(schedule.dates().to_vec());
let coupons = |schedule| {
FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_interest_rate(simple(
0.01,
ActualActual::with_convention(Convention::ISMA),
))
.coupons()
.unwrap()
};
for leg in [coupons(schedule), coupons(with_metadata)] {
assert_eq!(
leg[0].reference_period_start(),
Date::new(25, Month::March, 2017)
);
assert_eq!(
leg[0].reference_period_end(),
Date::new(25, Month::September, 2017)
);
assert_eq!(
leg.last().unwrap().reference_period_start(),
Date::new(25, Month::September, 2020)
);
assert_eq!(
leg.last().unwrap().reference_period_end(),
Date::new(25, Month::March, 2021)
);
}
let leg = coupons(without_metadata);
assert_eq!(
leg[0].reference_period_start(),
Date::new(15, Month::September, 2017)
);
assert_eq!(
leg[0].reference_period_end(),
Date::new(25, Month::September, 2017)
);
assert_eq!(
leg.last().unwrap().reference_period_start(),
Date::new(25, Month::September, 2020)
);
assert_eq!(
leg.last().unwrap().reference_period_end(),
Date::new(30, Month::September, 2020)
);
}
}