use crate::cashflow::{CashFlow, Leg};
use crate::cashflows::capflooredyoyinflationcoupon::CappedFlooredYoYInflationCoupon;
use crate::cashflows::yoyinflationcoupon::{
SwapletYoYInflationCouponPricer, YoYInflationCoupon, YoYInflationCouponPricer,
};
use crate::errors::QlResult;
use crate::indexes::inflationindex::{CpiInterpolationType, YoYInflationIndex};
use crate::shared::{Shared, SharedMut, shared, shared_mut};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::daycounter::DayCounter;
use crate::time::period::Period;
use crate::time::schedule::Schedule;
use crate::types::{Natural, Rate, Real, Spread};
use crate::{fail, require};
#[must_use]
pub struct YoYInflationLeg {
schedule: Schedule,
payment_calendar: Calendar,
yoy_index: Shared<YoYInflationIndex>,
observation_lag: Period,
interpolation: CpiInterpolationType,
notionals: Vec<Real>,
payment_day_counter: Option<DayCounter>,
payment_adjustment: BusinessDayConvention,
fixing_days: Vec<Natural>,
gearings: Vec<Real>,
spreads: Vec<Spread>,
caps: Vec<Rate>,
floors: Vec<Rate>,
}
impl YoYInflationLeg {
pub fn new(
schedule: Schedule,
payment_calendar: Calendar,
yoy_index: Shared<YoYInflationIndex>,
observation_lag: Period,
interpolation: CpiInterpolationType,
) -> YoYInflationLeg {
YoYInflationLeg {
schedule,
payment_calendar,
yoy_index,
observation_lag,
interpolation,
notionals: Vec::new(),
payment_day_counter: None,
payment_adjustment: BusinessDayConvention::ModifiedFollowing,
fixing_days: Vec::new(),
gearings: Vec::new(),
spreads: Vec::new(),
caps: Vec::new(),
floors: Vec::new(),
}
}
pub fn with_notional(self, notional: Real) -> YoYInflationLeg {
self.with_notionals(vec![notional])
}
pub fn with_notionals(mut self, notionals: Vec<Real>) -> YoYInflationLeg {
self.notionals = notionals;
self
}
pub fn with_payment_day_counter(mut self, day_counter: DayCounter) -> YoYInflationLeg {
self.payment_day_counter = Some(day_counter);
self
}
pub fn with_payment_adjustment(mut self, convention: BusinessDayConvention) -> YoYInflationLeg {
self.payment_adjustment = convention;
self
}
pub fn with_fixing_days(self, fixing_days: Natural) -> YoYInflationLeg {
self.with_fixing_days_per_coupon(vec![fixing_days])
}
pub fn with_fixing_days_per_coupon(mut self, fixing_days: Vec<Natural>) -> YoYInflationLeg {
self.fixing_days = fixing_days;
self
}
pub fn with_gearing(self, gearing: Real) -> YoYInflationLeg {
self.with_gearings(vec![gearing])
}
pub fn with_gearings(mut self, gearings: Vec<Real>) -> YoYInflationLeg {
self.gearings = gearings;
self
}
pub fn with_spread(self, spread: Spread) -> YoYInflationLeg {
self.with_spreads(vec![spread])
}
pub fn with_spreads(mut self, spreads: Vec<Spread>) -> YoYInflationLeg {
self.spreads = spreads;
self
}
pub fn with_caps(self, cap: Rate) -> YoYInflationLeg {
self.with_caps_per_coupon(vec![cap])
}
pub fn with_caps_per_coupon(mut self, caps: Vec<Rate>) -> YoYInflationLeg {
self.caps = caps;
self
}
pub fn with_floors(self, floor: Rate) -> YoYInflationLeg {
self.with_floors_per_coupon(vec![floor])
}
pub fn with_floors_per_coupon(mut self, floors: Vec<Rate>) -> YoYInflationLeg {
self.floors = floors;
self
}
fn raw_coupons(&self) -> QlResult<Vec<Shared<YoYInflationCoupon>>> {
let Some(payment_day_counter) = &self.payment_day_counter else {
fail!("no payment daycounter 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 periods = size - 1;
require!(
self.notionals.len() <= periods,
"too many notionals ({}), only {periods} required",
self.notionals.len()
);
require!(
self.gearings.len() <= periods,
"too many gearings ({}), only {periods} required",
self.gearings.len()
);
require!(
self.spreads.len() <= periods,
"too many spreads ({}), only {periods} required",
self.spreads.len()
);
require!(
self.caps.len() <= periods,
"too many caps ({}), only {periods} required",
self.caps.len()
);
require!(
self.floors.len() <= periods,
"too many floors ({}), only {periods} required",
self.floors.len()
);
let calendar = self.schedule.calendar();
let convention = self.schedule.business_day_convention();
let stub = |period: usize| {
self.schedule.has_tenor()
&& self.schedule.has_is_regular()
&& !self.schedule.is_regular_at(period)
};
let mut coupons = Vec::with_capacity(periods);
for i in 0..periods {
let start = self.schedule.date(i);
let end = self.schedule.date(i + 1);
let mut reference_start = start;
let mut reference_end = end;
if i == 0 && stub(1) {
reference_start =
calendar.advance_by_period(end, -self.schedule.tenor(), convention, false);
}
if i == periods - 1 && stub(i + 1) {
reference_end =
calendar.advance_by_period(start, self.schedule.tenor(), convention, false);
}
let payment_date = self.payment_calendar.adjust(end, self.payment_adjustment);
let coupon = YoYInflationCoupon::new(
payment_date,
broadcast(&self.notionals, i, 1.0),
start,
end,
broadcast(&self.fixing_days, i, 0),
Shared::clone(&self.yoy_index),
self.observation_lag,
self.interpolation,
payment_day_counter.clone(),
broadcast(&self.gearings, i, 1.0),
broadcast(&self.spreads, i, 0.0),
Some(reference_start),
Some(reference_end),
);
coupons.push(shared(coupon));
}
Ok(coupons)
}
pub fn coupons(&self) -> QlResult<Vec<Shared<YoYInflationCoupon>>> {
let coupons = self.raw_coupons()?;
if self.caps.is_empty() && self.floors.is_empty() {
for coupon in &coupons {
coupon.set_pricer(default_pricer());
}
}
Ok(coupons)
}
pub fn capped_floored_coupons(&self) -> QlResult<Vec<Shared<CappedFlooredYoYInflationCoupon>>> {
let raw = self.raw_coupons()?;
let mut coupons = Vec::with_capacity(raw.len());
for (i, underlying) in raw.into_iter().enumerate() {
let cap = pick(&self.caps, i);
let floor = pick(&self.floors, i);
coupons.push(shared(CappedFlooredYoYInflationCoupon::new(
underlying, cap, floor,
)?));
}
Ok(coupons)
}
pub fn build(&self) -> QlResult<Leg> {
if self.caps.is_empty() && self.floors.is_empty() {
Ok(self
.coupons()?
.into_iter()
.map(|coupon| coupon as Shared<dyn CashFlow>)
.collect())
} else {
Ok(self
.capped_floored_coupons()?
.into_iter()
.map(|coupon| coupon as Shared<dyn CashFlow>)
.collect())
}
}
}
pub trait AttachYoYInflationPricer {
fn attach_pricer(&self, pricer: SharedMut<dyn YoYInflationCouponPricer>);
}
impl AttachYoYInflationPricer for YoYInflationCoupon {
fn attach_pricer(&self, pricer: SharedMut<dyn YoYInflationCouponPricer>) {
self.set_pricer(pricer);
}
}
impl AttachYoYInflationPricer for CappedFlooredYoYInflationCoupon {
fn attach_pricer(&self, pricer: SharedMut<dyn YoYInflationCouponPricer>) {
self.set_pricer(pricer);
}
}
pub fn set_yoy_coupon_pricer<C: AttachYoYInflationPricer>(
coupons: &[Shared<C>],
pricer: SharedMut<dyn YoYInflationCouponPricer>,
) {
for coupon in coupons {
coupon.attach_pricer(pricer.clone());
}
}
fn pick(rates: &[Rate], index: usize) -> Option<Rate> {
if rates.is_empty() {
None
} else {
Some(broadcast(rates, index, 0.0))
}
}
fn default_pricer() -> SharedMut<dyn YoYInflationCouponPricer> {
shared_mut(SwapletYoYInflationCouponPricer::new()) as SharedMut<dyn YoYInflationCouponPricer>
}
fn broadcast<T: Clone>(values: &[T], index: usize, default: T) -> T {
match values.last() {
None => default,
Some(last) => values.get(index).unwrap_or(last).clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cashflows::coupon::Coupon;
use crate::currency::Currency;
use crate::indexes::Region;
use crate::indexes::index::Index;
use crate::indexes::inflationindex::Cpi;
use crate::settings::Settings;
use crate::time::calendars::unitedkingdom::{self, UnitedKingdom};
use crate::time::date::Date;
use crate::time::date::Month::{August, February, June, March, September};
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::frequency::Frequency;
use crate::time::schedule::MakeSchedule;
use crate::time::timeunit::TimeUnit;
use crate::types::Rate;
const NOTIONAL: Real = 1_000_000.0;
fn uk() -> Calendar {
UnitedKingdom::new(unitedkingdom::Market::Settlement)
}
fn day_counter() -> DayCounter {
Thirty360::with_convention(Convention::BondBasis)
}
fn lag() -> Period {
Period::new(2, TimeUnit::Months)
}
fn published_index(rates: &[(Date, Rate)]) -> Shared<YoYInflationIndex> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(10, February, 2022));
let index = shared(YoYInflationIndex::new(
"YY_RPI".into(),
Region::uk(),
false,
Frequency::Monthly,
Period::new(1, TimeUnit::Months),
Currency::gbp(),
settings,
));
for &(date, rate) in rates {
index.add_fixing(date, rate).expect("publishing a rate");
}
index
}
fn leg(schedule: Schedule, index: Shared<YoYInflationIndex>) -> YoYInflationLeg {
YoYInflationLeg::new(schedule, uk(), index, lag(), CpiInterpolationType::Flat)
.with_notional(NOTIONAL)
.with_payment_day_counter(day_counter())
}
#[test]
fn the_front_coupon_fixes_two_months_before_its_reference_period_end() {
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2007))
.to(Date::new(13, August, 2008))
.with_tenor(Period::new(1, TimeUnit::Years))
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.backwards()
.build();
let coupons = leg(schedule.clone(), published_index(&[]))
.coupons()
.expect("the leg is fully specified");
assert_eq!(coupons.len(), 1);
assert_eq!(
coupons[0].reference_period_end(),
Date::new(13, August, 2008)
);
assert_eq!(coupons[0].fixing_date(), Date::new(13, June, 2008));
let rolled = leg(schedule, published_index(&[]))
.with_fixing_days(3)
.coupons()
.expect("the leg is fully specified");
assert_eq!(rolled[0].fixing_date(), Date::new(10, June, 2008));
}
#[test]
fn each_coupon_gears_and_spreads_its_lagged_rate() {
let rates: Vec<(Date, Rate)> = (2016..=2020)
.map(|year| {
(
Date::new(1, June, year),
0.02 + 0.001 * f64::from(year - 2016),
)
})
.collect();
let index = published_index(&rates);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2020))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let notionals = vec![1e6, 2e6, 3e6, 4e6, 5e6];
let gearings = vec![1.5, 2.5];
let spread = 0.0035;
let coupons = leg(schedule, Shared::clone(&index))
.with_notionals(notionals.clone())
.with_gearings(gearings)
.with_spread(spread)
.coupons()
.expect("the leg is fully specified");
assert_eq!(coupons.len(), 5);
let expected_gearings = [1.5, 2.5, 2.5, 2.5, 2.5];
for (i, coupon) in coupons.iter().enumerate() {
let fixing = Cpi::lagged_yoy_rate(
&index,
coupon.accrual_end_date(),
lag(),
CpiInterpolationType::Flat,
)
.expect("the observed month is published");
assert!(
(fixing - rates[i].1).abs() < 1e-12,
"coupon {i} observed {fixing}"
);
let expected =
notionals[i] * coupon.accrual_period() * (expected_gearings[i] * fixing + spread);
let amount = Coupon::amount(&**coupon).expect("the observed month is published");
assert!(
(amount - expected).abs() < 1e-8,
"coupon {i} paid {amount}, expected {expected}"
);
}
assert_eq!(coupons[0].accrual_end_date(), Date::new(13, August, 2016));
assert_eq!(
coupons[0].coupon_base().payment_date(),
Date::new(15, August, 2016)
);
}
#[test]
fn an_irregular_period_accrues_against_a_full_reference_period() {
let index = published_index(&[]);
let irregular = MakeSchedule::new()
.from(Date::new(15, September, 2017))
.to(Date::new(30, September, 2020))
.with_next_to_last_date(Date::new(25, September, 2020))
.with_frequency(Frequency::Semiannual)
.backwards()
.build();
let coupons = leg(irregular, Shared::clone(&index))
.coupons()
.expect("the leg is fully specified");
let last = coupons.last().expect("the schedule spans periods");
assert_eq!(
coupons[0].reference_period_start(),
Date::new(25, March, 2017)
);
assert_eq!(
coupons[0].reference_period_end(),
Date::new(25, September, 2017)
);
assert_eq!(
last.reference_period_start(),
Date::new(25, September, 2020)
);
assert_eq!(last.reference_period_end(), Date::new(25, March, 2021));
let regular = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2020))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let coupons = leg(regular, index)
.coupons()
.expect("the leg is fully specified");
for coupon in coupons {
assert_eq!(coupon.reference_period_start(), coupon.accrual_start_date());
assert_eq!(coupon.reference_period_end(), coupon.accrual_end_date());
}
}
#[test]
fn the_builder_attaches_a_rating_but_not_discounting_pricer() {
let index = published_index(&[(Date::new(1, June, 2016), 0.02)]);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2016))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let coupons = leg(schedule, index)
.coupons()
.expect("the leg is fully specified");
let pricer = coupons[0].pricer().expect("the builder attached a pricer");
Coupon::amount(&*coupons[0]).expect("a rate needs no curve");
let err = pricer
.borrow()
.swaplet_price()
.expect_err("prices need a nominal curve");
assert!(
err.message().contains("no nominal term structure provided"),
"err was: {err}"
);
}
#[test]
fn a_zero_gearing_pays_its_spread_alone() {
let index = published_index(&[(Date::new(1, June, 2016), 0.02)]);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2016))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let spread = 0.0035;
let coupons = leg(schedule, index)
.with_gearing(0.0)
.with_spread(spread)
.coupons()
.expect("the leg is fully specified");
let expected = NOTIONAL * coupons[0].accrual_period() * spread;
let amount = Coupon::amount(&*coupons[0]).expect("the observed month is published");
assert!((amount - expected).abs() < 1e-10, "amount was {amount}");
}
#[test]
fn a_capped_leg_withholds_the_default_pricer_and_broadcasts_its_caps() {
let index = published_index(&[]);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2018))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let capped =
|| leg(schedule.clone(), Shared::clone(&index)).with_caps_per_coupon(vec![0.05, 0.06]);
let plain = capped().coupons().expect("the leg is fully specified");
assert_eq!(plain.len(), 3);
for coupon in &plain {
assert!(
coupon.pricer().is_none(),
"a capped leg withholds the pricer"
);
}
let coupons = capped()
.capped_floored_coupons()
.expect("the leg is fully specified");
assert_eq!(coupons.len(), 3);
let expected_caps = [0.05, 0.06, 0.06];
for (i, coupon) in coupons.iter().enumerate() {
assert!(coupon.is_capped() && !coupon.is_floored());
assert!((coupon.effective_cap() - expected_caps[i]).abs() < 1e-15);
}
}
#[test]
fn a_pricer_is_installed_across_a_capped_leg() {
let index = published_index(&[]);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2017))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let coupons = leg(schedule, index)
.with_floors(0.01)
.capped_floored_coupons()
.expect("the leg is fully specified");
assert_eq!(coupons.len(), 2);
for coupon in &coupons {
assert!(coupon.is_floored() && !coupon.is_capped());
assert!(coupon.underlying().pricer().is_none());
}
let pricer = shared_mut(SwapletYoYInflationCouponPricer::new())
as SharedMut<dyn YoYInflationCouponPricer>;
set_yoy_coupon_pricer(&coupons, pricer.clone());
for coupon in &coupons {
let installed = coupon
.underlying()
.pricer()
.expect("a pricer was installed");
assert!(SharedMut::ptr_eq(&installed, &pricer));
}
}
#[test]
fn an_underspecified_or_oversized_leg_is_an_error() {
let index = published_index(&[]);
let schedule = MakeSchedule::new()
.from(Date::new(13, August, 2015))
.to(Date::new(13, August, 2020))
.with_frequency(Frequency::Annual)
.with_calendar(uk())
.with_convention(BusinessDayConvention::Unadjusted)
.forwards()
.build();
let bare = || {
YoYInflationLeg::new(
schedule.clone(),
uk(),
Shared::clone(&index),
lag(),
CpiInterpolationType::Flat,
)
};
let Err(no_day_counter) = bare().with_notional(NOTIONAL).coupons() else {
panic!("a coupon needs a day counter");
};
assert!(
no_day_counter.message().contains("no payment daycounter"),
"err was: {no_day_counter}"
);
let Err(no_notional) = bare().with_payment_day_counter(day_counter()).coupons() else {
panic!("a coupon needs a notional");
};
assert!(
no_notional.message().contains("no notional given"),
"err was: {no_notional}"
);
let Err(too_many) = bare()
.with_payment_day_counter(day_counter())
.with_notional(NOTIONAL)
.with_gearings(vec![1.0; 6])
.coupons()
else {
panic!("the schedule has five periods");
};
assert!(
too_many.message().contains("too many gearings (6), only 5"),
"err was: {too_many}"
);
let Err(too_many_caps) = bare()
.with_payment_day_counter(day_counter())
.with_notional(NOTIONAL)
.with_caps_per_coupon(vec![0.05; 6])
.capped_floored_coupons()
else {
panic!("the schedule has five periods");
};
assert!(
too_many_caps
.message()
.contains("too many caps (6), only 5"),
"err was: {too_many_caps}"
);
}
}