use crate::cashflows::CpiLeg;
use crate::errors::QlResult;
use crate::indexes::Index;
use crate::indexes::inflationindex::{CpiInterpolationType, ZeroInflationIndex};
use crate::instrument::Instrument;
use crate::instruments::Bond;
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
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::types::{Natural, Rate, Real};
pub struct CpiBond {
bond: Bond,
frequency: Frequency,
day_counter: DayCounter,
base_cpi: Real,
observation_lag: Period,
index: Shared<ZeroInflationIndex>,
interpolation: CpiInterpolationType,
}
impl CpiBond {
#[allow(clippy::too_many_arguments)]
pub fn new(
settlement_days: Natural,
face_amount: Real,
base_cpi: Real,
observation_lag: Period,
index: Shared<ZeroInflationIndex>,
interpolation: CpiInterpolationType,
schedule: Schedule,
coupons: Vec<Rate>,
accrual_day_counter: DayCounter,
payment_convention: BusinessDayConvention,
issue_date: Option<Date>,
payment_calendar: Option<Calendar>,
ex_coupon_period: Option<Period>,
ex_coupon_calendar: Calendar,
ex_coupon_convention: BusinessDayConvention,
ex_coupon_end_of_month: bool,
settings: Shared<Settings<Date>>,
) -> QlResult<Self> {
require!(schedule.len() >= 2, "schedule spans no period");
require!(
settlement_days <= (Date::max_date() - Date::min_date()) as u32,
"settlement days exceed supported date range"
);
require!(
face_amount.is_finite() && face_amount > 0.0 && base_cpi.is_finite() && base_cpi > 0.0,
"positive finite face amount and base CPI required"
);
require!(
!coupons.is_empty() && coupons.iter().all(|r| r.is_finite()),
"nonempty finite coupon rates required"
);
require!(
std::ptr::eq(&*settings, Index::settings(&*index)),
"bond and index must share settings"
);
require!(
issue_date.is_none_or(|d| d != Date::null())
&& schedule.dates().iter().all(|d| *d != Date::null())
&& schedule.dates().windows(2).all(|d| d[0] < d[1]),
"non-null increasing schedule and issue dates required"
);
require!(
ex_coupon_period.is_none_or(|p| p.length() >= 0),
"ex-coupon period must be nonnegative"
);
let maturity = schedule.end_date();
let frequency = if schedule.has_tenor() {
schedule.tenor().frequency()
} else {
Frequency::NoFrequency
};
let calendar = payment_calendar.unwrap_or_else(|| schedule.calendar().clone());
let mut builder = CpiLeg::new(schedule, index.clone(), base_cpi, observation_lag)
.with_notional(face_amount)
.with_fixed_rates(coupons)
.with_payment_day_counter(accrual_day_counter.clone())
.with_payment_adjustment(payment_convention)
.with_payment_calendar(calendar.clone())
.with_observation_interpolation(interpolation)
.with_subtract_inflation_nominal(false);
if let Some(period) = ex_coupon_period.filter(|p| p.length() != 0) {
builder = builder.with_ex_coupon_period(
period,
ex_coupon_calendar,
ex_coupon_convention,
ex_coupon_end_of_month,
);
}
let mut flows = builder.build()?;
let redemption = flows
.pop()
.ok_or_else(|| crate::errors::QlError::new("empty CPI leg", file!(), line!()))?;
let mut bond = Bond::new(settlement_days, calendar, issue_date, flows, settings)?;
bond.set_indexed_redemption(face_amount, redemption)?;
bond.set_maturity_date(maturity);
bond.base().register_with(Index::observable(&*index));
Ok(Self {
bond,
frequency,
day_counter: accrual_day_counter,
base_cpi,
observation_lag,
index,
interpolation,
})
}
pub fn bond(&self) -> &Bond {
&self.bond
}
pub fn bond_mut(&mut self) -> &mut Bond {
&mut self.bond
}
pub fn into_bond(self) -> Bond {
self.bond
}
pub fn frequency(&self) -> Frequency {
self.frequency
}
pub fn day_counter(&self) -> &DayCounter {
&self.day_counter
}
pub fn base_cpi(&self) -> Real {
self.base_cpi
}
pub fn observation_lag(&self) -> Period {
self.observation_lag
}
pub fn cpi_index(&self) -> &Shared<ZeroInflationIndex> {
&self.index
}
pub fn observation_interpolation(&self) -> CpiInterpolationType {
self.interpolation
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::Handle;
use crate::indexes::inflation::UkRpi;
use crate::interestrate::Compounding;
use crate::pricingengines::bond::DiscountingBondEngine;
use crate::shared::{shared, shared_mut};
use crate::termstructures::yields::FlatForward;
use crate::time::calendars::NullCalendar;
use crate::time::date::Month::*;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::timeunit::TimeUnit;
fn fixture(interpolation: CpiInterpolationType) -> (CpiBond, Shared<ZeroInflationIndex>) {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, June, 2025));
let index = shared(UkRpi::new(settings.clone()));
for (month, value) in [(March, 104.0), (April, 105.0), (May, 107.0)] {
index.add_fixing(Date::new(1, month, 2025), value).unwrap();
}
let mut bond = CpiBond::new(
0,
1000.0,
100.0,
Period::new(3, TimeUnit::Months),
index.clone(),
interpolation,
Schedule::from_dates(vec![
Date::new(15, January, 2025),
Date::new(15, July, 2025),
]),
vec![0.02],
Actual365Fixed::new(),
BusinessDayConvention::Unadjusted,
Some(Date::new(15, January, 2025)),
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
settings.clone(),
)
.unwrap();
let curve = shared(FlatForward::with_rate(
Date::new(15, June, 2025),
0.0,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
));
bond.bond_mut()
.base_mut()
.set_pricing_engine(shared_mut(DiscountingBondEngine::new(
Handle::new(curve),
None,
settings,
)));
(bond, index)
}
#[test]
fn indexed_principal_and_original_face_price_units() {
let (mut bond, _) = fixture(CpiInterpolationType::Flat);
let coupon = 1000.0 * 0.02 * 181.0 / 365.0 * 1.05;
let expected = 1050.0 + coupon;
assert_eq!(bond.bond().cashflows().len(), 2);
assert_eq!(bond.bond().redemptions().len(), 1);
assert!((bond.bond().redemptions()[0].amount().unwrap() - 1050.0).abs() < 1e-12);
assert!((bond.bond_mut().npv().unwrap() - expected).abs() < 1e-10);
assert!((bond.bond_mut().dirty_price().unwrap() - expected / 10.0).abs() < 1e-10);
assert_eq!(bond.bond().notional(None).unwrap(), 1000.0);
assert_eq!(
bond.bond()
.notional(Some(Date::new(15, July, 2025)))
.unwrap(),
0.0
);
assert!(
(bond
.bond()
.accrued_amount(Some(Date::new(17, June, 2025)))
.unwrap()
- 100.0 * 0.02 * 153.0 / 365.0 * 1.04)
.abs()
< 1e-12
);
}
#[test]
fn linear_observation_and_live_fixing_invalidation() {
let (mut bond, index) = fixture(CpiInterpolationType::Linear);
let ratio = (105.0 + 2.0 * 14.0 / 31.0) / 100.0;
assert!((bond.bond().redemptions()[0].amount().unwrap() - 1000.0 * ratio).abs() < 1e-10);
let before = bond.bond_mut().npv().unwrap();
index.clear_fixings();
assert!(bond.bond_mut().npv().is_err());
for (month, value) in [(March, 105.0), (April, 106.0), (May, 108.0)] {
index.add_fixing(Date::new(1, month, 2025), value).unwrap();
}
assert!(bond.bond_mut().npv().unwrap() > before);
let base = bond.into_bond();
drop(index);
assert!(base.redemptions()[0].amount().unwrap().is_finite());
let snapshot = base.unpriced_snapshot();
assert!(
(snapshot.redemptions()[0].amount().unwrap() - base.redemptions()[0].amount().unwrap())
.abs()
< 1e-12
);
}
#[test]
fn coupon_vectors_repeat_last_and_support_distinct_period_rates() {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, June, 2026));
let index = shared(UkRpi::new(settings.clone()));
index.add_fixing(Date::new(1, April, 2025), 105.0).unwrap();
index
.add_fixing(Date::new(1, October, 2025), 110.0)
.unwrap();
let build = |rates| {
CpiBond::new(
0,
1000.0,
100.0,
Period::new(3, TimeUnit::Months),
index.clone(),
CpiInterpolationType::Flat,
Schedule::from_dates(vec![
Date::new(15, January, 2025),
Date::new(15, July, 2025),
Date::new(15, January, 2026),
]),
rates,
Actual365Fixed::new(),
BusinessDayConvention::Unadjusted,
None,
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
settings.clone(),
)
.unwrap()
};
let repeated = build(vec![0.02]);
let explicit = build(vec![0.02, 0.02]);
for (left, right) in repeated
.bond()
.cashflows()
.iter()
.zip(explicit.bond().cashflows())
{
assert!((left.amount().unwrap() - right.amount().unwrap()).abs() < 1e-12);
}
let varying = build(vec![0.02, 0.03]);
let first = varying.bond().cashflows()[0].amount().unwrap();
let second = varying.bond().cashflows()[1].amount().unwrap();
assert!((first - 1000.0 * 0.02 * 181.0 / 365.0 * 1.05).abs() < 1e-10);
assert!((second - 1000.0 * 0.03 * 184.0 / 365.0 * 1.10).abs() < 1e-10);
assert!((varying.bond().redemptions()[0].amount().unwrap() - 1100.0).abs() < 1e-12);
}
}