use super::coupon::{Coupon, CouponBase};
use super::couponpricer::FloatingRateCouponPricer;
use super::floatingratecoupon::FloatingRateCoupon;
use crate::errors::QlResult;
use crate::indexes::iborindex::IborIndex;
use crate::indexes::index::Index;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::patterns::observable::{AsObservable, Observable};
use crate::shared::{Shared, SharedMut};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Natural, Rate, Real, Spread, Time};
use crate::{fail, require};
pub struct IborCoupon {
base: FloatingRateCoupon,
ibor_index: Shared<IborIndex>,
}
impl IborCoupon {
#[allow(clippy::too_many_arguments)]
pub fn new(
payment_date: Date,
nominal: Real,
accrual_start_date: Date,
accrual_end_date: Date,
fixing_days: Option<Natural>,
index: Shared<IborIndex>,
gearing: Real,
spread: Spread,
ref_period_start: Option<Date>,
ref_period_end: Option<Date>,
day_counter: Option<DayCounter>,
is_in_arrears: bool,
ex_coupon_date: Option<Date>,
fixing_convention: BusinessDayConvention,
) -> QlResult<IborCoupon> {
let base = FloatingRateCoupon::new(
payment_date,
nominal,
accrual_start_date,
accrual_end_date,
fixing_days,
index.clone(),
gearing,
spread,
ref_period_start,
ref_period_end,
day_counter,
is_in_arrears,
ex_coupon_date,
fixing_convention,
)?;
Ok(IborCoupon {
base,
ibor_index: index,
})
}
pub fn ibor_index(&self) -> &Shared<IborIndex> {
&self.ibor_index
}
pub fn spread(&self) -> Spread {
self.base.spread()
}
pub fn gearing(&self) -> Real {
self.base.gearing()
}
pub fn fixing_date(&self) -> Date {
self.base.fixing_date()
}
pub fn index_fixing(&self) -> QlResult<Rate> {
if self.has_fixed()? {
self.base.index_fixing()
} else {
let (value_date, end_date, spanning_time) = self.forecast_fixing_dates()?;
self.ibor_index
.forecast_fixing_between(value_date, end_date, spanning_time)
}
}
fn forecast_fixing_dates(&self) -> QlResult<(Date, Date, Time)> {
let index = &self.ibor_index;
let calendar = index.fixing_calendar();
let fixing_value_date = index.value_date(self.fixing_date())?;
let fixing_maturity_date = index.maturity_date(fixing_value_date)?;
let using_at_par = index.settings().using_at_par_coupons();
let fixing_end_date = if !using_at_par || self.base.is_in_arrears() {
fixing_maturity_date
} else {
let next_fixing_date = calendar.advance(
self.accrual_end_date(),
-(self.base.fixing_days() as Integer),
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let end_date = calendar.advance(
next_fixing_date,
index.fixing_days() as Integer,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
end_date.max(fixing_value_date + 1)
};
let spanning_time = index
.day_counter()
.year_fraction(fixing_value_date, fixing_end_date);
let positive_time = spanning_time > 0.0;
require!(
positive_time,
"cannot calculate forward rate between {fixing_value_date:?} and {fixing_end_date:?}: non positive time ({spanning_time}) using {} daycounter",
index.day_counter().name()
);
Ok((fixing_value_date, fixing_end_date, spanning_time))
}
pub fn has_fixed(&self) -> QlResult<bool> {
let settings = self.ibor_index.settings();
let today = match settings.evaluation_date() {
Some(today) => today,
None => fail!("no evaluation date set: an ibor coupon needs a reference date"),
};
let fixing_date = self.fixing_date();
if fixing_date > today {
Ok(false)
} else if fixing_date < today || settings.enforces_todays_historic_fixings() {
Ok(true)
} else {
Ok(self.ibor_index.has_historical_fixing(fixing_date))
}
}
pub fn pricer(&self) -> Option<SharedMut<dyn FloatingRateCouponPricer>> {
self.base.pricer()
}
pub fn set_pricer(&self, pricer: SharedMut<dyn FloatingRateCouponPricer>) {
self.base.set_pricer(pricer);
}
}
impl AsObservable for IborCoupon {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl Coupon for IborCoupon {
fn coupon_base(&self) -> &CouponBase {
self.base.coupon_base()
}
fn amount(&self) -> QlResult<Real> {
Ok(self.rate()? * self.accrual_period() * self.nominal())
}
fn rate(&self) -> QlResult<Rate> {
let Some(pricer) = self.base.pricer() else {
fail!("pricer not set");
};
pricer.borrow_mut().initialize(&self.base);
let index_fixing = self.index_fixing();
pricer.borrow().swaplet_rate_for(index_fixing)
}
fn day_counter(&self) -> DayCounter {
self.base.day_counter()
}
fn accrued_amount(&self, date: Date) -> QlResult<Real> {
if date <= self.accrual_start_date() || date > self.coupon_base().payment_date() {
Ok(0.0)
} else {
Ok(self.nominal() * self.rate()? * self.accrued_period(date))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::currency::Currency;
use crate::handle::Handle;
use crate::indexes::ibor::Euribor;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::interestrate::Compounding;
use crate::settings::Settings;
use crate::shared::{SharedMut, shared, shared_mut};
use crate::termstructures::yields::FlatForward;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use super::super::couponpricer::BlackIborCouponPricer;
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn ibor6m(settings: Shared<Settings<Date>>) -> Shared<IborIndex> {
shared(IborIndex::new(
"foo".into(),
Period::new(6, TimeUnit::Months),
2,
Currency::eur(),
Target::new(),
BusinessDayConvention::Following,
false,
Actual360::new(),
Handle::<dyn YieldTermStructure>::empty(),
settings,
))
}
fn pricer() -> SharedMut<dyn FloatingRateCouponPricer> {
shared_mut(BlackIborCouponPricer::new()) as SharedMut<dyn FloatingRateCouponPricer>
}
fn flat_curve(reference: Date, rate: Rate) -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
reference,
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
}
fn coupon_for_fixing_date(index: Shared<IborIndex>, fixing_date: Date) -> IborCoupon {
let start_date = index.value_date(fixing_date).unwrap();
let end_date = index.maturity_date(fixing_date).unwrap();
let coupon = IborCoupon::new(
end_date,
100.0,
start_date,
end_date,
Some(index.fixing_days()),
index,
1.0,
0.0,
None,
None,
None,
false,
None,
BusinessDayConvention::Preceding,
)
.unwrap();
coupon.set_pricer(pricer());
coupon
}
#[test]
fn a_past_ibor_coupon_prices_off_the_store() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let index = ibor6m(settings);
let calendar = index.fixing_calendar();
let fixing_date = calendar.advance(
today,
-2,
TimeUnit::Months,
BusinessDayConvention::Following,
false,
);
let past_fixing = 0.01;
index.add_fixing(fixing_date, past_fixing).unwrap();
let coupon = coupon_for_fixing_date(index, fixing_date);
let amount = coupon.amount().unwrap();
let expected = past_fixing * coupon.nominal() * coupon.accrual_period();
assert!(
(amount - expected).abs() < 1e-8,
"amount {amount} vs expected {expected}"
);
}
#[test]
fn an_ibor_coupon_knows_when_it_has_fixed() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let index = Euribor::three_months(Handle::empty(), settings.clone());
let index = shared(index);
let calendar = index.fixing_calendar();
let yesterday = calendar.advance(
today,
-1,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let tomorrow = calendar.advance(
today,
1,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
{
let coupon = coupon_for_fixing_date(index.clone(), yesterday);
index.clear_fixings();
assert!(coupon.has_fixed().unwrap());
assert!(coupon.rate().is_err());
}
{
let coupon = coupon_for_fixing_date(index.clone(), today);
settings.set_enforces_todays_historic_fixings(false);
index.clear_fixings();
assert!(!coupon.has_fixed().unwrap());
}
{
let coupon = coupon_for_fixing_date(index.clone(), today);
settings.set_enforces_todays_historic_fixings(false);
index.add_fixing(coupon.fixing_date(), 0.01).unwrap();
assert!(coupon.has_fixed().unwrap());
}
{
let coupon = coupon_for_fixing_date(index.clone(), today);
settings.set_enforces_todays_historic_fixings(true);
index.clear_fixings();
assert!(coupon.has_fixed().unwrap());
assert!(coupon.rate().is_err());
}
{
let coupon = coupon_for_fixing_date(index.clone(), tomorrow);
assert!(!coupon.has_fixed().unwrap());
}
}
#[test]
fn an_in_arrears_coupon_refuses_to_price() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let index = ibor6m(settings);
let start = Date::new(15, Month::January, 2026);
let end = Date::new(15, Month::July, 2026);
let coupon = IborCoupon::new(
end,
100.0,
start,
end,
Some(2),
index,
1.0,
0.0,
None,
None,
None,
true,
None,
BusinessDayConvention::Preceding,
)
.unwrap();
coupon.set_pricer(pricer());
assert!(coupon.rate().is_err());
}
#[test]
fn an_unfixed_coupon_forecasts_in_indexed_mode_behind_the_flag() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
settings.set_using_at_par_coupons(false);
let index = shared(IborIndex::new(
"foo".into(),
Period::new(6, TimeUnit::Months),
2,
Currency::eur(),
Target::new(),
BusinessDayConvention::Following,
false,
Actual360::new(),
flat_curve(today, 0.03),
settings,
));
let fixing_date = Date::new(15, Month::July, 2026);
let start_date = index.value_date(fixing_date).unwrap();
let end_date = index.maturity_date(start_date).unwrap();
let gearing = 2.0;
let spread = 0.01;
let coupon = IborCoupon::new(
end_date,
100.0,
start_date,
end_date,
Some(index.fixing_days()),
index.clone(),
gearing,
spread,
None,
None,
None,
false,
None,
BusinessDayConvention::Preceding,
)
.unwrap();
coupon.set_pricer(pricer());
assert!(!coupon.has_fixed().unwrap());
assert_eq!(coupon.fixing_date(), fixing_date);
let forecast = index.forecast_fixing(fixing_date).unwrap();
let expected = gearing * forecast + spread;
assert!((coupon.rate().unwrap() - expected).abs() < 1e-14);
}
fn stub_coupon(rate: Rate) -> (Shared<IborIndex>, IborCoupon, Real, Spread) {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let index = shared(IborIndex::new(
"foo".into(),
Period::new(6, TimeUnit::Months),
2,
Currency::eur(),
Target::new(),
BusinessDayConvention::Following,
false,
Actual360::new(),
flat_curve(today, rate),
settings,
));
let fixing_date = Date::new(15, Month::July, 2026);
let start_date = index.value_date(fixing_date).unwrap();
let end_date = index.fixing_calendar().advance_by_period(
start_date,
Period::new(3, TimeUnit::Months),
BusinessDayConvention::Following,
false,
);
let gearing = 2.0;
let spread = 0.01;
let coupon = IborCoupon::new(
end_date,
100.0,
start_date,
end_date,
Some(index.fixing_days()),
index.clone(),
gearing,
spread,
None,
None,
None,
false,
None,
BusinessDayConvention::Preceding,
)
.unwrap();
coupon.set_pricer(pricer());
(index, coupon, gearing, spread)
}
#[test]
fn a_par_coupon_forecasts_over_its_own_accrual_end() {
let rate = 0.03;
let (index, coupon, gearing, spread) = stub_coupon(rate);
let day_counter = Actual360::new();
let fixing_value_date = index.value_date(coupon.fixing_date()).unwrap();
let calendar = index.fixing_calendar();
let next_fixing_date = calendar.advance(
coupon.accrual_end_date(),
-(index.fixing_days() as i32),
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let par_fixing_end_date = calendar
.advance(
next_fixing_date,
index.fixing_days() as i32,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
)
.max(fixing_value_date + 1);
let t = day_counter.year_fraction(fixing_value_date, par_fixing_end_date);
let forecast = ((rate * t).exp() - 1.0) / t;
let expected = gearing * forecast + spread;
assert!(!coupon.has_fixed().unwrap());
assert!((coupon.rate().unwrap() - expected).abs() < 1e-14);
}
#[test]
fn par_and_indexed_forecasts_differ_on_a_stub() {
let (index, coupon, gearing, spread) = stub_coupon(0.03);
let par_rate = coupon.rate().unwrap();
let indexed = gearing * index.forecast_fixing(coupon.fixing_date()).unwrap() + spread;
assert!(
(par_rate - indexed).abs() > 1e-6,
"par {par_rate} vs indexed {indexed} should differ on a stub"
);
}
#[test]
fn amount_routes_through_the_mode_aware_rate() {
let (_index, coupon, _gearing, _spread) = stub_coupon(0.03);
assert!(!coupon.has_fixed().unwrap());
let rate = coupon.rate().unwrap();
let expected_amount = rate * coupon.accrual_period() * coupon.nominal();
assert!((coupon.amount().unwrap() - expected_amount).abs() < 1e-14);
let payment_date = coupon.coupon_base().payment_date();
let expected_accrued = coupon.nominal() * rate * coupon.accrued_period(payment_date);
assert!((coupon.accrued_amount(payment_date).unwrap() - expected_accrued).abs() < 1e-14);
}
}