use std::cell::RefCell;
use std::cmp::Ordering;
use crate::cashflow::{CashFlow, Leg};
use crate::cashflows::Duration;
use crate::errors::{QlError, QlResult};
use crate::event::reference_date;
use crate::interestrate::{Compounding, InterestRate};
use crate::math::solver1d::{DerivativeSolver, Function1D};
use crate::math::solvers1d::newtonsafe::NewtonSafe;
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::yields::FlatForward;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::types::{DiscountFactor, Rate, Real, Spread, Time};
const BASIS_POINT: Spread = 1.0e-4;
#[derive(Default)]
struct Totals {
npv: Real,
bps: Real,
non_sens_npv: Real,
}
struct IrrFinder<'a> {
leg: &'a Leg,
npv: Real,
day_counter: DayCounter,
compounding: Compounding,
frequency: Frequency,
settings: &'a Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement: Date,
npv_date: Date,
failure: &'a RefCell<Option<QlError>>,
}
impl IrrFinder<'_> {
fn present_value(&self, y: Rate) -> QlResult<(InterestRate, Real)> {
let rate = InterestRate::new(
y,
self.day_counter.clone(),
self.compounding,
self.frequency,
)?;
let npv = CashFlows::npv_at_yield(
self.leg,
&rate,
self.settings,
self.include_settlement_date_flows,
Some(self.settlement),
Some(self.npv_date),
)?;
Ok((rate, npv))
}
fn park(&self, error: QlError) -> Real {
self.failure.borrow_mut().get_or_insert(error);
Real::NAN
}
}
impl Function1D for IrrFinder<'_> {
fn value(&mut self, y: Rate) -> Real {
match self.present_value(y) {
Ok((_, npv)) => npv - self.npv,
Err(error) => self.park(error),
}
}
fn derivative(&mut self, y: Rate) -> Real {
let (rate, npv) = match self.present_value(y) {
Ok(found) => found,
Err(error) => return self.park(error),
};
match CashFlows::duration(
self.leg,
&rate,
Duration::Modified,
self.settings,
self.include_settlement_date_flows,
Some(self.settlement),
Some(self.npv_date),
) {
Ok(modified) => -modified * npv,
Err(error) => self.park(error),
}
}
}
fn sign(x: Real) -> i32 {
match x.partial_cmp(&0.0) {
Some(Ordering::Equal) => 0,
Some(Ordering::Greater) => 1,
_ => -1,
}
}
pub enum CashFlows {}
impl CashFlows {
pub fn start_date(leg: &Leg) -> QlResult<Date> {
require!(!leg.is_empty(), "empty leg");
Ok(leg
.iter()
.map(|flow| Self::accrual_start_or_payment(flow.as_ref()))
.min()
.expect("a non-empty leg has a minimum"))
}
pub fn maturity_date(leg: &Leg) -> QlResult<Date> {
require!(!leg.is_empty(), "empty leg");
Ok(leg
.iter()
.map(|flow| Self::accrual_end_or_payment(flow.as_ref()))
.max()
.expect("a non-empty leg has a maximum"))
}
pub fn previous_cash_flow(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<usize>> {
for index in (0..leg.len()).rev() {
if leg[index].has_occurred(settings, settlement_date, include_settlement_date_flows)? {
return Ok(Some(index));
}
}
Ok(None)
}
pub fn next_cash_flow(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<usize>> {
for (index, flow) in leg.iter().enumerate() {
if !flow.has_occurred(settings, settlement_date, include_settlement_date_flows)? {
return Ok(Some(index));
}
}
Ok(None)
}
pub fn previous_cash_flow_date(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<Date>> {
let found = Self::previous_cash_flow(
leg,
settings,
include_settlement_date_flows,
settlement_date,
)?;
Ok(found.map(|index| leg[index].date()))
}
pub fn next_cash_flow_date(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<Date>> {
let found = Self::next_cash_flow(
leg,
settings,
include_settlement_date_flows,
settlement_date,
)?;
Ok(found.map(|index| leg[index].date()))
}
pub fn previous_cash_flow_amount(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<Real>> {
let Some(index) = Self::previous_cash_flow(
leg,
settings,
include_settlement_date_flows,
settlement_date,
)?
else {
return Ok(None);
};
Self::amount_on_payment_date(leg[..=index].iter().rev(), leg[index].date()).map(Some)
}
pub fn next_cash_flow_amount(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Option<Real>> {
let Some(index) = Self::next_cash_flow(
leg,
settings,
include_settlement_date_flows,
settlement_date,
)?
else {
return Ok(None);
};
Self::amount_on_payment_date(leg[index..].iter(), leg[index].date()).map(Some)
}
pub fn npv(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let (totals, discount) = Self::measure(
leg,
discount_curve,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
Ok(totals.npv / discount)
}
pub fn bps(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let (totals, discount) = Self::measure(
leg,
discount_curve,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
Ok(BASIS_POINT * totals.bps / discount)
}
pub fn npvbps(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<(Real, Real)> {
if leg.is_empty() {
return Ok((0.0, 0.0));
}
let (totals, discount) = Self::measure(
leg,
discount_curve,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
Ok((totals.npv / discount, BASIS_POINT * totals.bps / discount))
}
pub fn atm_rate(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
target_npv: Option<Real>,
) -> QlResult<Rate> {
if leg.is_empty() {
return Ok(0.0);
}
let (totals, discount) = Self::measure(
leg,
discount_curve,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let required = match target_npv {
None => totals.npv,
Some(target) => target * discount,
};
let target = required - totals.non_sens_npv;
if target == 0.0 {
return Ok(0.0);
}
require!(totals.bps != 0.0, "null bps: impossible atm rate");
Ok(target / totals.bps)
}
pub fn npv_at_yield(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
let mut npv = 0.0;
let mut discount: DiscountFactor = 1.0;
let mut last_date = npv_date;
for flow in leg {
if flow.has_occurred(settings, Some(settlement), include_settlement_date_flows)? {
continue;
}
let amount = if flow.trading_ex_coupon(settings, Some(settlement))? {
0.0
} else {
flow.amount()?
};
let step = stepwise_discount_time(
flow.as_ref(),
yield_rate.day_counter(),
npv_date,
last_date,
);
discount *= yield_rate.discount_factor(step)?;
last_date = flow.date();
npv += amount * discount;
}
Ok(npv)
}
pub fn bps_at_yield(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
let flat_rate = FlatForward::with_rate(
settlement,
yield_rate.rate(),
yield_rate.day_counter().clone(),
yield_rate.compounding(),
yield_rate.frequency(),
);
Self::bps(
leg,
&flat_rate,
settings,
include_settlement_date_flows,
Some(settlement),
Some(npv_date),
)
}
#[allow(clippy::too_many_arguments)]
pub fn solve_yield(
leg: &Leg,
npv: Real,
day_counter: DayCounter,
compounding: Compounding,
frequency: Frequency,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
accuracy: Option<Real>,
max_iterations: Option<usize>,
guess: Option<Rate>,
) -> QlResult<Rate> {
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
Self::check_sign(
leg,
npv,
settings,
include_settlement_date_flows,
settlement,
)?;
let failure = RefCell::new(None);
let finder = IrrFinder {
leg,
npv,
day_counter,
compounding,
frequency,
settings,
include_settlement_date_flows,
settlement,
npv_date,
failure: &failure,
};
let guess = guess.unwrap_or(0.05);
let solver = NewtonSafe::new().with_max_evaluations(max_iterations.unwrap_or(100));
let root = solver.solve(finder, accuracy.unwrap_or(1.0e-10), guess, guess / 10.0);
match failure.into_inner() {
Some(error) => Err(error),
None => root,
}
}
fn check_sign(
leg: &Leg,
npv: Real,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement: Date,
) -> QlResult<()> {
let mut last_sign = sign(-npv);
let mut sign_changes = 0;
for flow in leg {
if flow.has_occurred(settings, Some(settlement), include_settlement_date_flows)?
|| flow.trading_ex_coupon(settings, Some(settlement))?
{
continue;
}
let this_sign = sign(flow.amount()?);
if last_sign * this_sign < 0 {
sign_changes += 1;
}
if this_sign != 0 {
last_sign = this_sign;
}
}
require!(
sign_changes > 0,
"the given cash flows cannot result in the given market price ({npv}) due to their sign"
);
Ok(())
}
pub fn duration(
leg: &Leg,
yield_rate: &InterestRate,
duration_type: Duration,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Time> {
if leg.is_empty() {
return Ok(0.0);
}
let flows = Self::discounted_flows(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
match duration_type {
Duration::Simple => Ok(Self::simple_duration(&flows)),
Duration::Modified => Ok(Self::modified_duration(&flows, yield_rate)),
Duration::Macaulay => {
require!(
yield_rate.compounding() == Compounding::Compounded,
"compounded rate required for a Macaulay duration"
);
let n = frequency_of(yield_rate);
Ok((1.0 + yield_rate.rate() / n) * Self::modified_duration(&flows, yield_rate))
}
}
}
pub fn convexity(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let flows = Self::discounted_flows(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let (rate, n) = (yield_rate.rate(), frequency_of(yield_rate));
let mut present_value = 0.0;
let mut second_derivative = 0.0;
for &(amount, t, discount) in &flows {
present_value += amount * discount;
second_derivative += amount
* match effective_compounding(yield_rate.compounding(), t, n) {
Compounding::Simple => 2.0 * discount.powi(3) * t * t,
Compounding::Continuous => discount * t * t,
_ => discount * t * (n * t + 1.0) / (n * (1.0 + rate / n).powi(2)),
};
}
if present_value == 0.0 {
return Ok(0.0);
}
Ok(second_derivative / present_value)
}
pub fn basis_point_value(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let npv = Self::npv_at_yield(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let modified = Self::duration(
leg,
yield_rate,
Duration::Modified,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let convexity = Self::convexity(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let shift = 0.0001;
let delta = -modified * npv * shift;
let gamma = (convexity / 100.0) * npv * shift * shift;
Ok(delta + 0.5 * gamma)
}
pub fn yield_value_basis_point(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Real> {
if leg.is_empty() {
return Ok(0.0);
}
let npv = Self::npv_at_yield(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let modified = Self::duration(
leg,
yield_rate,
Duration::Modified,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
let shift = 0.01;
Ok(shift / (-npv * modified))
}
fn simple_duration(flows: &[(Real, Time, DiscountFactor)]) -> Time {
let mut present_value = 0.0;
let mut derivative = 0.0;
for &(amount, t, discount) in flows {
present_value += amount * discount;
derivative += t * amount * discount;
}
if present_value == 0.0 {
return 0.0;
}
derivative / present_value
}
fn modified_duration(
flows: &[(Real, Time, DiscountFactor)],
yield_rate: &InterestRate,
) -> Time {
let (rate, n) = (yield_rate.rate(), frequency_of(yield_rate));
let mut present_value = 0.0;
let mut derivative = 0.0;
for &(amount, t, discount) in flows {
present_value += amount * discount;
derivative -= amount
* match effective_compounding(yield_rate.compounding(), t, n) {
Compounding::Simple => discount * discount * t,
Compounding::Continuous => discount * t,
_ => t * discount / (1.0 + rate / n),
};
}
if present_value == 0.0 {
return 0.0;
}
-derivative / present_value
}
fn discounted_flows(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<Vec<(Real, Time, DiscountFactor)>> {
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
let mut flows = Vec::with_capacity(leg.len());
let mut t = 0.0;
let mut last_date = npv_date;
for flow in leg {
if flow.has_occurred(settings, Some(settlement), include_settlement_date_flows)? {
continue;
}
let amount = if flow.trading_ex_coupon(settings, Some(settlement))? {
0.0
} else {
flow.amount()?
};
t += stepwise_discount_time(
flow.as_ref(),
yield_rate.day_counter(),
npv_date,
last_date,
);
flows.push((amount, t, yield_rate.discount_factor(t)?));
last_date = flow.date();
}
Ok(flows)
}
fn yield_dates(
settings: &Settings<Date>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<(Date, Date)> {
let settlement = reference_date(settings, settlement_date)?;
Ok((settlement, npv_date.unwrap_or(settlement)))
}
fn measure(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
) -> QlResult<(Totals, DiscountFactor)> {
let settlement = reference_date(settings, settlement_date)?;
let npv_date = npv_date.unwrap_or(settlement);
let totals = Self::totals(
leg,
discount_curve,
settings,
include_settlement_date_flows,
settlement,
)?;
Ok((totals, discount_curve.discount_date(npv_date, false)?))
}
fn totals(
leg: &Leg,
discount_curve: &dyn YieldTermStructure,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement: Date,
) -> QlResult<Totals> {
let settlement = Some(settlement);
let mut totals = Totals::default();
for flow in leg {
if flow.has_occurred(settings, settlement, include_settlement_date_flows)?
|| flow.trading_ex_coupon(settings, settlement)?
{
continue;
}
let discount = discount_curve.discount_date(flow.date(), false)?;
let amount = flow.amount()? * discount;
totals.npv += amount;
match flow.as_coupon() {
Some(coupon) => totals.bps += coupon.nominal() * coupon.accrual_period() * discount,
None => totals.non_sens_npv += amount,
}
}
Ok(totals)
}
fn accrual_start_or_payment(flow: &dyn CashFlow) -> Date {
match flow.as_coupon() {
Some(coupon) => coupon.accrual_start_date(),
None => flow.date(),
}
}
fn accrual_end_or_payment(flow: &dyn CashFlow) -> Date {
match flow.as_coupon() {
Some(coupon) => coupon.accrual_end_date(),
None => flow.date(),
}
}
fn amount_on_payment_date<'a>(
flows: impl Iterator<Item = &'a Shared<dyn CashFlow>>,
payment_date: Date,
) -> QlResult<Real> {
let mut total = 0.0;
for flow in flows.take_while(|flow| flow.date() == payment_date) {
total += flow.amount()?;
}
Ok(total)
}
}
fn frequency_of(yield_rate: &InterestRate) -> Real {
yield_rate.frequency() as i16 as Real
}
fn effective_compounding(compounding: Compounding, t: Time, n: Real) -> Compounding {
match compounding {
Compounding::SimpleThenCompounded if t <= 1.0 / n => Compounding::Simple,
Compounding::CompoundedThenSimple if t > 1.0 / n => Compounding::Simple,
Compounding::SimpleThenCompounded | Compounding::CompoundedThenSimple => {
Compounding::Compounded
}
other => other,
}
}
fn stepwise_discount_time(
flow: &dyn CashFlow,
day_counter: &DayCounter,
npv_date: Date,
last_date: Date,
) -> Time {
let payment_date = flow.date();
let coupon = flow.as_coupon();
let (ref_start, ref_end) = match coupon {
Some(coupon) => (
coupon.reference_period_start(),
coupon.reference_period_end(),
),
None if last_date == npv_date => {
(payment_date - Period::new(1, TimeUnit::Years), payment_date)
}
None => (last_date, payment_date),
};
match coupon {
Some(coupon) if last_date != coupon.accrual_start_date() => {
let start = coupon.accrual_start_date();
day_counter.year_fraction_ref(start, payment_date, ref_start, ref_end)
- day_counter.year_fraction_ref(start, last_date, ref_start, ref_end)
}
_ => day_counter.year_fraction_ref(last_date, payment_date, ref_start, ref_end),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cashflows::fixedratecoupon::FixedRateCoupon;
use crate::cashflows::simplecashflow::{Redemption, SimpleCashFlow};
use crate::shared::shared;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
fn today() -> Date {
Date::new(7, Month::July, 2026)
}
#[test]
fn the_sign_of_a_nan_is_negative_as_the_cpp_template_leaves_it() {
assert_eq!(sign(0.0), 0);
assert_eq!(sign(-0.0), 0);
assert_eq!(sign(1.0), 1);
assert_eq!(sign(-1.0), -1);
assert_eq!(sign(Real::NAN), -1);
assert_eq!(sign(Real::INFINITY), 1);
assert_eq!(sign(Real::NEG_INFINITY), -1);
}
fn accrual_start() -> Date {
Date::new(15, Month::January, 2026)
}
fn accrual_end() -> Date {
Date::new(15, Month::July, 2026)
}
fn payment() -> Date {
Date::new(20, Month::July, 2026)
}
fn early_payment() -> Date {
Date::new(10, Month::January, 2026)
}
fn coupon_amount() -> Real {
100.0 * 0.03 * 181.0 / 360.0
}
fn leg() -> Leg {
vec![
shared(SimpleCashFlow::new(5.0, early_payment()).unwrap()) as Shared<dyn CashFlow>,
shared(FixedRateCoupon::from_rate(
payment(),
100.0,
0.03,
Actual360::new(),
accrual_start(),
accrual_end(),
None,
None,
None,
)) as Shared<dyn CashFlow>,
shared(Redemption::new(100.0, payment()).unwrap()) as Shared<dyn CashFlow>,
]
}
fn settings() -> Settings<Date> {
let settings = Settings::new();
settings.set_evaluation_date(today());
settings
}
#[test]
fn the_leg_spans_the_accrual_dates_of_its_coupons() {
let leg = leg();
assert_eq!(CashFlows::start_date(&leg).unwrap(), early_payment());
assert_eq!(CashFlows::maturity_date(&leg).unwrap(), payment());
}
#[test]
fn an_empty_leg_has_no_dates() {
let leg = Leg::new();
assert!(CashFlows::start_date(&leg).is_err());
assert!(CashFlows::maturity_date(&leg).is_err());
}
#[test]
fn the_previous_and_next_flows_straddle_the_settlement_date() {
let (leg, settings) = (leg(), settings());
let at = |date| {
(
CashFlows::previous_cash_flow(&leg, &settings, None, date).unwrap(),
CashFlows::next_cash_flow(&leg, &settings, None, date).unwrap(),
)
};
assert_eq!(at(None), (Some(0), Some(1)));
assert_eq!(at(Some(early_payment() - 1)), (None, Some(0)));
assert_eq!(at(Some(payment() + 1)), (Some(2), None));
}
#[test]
fn the_flow_dates_follow_the_flows_they_are_read_off() {
let (leg, settings) = (leg(), settings());
let previous = |date| CashFlows::previous_cash_flow_date(&leg, &settings, None, date);
let next = |date| CashFlows::next_cash_flow_date(&leg, &settings, None, date);
assert_eq!(previous(None).unwrap(), Some(early_payment()));
assert_eq!(next(None).unwrap(), Some(payment()));
assert_eq!(previous(Some(early_payment() - 1)).unwrap(), None);
assert_eq!(next(Some(payment() + 1)).unwrap(), None);
}
#[test]
fn the_amounts_sum_the_flows_sharing_a_payment_date() {
let (leg, settings) = (leg(), settings());
let previous = |date| CashFlows::previous_cash_flow_amount(&leg, &settings, None, date);
let next = |date| CashFlows::next_cash_flow_amount(&leg, &settings, None, date);
assert_eq!(previous(None).unwrap(), Some(5.0));
assert_eq!(previous(Some(early_payment() - 1)).unwrap(), None);
assert_eq!(next(Some(payment() + 1)).unwrap(), None);
let both = next(None).unwrap().unwrap();
assert!((both - (coupon_amount() + 100.0)).abs() < 1e-13);
}
#[test]
fn an_unset_evaluation_date_is_an_error() {
let (leg, settings) = (leg(), Settings::new());
assert!(CashFlows::previous_cash_flow(&leg, &settings, None, None).is_err());
assert!(CashFlows::next_cash_flow(&leg, &settings, None, None).is_err());
}
}
#[cfg(test)]
mod analytics_tests {
use super::*;
use crate::cashflows::fixedratecoupon::FixedRateCoupon;
use crate::cashflows::fixedrateleg::FixedRateLeg;
use crate::cashflows::simplecashflow::{Redemption, SimpleCashFlow};
use crate::interestrate::{Compounding, InterestRate};
use crate::shared::shared;
use crate::termstructures::yields::FlatForward;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::date::{Month, Year};
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::frequency::Frequency;
use crate::time::schedule::MakeSchedule;
use crate::types::Rate;
const NOMINAL: Real = 100.0;
const RATE: Rate = 0.05;
const FORWARD: Rate = 0.03;
fn day(month: Month, year: Year) -> Date {
Date::new(15, month, year)
}
fn today() -> Date {
day(Month::January, 2026)
}
fn maturity() -> Date {
day(Month::January, 2028)
}
fn settings() -> Settings<Date> {
let settings = Settings::new();
settings.set_evaluation_date(today());
settings
}
fn curve(forward: Rate) -> FlatForward {
FlatForward::with_rate(
today(),
forward,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)
}
fn df(date: Date) -> Real {
(-FORWARD * f64::from(date - today()) / 365.0).exp()
}
fn accrual(start: Date, end: Date) -> Real {
f64::from(end - start) / 360.0
}
fn simple(rate: Rate) -> InterestRate {
InterestRate::new(
rate,
Actual360::new(),
Compounding::Simple,
Frequency::Annual,
)
.unwrap()
}
fn simple_365(rate: Rate) -> InterestRate {
InterestRate::new(
rate,
Actual365Fixed::new(),
Compounding::Simple,
Frequency::NoFrequency,
)
.unwrap()
}
fn periods() -> [(Date, Date); 4] {
[
(today(), day(Month::July, 2026)),
(day(Month::July, 2026), day(Month::January, 2027)),
(day(Month::January, 2027), day(Month::July, 2027)),
(day(Month::July, 2027), maturity()),
]
}
fn fixed_leg(rate: Rate) -> Leg {
let schedule = MakeSchedule::new()
.from(today())
.to(maturity())
.with_frequency(Frequency::Semiannual)
.with_calendar(NullCalendar::new())
.with_convention(BusinessDayConvention::Unadjusted)
.backwards()
.build();
FixedRateLeg::new(schedule)
.with_notional(NOMINAL)
.with_interest_rate(simple(rate))
.build()
.unwrap()
}
fn unit_leg() -> Leg {
(0..3)
.map(|i| shared(SimpleCashFlow::new(1.0, today() + i).unwrap()) as Shared<dyn CashFlow>)
.collect()
}
#[test]
fn the_npv_at_yield_counts_the_flows_the_settlement_date_rule_admits() {
let settings = settings();
let leg = unit_leg();
let no_discount = InterestRate::new(
0.0,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)
.unwrap();
let npv = |include| {
CashFlows::npv_at_yield(
&leg,
&no_discount,
&settings,
Some(include),
Some(today()),
None,
)
.unwrap()
};
settings.set_include_todays_cash_flows(None);
assert_eq!(npv(false), 2.0);
assert_eq!(npv(true), 3.0);
settings.set_include_todays_cash_flows(Some(false));
assert_eq!(npv(false), 2.0);
assert_eq!(npv(true), 2.0);
}
#[test]
fn the_npv_counts_the_flows_the_settlement_date_rule_admits() {
let (settings, curve) = (settings(), curve(0.0));
let leg = unit_leg();
let npv = |include| {
CashFlows::npv(&leg, &curve, &settings, Some(include), Some(today()), None).unwrap()
};
settings.set_include_todays_cash_flows(None);
assert_eq!(npv(false), 2.0);
assert_eq!(npv(true), 3.0);
settings.set_include_todays_cash_flows(Some(false));
assert_eq!(npv(false), 2.0);
assert_eq!(npv(true), 2.0);
}
#[test]
fn the_npv_discounts_every_flow_and_then_the_npv_date() {
let (settings, curve, leg) = (settings(), curve(FORWARD), fixed_leg(RATE));
let expected: Real = periods()
.iter()
.map(|&(start, end)| NOMINAL * RATE * accrual(start, end) * df(end))
.sum();
let npv = |npv_date| CashFlows::npv(&leg, &curve, &settings, None, None, npv_date).unwrap();
assert!((npv(None) - expected).abs() < 1e-12);
assert!((npv(Some(maturity())) - expected / df(maturity())).abs() < 1e-12);
}
#[test]
fn the_bps_sums_the_discounted_accruals_of_the_coupons() {
let (settings, curve, leg) = (settings(), curve(FORWARD), fixed_leg(RATE));
let expected: Real = 1.0e-4
* periods()
.iter()
.map(|&(start, end)| NOMINAL * accrual(start, end) * df(end))
.sum::<Real>();
let bps = CashFlows::bps(&leg, &curve, &settings, None, None, None).unwrap();
assert!((bps - expected).abs() < 1e-14);
}
#[test]
fn the_bps_is_the_npv_change_for_a_one_basis_point_coupon_spread() {
let (settings, curve) = (settings(), curve(FORWARD));
let npv =
|rate| CashFlows::npv(&fixed_leg(rate), &curve, &settings, None, None, None).unwrap();
let bumped = npv(RATE + 1.0e-4) - npv(RATE);
let bps = CashFlows::bps(&fixed_leg(RATE), &curve, &settings, None, None, None).unwrap();
assert!((bumped - bps).abs() < 1e-12);
}
#[test]
fn a_flow_trading_ex_coupon_is_left_out() {
let (settings, curve) = (settings(), curve(FORWARD));
let (start, end) = periods()[0];
let leg = |ex_coupon_date| {
vec![shared(FixedRateCoupon::new(
end,
NOMINAL,
simple(RATE),
start,
end,
None,
None,
ex_coupon_date,
)) as Shared<dyn CashFlow>]
};
let npv = |leg: &Leg| CashFlows::npv(leg, &curve, &settings, None, None, None).unwrap();
let bps = |leg: &Leg| CashFlows::bps(leg, &curve, &settings, None, None, None).unwrap();
let paying = leg(None);
assert!((npv(&paying) - NOMINAL * RATE * accrual(start, end) * df(end)).abs() < 1e-13);
assert!(bps(&paying) > 0.0);
let ex_coupon = leg(Some(today()));
assert_eq!(npv(&ex_coupon), 0.0);
assert_eq!(bps(&ex_coupon), 0.0);
}
#[test]
fn an_empty_leg_is_worth_nothing() {
let (settings, curve, leg) = (Settings::new(), curve(FORWARD), Leg::new());
assert_eq!(
CashFlows::npv(&leg, &curve, &settings, None, None, None).unwrap(),
0.0
);
assert_eq!(
CashFlows::bps(&leg, &curve, &settings, None, None, None).unwrap(),
0.0
);
}
#[test]
fn npvbps_returns_the_npv_and_the_bps() {
let (settings, curve, leg) = (settings(), curve(FORWARD), fixed_leg(RATE));
let at = Some(day(Month::January, 2027));
let (npv, bps) = CashFlows::npvbps(&leg, &curve, &settings, None, None, at).unwrap();
assert_eq!(
npv,
CashFlows::npv(&leg, &curve, &settings, None, None, at).unwrap()
);
assert_eq!(
bps,
CashFlows::bps(&leg, &curve, &settings, None, None, at).unwrap()
);
}
#[test]
fn the_atm_rate_reprices_the_leg_and_ignores_the_flows_that_do_not_accrue() {
let (settings, curve) = (settings(), curve(FORWARD));
let mut leg = fixed_leg(RATE);
let atm = |leg: &Leg, target| {
CashFlows::atm_rate(leg, &curve, &settings, None, None, None, target).unwrap()
};
assert!((atm(&leg, None) - RATE).abs() < 1e-14);
leg.push(shared(Redemption::new(NOMINAL, maturity()).unwrap()) as Shared<dyn CashFlow>);
assert!((atm(&leg, None) - RATE).abs() < 1e-14);
let npv = CashFlows::npv(&leg, &curve, &settings, None, None, None).unwrap();
assert!((atm(&leg, Some(npv)) - RATE).abs() < 1e-14);
}
#[test]
fn a_target_npv_is_scaled_by_the_discount_at_the_npv_date() {
let (settings, curve) = (settings(), curve(FORWARD));
let leg = fixed_leg(RATE);
let npv_date = maturity();
let discount = curve.discount_date(npv_date, false).unwrap();
assert!((discount - 1.0).abs() > 0.05);
let npv = CashFlows::npv(&leg, &curve, &settings, None, None, Some(npv_date)).unwrap();
let atm = CashFlows::atm_rate(
&leg,
&curve,
&settings,
None,
None,
Some(npv_date),
Some(npv),
)
.unwrap();
assert!((atm - RATE).abs() < 1e-14);
}
#[test]
fn the_atm_rate_needs_a_target_and_a_sensitivity() {
let (settings, curve) = (settings(), curve(FORWARD));
let leg = fixed_leg(RATE);
let bare: Leg =
vec![shared(Redemption::new(NOMINAL, maturity()).unwrap()) as Shared<dyn CashFlow>];
let atm = |leg: &Leg, target| {
CashFlows::atm_rate(leg, &curve, &settings, None, None, None, target)
};
assert_eq!(atm(&leg, Some(0.0)).unwrap(), 0.0);
assert_eq!(atm(&bare, None).unwrap(), 0.0);
assert!(atm(&bare, Some(0.0)).is_err());
}
#[test]
fn the_npv_at_yield_discounts_each_flow_over_its_own_step() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let npv_date = today() + 30;
let yield_rate = InterestRate::new(
FORWARD,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)
.unwrap();
let expected: Real = periods()
.iter()
.map(|&(start, end)| {
let t = f64::from(end - npv_date) / 365.0;
NOMINAL * RATE * accrual(start, end) * (-FORWARD * t).exp()
})
.sum();
let npv = CashFlows::npv_at_yield(&leg, &yield_rate, &settings, None, None, Some(npv_date))
.unwrap();
assert!((npv - expected).abs() < 1e-12);
assert!(
(npv - CashFlows::npv_at_yield(&leg, &yield_rate, &settings, None, None, None)
.unwrap())
.abs()
> 1e-3
);
}
#[test]
fn the_npv_at_yield_compounds_the_steps_rather_than_the_total_time() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let yield_rate = simple_365(FORWARD);
let mut stepwise = 0.0;
let mut flat = 0.0;
let mut discount = 1.0;
let mut last = today();
for (start, end) in periods() {
let amount = NOMINAL * RATE * accrual(start, end);
discount /= 1.0 + FORWARD * f64::from(end - last) / 365.0;
stepwise += amount * discount;
flat += amount / (1.0 + FORWARD * f64::from(end - today()) / 365.0);
last = end;
}
assert!(flat - stepwise > 1e-3);
let npv = CashFlows::npv_at_yield(&leg, &yield_rate, &settings, None, None, None).unwrap();
assert!((npv - stepwise).abs() < 1e-12);
}
#[test]
fn the_bps_at_yield_is_the_bps_off_a_flat_curve_of_the_yield() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let yield_rate = simple_365(FORWARD);
let flat = FlatForward::with_rate(
today(),
FORWARD,
Actual365Fixed::new(),
Compounding::Simple,
Frequency::NoFrequency,
);
let at_yield =
CashFlows::bps_at_yield(&leg, &yield_rate, &settings, None, None, None).unwrap();
assert_eq!(
at_yield,
CashFlows::bps(&leg, &flat, &settings, None, None, None).unwrap()
);
assert!(at_yield > 0.0);
}
#[test]
fn an_empty_leg_has_no_yield_analytics() {
let (settings, leg) = (Settings::new(), Leg::new());
let yield_rate = simple_365(FORWARD);
assert_eq!(
CashFlows::npv_at_yield(&leg, &yield_rate, &settings, None, None, None).unwrap(),
0.0
);
assert_eq!(
CashFlows::bps_at_yield(&leg, &yield_rate, &settings, None, None, None).unwrap(),
0.0
);
assert_eq!(
CashFlows::duration(
&leg,
&yield_rate,
Duration::Simple,
&settings,
None,
None,
None
)
.unwrap(),
0.0
);
assert_eq!(
CashFlows::convexity(&leg, &yield_rate, &settings, None, None, None).unwrap(),
0.0
);
}
fn compounded(rate: Rate) -> InterestRate {
InterestRate::new(
rate,
Actual365Fixed::new(),
Compounding::Compounded,
Frequency::Semiannual,
)
.unwrap()
}
#[test]
fn the_modified_duration_is_the_finite_difference_of_the_npv() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let h = 1.0e-6;
let npv =
|y| CashFlows::npv_at_yield(&leg, &compounded(y), &settings, None, None, None).unwrap();
let expected = -(npv(FORWARD + h) - npv(FORWARD - h)) / (2.0 * h) / npv(FORWARD);
let modified = CashFlows::duration(
&leg,
&compounded(FORWARD),
Duration::Modified,
&settings,
None,
None,
None,
)
.unwrap();
assert!(modified > 0.9);
assert!((modified - expected).abs() < 1e-7);
}
#[test]
fn the_convexity_is_the_second_finite_difference_of_the_npv() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let h = 1.0e-4;
let npv =
|y| CashFlows::npv_at_yield(&leg, &compounded(y), &settings, None, None, None).unwrap();
let expected =
(npv(FORWARD + h) - 2.0 * npv(FORWARD) + npv(FORWARD - h)) / (h * h) / npv(FORWARD);
let convexity =
CashFlows::convexity(&leg, &compounded(FORWARD), &settings, None, None, None).unwrap();
assert!(convexity > 1.0);
assert!((convexity - expected).abs() < 1e-5);
}
fn continuous(rate: Rate) -> InterestRate {
InterestRate::new(
rate,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)
.unwrap()
}
#[test]
fn the_continuous_yield_duration_and_convexity_weight_each_flow_by_its_own_time() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let h = 1.0e-4;
let npv =
|y| CashFlows::npv_at_yield(&leg, &continuous(y), &settings, None, None, None).unwrap();
let first = -(npv(FORWARD + h) - npv(FORWARD - h)) / (2.0 * h) / npv(FORWARD);
let second =
(npv(FORWARD + h) - 2.0 * npv(FORWARD) + npv(FORWARD - h)) / (h * h) / npv(FORWARD);
let modified = CashFlows::duration(
&leg,
&continuous(FORWARD),
Duration::Modified,
&settings,
None,
None,
None,
)
.unwrap();
let convexity =
CashFlows::convexity(&leg, &continuous(FORWARD), &settings, None, None, None).unwrap();
assert!(modified > 0.9);
assert!((modified - first).abs() < 1e-8);
assert!(convexity > 1.0);
assert!((convexity - second).abs() < 1e-5);
}
#[test]
fn the_simple_yield_duration_and_convexity_follow_from_its_own_discount_factor() {
let (settings, yield_rate) = (settings(), simple_365(FORWARD));
let (start, end) = periods()[0];
let leg: Leg = vec![shared(FixedRateCoupon::new(
end,
NOMINAL,
simple(RATE),
start,
end,
None,
None,
None,
)) as Shared<dyn CashFlow>];
let t = f64::from(end - today()) / 365.0;
let discount = 1.0 / (1.0 + FORWARD * t);
let modified = CashFlows::duration(
&leg,
&yield_rate,
Duration::Modified,
&settings,
None,
None,
None,
)
.unwrap();
let convexity =
CashFlows::convexity(&leg, &yield_rate, &settings, None, None, None).unwrap();
assert!((modified - t * discount).abs() < 1e-14);
assert!((convexity - 2.0 * t * t * discount * discount).abs() < 1e-14);
}
#[test]
fn only_the_surviving_flows_of_nonzero_amount_can_change_sign() {
let settings = settings();
let flow = |amount, date| {
shared(SimpleCashFlow::new(amount, date).expect("valid flow")) as Shared<dyn CashFlow>
};
let solve = |leg: &Leg, npv| {
CashFlows::solve_yield(
leg,
npv,
Actual365Fixed::new(),
Compounding::Compounded,
Frequency::Semiannual,
&settings,
None,
None,
None,
None,
None,
None,
)
};
let wrong_sign = |leg: &Leg, npv| {
solve(leg, npv)
.unwrap_err()
.to_string()
.contains("due to their sign")
};
let with_zero: Leg = vec![flow(0.0, day(Month::July, 2026)), flow(NOMINAL, maturity())];
assert!(solve(&with_zero, 90.0).is_ok());
assert!(wrong_sign(&with_zero, -90.0));
let already_paid: Leg = vec![flow(-50.0, today() - 10), flow(NOMINAL, maturity())];
assert!(wrong_sign(&already_paid, -90.0));
}
#[test]
fn the_macaulay_duration_scales_the_modified_one_and_needs_a_compounded_yield() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let duration = |rate: &InterestRate, kind| {
CashFlows::duration(&leg, rate, kind, &settings, None, None, None)
};
let (compounded, simple) = (compounded(FORWARD), simple_365(FORWARD));
let modified = duration(&compounded, Duration::Modified).unwrap();
let macaulay = duration(&compounded, Duration::Macaulay).unwrap();
assert!((macaulay - (1.0 + FORWARD / 2.0) * modified).abs() < 1e-15);
assert!(macaulay > modified);
assert!(duration(&simple, Duration::Macaulay).is_err());
assert!(duration(&simple, Duration::Modified).is_ok());
}
fn redeeming_leg() -> Leg {
let mut leg = fixed_leg(RATE);
leg.push(shared(Redemption::new(NOMINAL, maturity()).unwrap()) as Shared<dyn CashFlow>);
leg
}
#[test]
fn the_solved_yield_reprices_the_leg() {
let (settings, leg) = (settings(), redeeming_leg());
let npv_date = Some(today() + 30);
let rate = compounded(FORWARD);
let target = CashFlows::npv_at_yield(&leg, &rate, &settings, None, None, npv_date).unwrap();
let irr = CashFlows::solve_yield(
&leg,
target,
Actual365Fixed::new(),
Compounding::Compounded,
Frequency::Semiannual,
&settings,
None,
None,
npv_date,
None,
None,
None,
)
.unwrap();
assert!((irr - FORWARD).abs() < 1e-10);
let repriced =
CashFlows::npv_at_yield(&leg, &compounded(irr), &settings, None, None, npv_date)
.unwrap();
assert!((repriced - target).abs() < 1e-9);
}
#[test]
fn the_yield_solver_errors_rather_than_returning_a_partial_answer() {
let (settings, leg) = (settings(), redeeming_leg());
let solve = |npv, frequency, max_iterations| {
CashFlows::solve_yield(
&leg,
npv,
Actual365Fixed::new(),
Compounding::Compounded,
frequency,
&settings,
None,
None,
None,
None,
max_iterations,
None,
)
};
let message = |npv, frequency, max_iterations| {
solve(npv, frequency, max_iterations)
.expect_err("the solve was meant to fail")
.to_string()
};
assert!(solve(100.0, Frequency::Semiannual, None).is_ok());
assert!(message(-100.0, Frequency::Semiannual, None).contains("due to their sign"));
assert!(message(100.0, Frequency::Semiannual, Some(1)).contains("unable to bracket"));
assert!(message(100.0, Frequency::Once, None).contains("frequency"));
}
#[test]
fn the_basis_point_value_is_the_taylor_expansion_with_a_hundredth_of_the_gamma() {
let (settings, leg) = (settings(), redeeming_leg());
let rate = compounded(FORWARD);
let npv =
|y| CashFlows::npv_at_yield(&leg, &compounded(y), &settings, None, None, None).unwrap();
let modified =
CashFlows::duration(&leg, &rate, Duration::Modified, &settings, None, None, None)
.unwrap();
let convexity = CashFlows::convexity(&leg, &rate, &settings, None, None, None).unwrap();
let shift = 1.0e-4;
let delta = -modified * npv(FORWARD) * shift;
let gamma = 0.5 * (convexity / 100.0) * npv(FORWARD) * shift * shift;
let bpv = CashFlows::basis_point_value(&leg, &rate, &settings, None, None, None).unwrap();
assert!(bpv < -0.01);
assert!((bpv - (delta + gamma)).abs() < 1e-16);
let actual = npv(FORWARD + shift) - npv(FORWARD);
assert!(((delta + 100.0 * gamma) - actual).abs() < 1e-9);
assert!((bpv - actual).abs() > 1e-6);
}
#[test]
fn the_yield_value_of_a_basis_point_is_the_yield_move_for_a_one_cent_price_move() {
let (settings, leg) = (settings(), redeeming_leg());
let rate = compounded(FORWARD);
let npv = CashFlows::npv_at_yield(&leg, &rate, &settings, None, None, None).unwrap();
let modified =
CashFlows::duration(&leg, &rate, Duration::Modified, &settings, None, None, None)
.unwrap();
let yvbp =
CashFlows::yield_value_basis_point(&leg, &rate, &settings, None, None, None).unwrap();
assert!((yvbp - 0.01 / (-npv * modified)).abs() < 1e-18);
assert!(yvbp < 0.0);
let irr = |target| {
CashFlows::solve_yield(
&leg,
target,
Actual365Fixed::new(),
Compounding::Compounded,
Frequency::Semiannual,
&settings,
None,
None,
None,
None,
None,
None,
)
.unwrap()
};
assert!((irr(npv + 0.01) - irr(npv) - yvbp).abs() < 1e-8);
}
#[test]
fn an_empty_leg_has_no_basis_point_values() {
let (settings, leg) = (Settings::new(), Leg::new());
let rate = simple_365(FORWARD);
assert_eq!(
CashFlows::basis_point_value(&leg, &rate, &settings, None, None, None).unwrap(),
0.0
);
assert_eq!(
CashFlows::yield_value_basis_point(&leg, &rate, &settings, None, None, None).unwrap(),
0.0
);
}
#[test]
fn the_durations_measure_time_from_the_npv_date() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let yield_rate = InterestRate::new(
FORWARD,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)
.unwrap();
let duration = |npv_date| {
CashFlows::duration(
&leg,
&yield_rate,
Duration::Simple,
&settings,
None,
None,
npv_date,
)
.unwrap()
};
let shift = 30.0 / 365.0;
assert!((duration(Some(today() + 30)) - (duration(None) - shift)).abs() < 1e-14);
assert!(duration(None) > 1.0);
}
#[test]
fn an_empty_leg_has_no_atm_rate() {
let (settings, curve, leg) = (Settings::new(), curve(FORWARD), Leg::new());
assert_eq!(
CashFlows::npvbps(&leg, &curve, &settings, None, None, None).unwrap(),
(0.0, 0.0)
);
assert_eq!(
CashFlows::atm_rate(&leg, &curve, &settings, None, None, None, None).unwrap(),
0.0
);
}
}
#[cfg(test)]
mod bonds_tests {
use super::*;
use crate::cashflows::fixedrateleg::FixedRateLeg;
use crate::cashflows::simplecashflow::Redemption;
use crate::shared::shared;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::calendars::australia::{self, Australia};
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::calendars::unitedkingdom::{self, UnitedKingdom};
use crate::time::date::Month;
use crate::time::dategenerationrule::DateGeneration;
use crate::time::daycounters::actualactual::{ActualActual, Convention};
use crate::time::schedule::Schedule;
struct Case {
settlement: Date,
npv: Real,
irr: Rate,
duration: Real,
convexity: Real,
}
fn ex_coupon_leg(
start: Date,
first_coupon: Date,
maturity: Date,
coupon: Rate,
ex_coupon_period: Period,
payment_calendar: Calendar,
ex_coupon_calendar: Calendar,
) -> (Leg, DayCounter) {
let unadjusted = BusinessDayConvention::Unadjusted;
let schedule = Schedule::new(
start,
maturity,
Period::new(6, TimeUnit::Months),
NullCalendar::new(),
unadjusted,
unadjusted,
DateGeneration::Forward,
true,
first_coupon,
Date::null(),
);
let day_counter = ActualActual::with_schedule(Convention::ISMA, schedule.clone());
let mut leg = FixedRateLeg::new(schedule)
.with_notional(100.0)
.with_coupon_rate(
coupon,
day_counter.clone(),
Compounding::Simple,
Frequency::Annual,
)
.unwrap()
.with_payment_calendar(payment_calendar)
.with_payment_adjustment(unadjusted)
.with_ex_coupon_period(ex_coupon_period, ex_coupon_calendar, unadjusted, false)
.build()
.unwrap();
leg.push(shared(Redemption::new(100.0, maturity).unwrap()) as Shared<dyn CashFlow>);
(leg, day_counter)
}
fn check(leg: &Leg, day_counter: &DayCounter, case: &Case, tolerance: (Real, Real, Real)) {
let settings = Settings::new();
let settlement = Some(case.settlement);
let (comp, freq) = (Compounding::Compounded, Frequency::Semiannual);
let irr = CashFlows::solve_yield(
leg,
case.npv,
day_counter.clone(),
comp,
freq,
&settings,
Some(false),
settlement,
None,
None,
None,
None,
)
.unwrap();
assert!((irr - case.irr).abs() < tolerance.0, "yield {irr}");
let rate = InterestRate::new(irr, day_counter.clone(), comp, freq).unwrap();
let duration = CashFlows::duration(
leg,
&rate,
Duration::Modified,
&settings,
Some(false),
settlement,
None,
)
.unwrap();
assert!(
(duration - case.duration).abs() < tolerance.0,
"duration {duration}"
);
let convexity =
CashFlows::convexity(leg, &rate, &settings, Some(false), settlement, None).unwrap();
assert!(
(convexity - case.convexity).abs() < tolerance.1,
"convexity {convexity}"
);
let npv =
CashFlows::npv_at_yield(leg, &rate, &settings, Some(false), settlement, None).unwrap();
assert!((npv - case.npv).abs() < tolerance.2, "npv {npv}");
}
#[test]
fn the_uk_gilt_reproduces_its_bloomberg_yield_duration_and_convexity() {
let calendar = UnitedKingdom::new(unitedkingdom::Market::Settlement);
let (leg, day_counter) = ex_coupon_leg(
Date::new(29, Month::February, 1996),
Date::new(7, Month::June, 1996),
Date::new(7, Month::June, 2021),
0.08,
Period::new(6, TimeUnit::Days),
calendar.clone(),
calendar,
);
let cases = [
Case {
settlement: Date::new(29, Month::May, 2013),
npv: 106.8021978,
irr: 0.0749518,
duration: 5.6760445,
convexity: 42.1531486,
},
Case {
settlement: Date::new(30, Month::May, 2013),
npv: 102.8241758,
irr: 0.0749618,
duration: 5.8928163,
convexity: 43.7562186,
},
Case {
settlement: Date::new(31, Month::May, 2013),
npv: 102.8461538,
irr: 0.0749599,
duration: 5.8901860,
convexity: 43.7239438,
},
];
for case in &cases {
check(&leg, &day_counter, case, (1e-6, 1e-6, 1e-6));
}
}
#[test]
fn the_australian_bond_reproduces_its_bloomberg_yield_duration_and_convexity() {
let (leg, day_counter) = ex_coupon_leg(
Date::new(15, Month::February, 2004),
Date::new(15, Month::August, 2004),
Date::new(15, Month::February, 2017),
0.06,
Period::new(7, TimeUnit::Days),
Australia::new(australia::Market::Settlement),
NullCalendar::new(),
);
let cases = [
Case {
settlement: Date::new(7, Month::August, 2014),
npv: 105.867,
irr: 0.04723,
duration: 2.26276,
convexity: 6.54870,
},
Case {
settlement: Date::new(8, Month::August, 2014),
npv: 102.884,
irr: 0.047235,
duration: 2.32536,
convexity: 6.72531,
},
Case {
settlement: Date::new(11, Month::August, 2014),
npv: 102.934,
irr: 0.047190,
duration: 2.31732,
convexity: 6.68407,
},
];
for case in &cases {
check(&leg, &day_counter, case, (1e-5, 1e-4, 1e-3));
}
}
}
#[cfg(test)]
mod basis_point_value_tests {
use super::*;
use crate::cashflows::fixedrateleg::FixedRateLeg;
use crate::cashflows::simplecashflow::Redemption;
use crate::shared::shared;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::calendars::unitedstates::{self, UnitedStates};
use crate::time::date::Month;
use crate::time::dategenerationrule::DateGeneration;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::schedule::Schedule;
const FACE_AMOUNT: Real = 1_000_000.0;
const CLEAN_PRICE: Real = 102.890625;
fn today() -> Date {
Date::new(29, Month::January, 2024)
}
fn maturity() -> Date {
Date::new(15, Month::August, 2033)
}
fn calendar() -> Calendar {
UnitedStates::new(unitedstates::Market::GovernmentBond)
}
fn treasury_leg(day_counter: &DayCounter) -> Leg {
let unadjusted = BusinessDayConvention::Unadjusted;
let schedule = Schedule::new(
Date::new(15, Month::November, 2023),
maturity(),
Period::new(6, TimeUnit::Months),
calendar(),
unadjusted,
unadjusted,
DateGeneration::Forward,
false,
Date::null(),
Date::null(),
);
let mut leg = FixedRateLeg::new(schedule)
.with_notional(FACE_AMOUNT)
.with_coupon_rate(
0.045,
day_counter.clone(),
Compounding::Simple,
Frequency::Annual,
)
.unwrap()
.with_payment_calendar(calendar())
.with_payment_adjustment(unadjusted)
.build()
.unwrap();
leg.push(shared(Redemption::new(FACE_AMOUNT, maturity()).unwrap()) as Shared<dyn CashFlow>);
leg
}
fn settlement_date() -> Date {
calendar().advance(
today(),
1,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
)
}
fn accrued_amount(leg: &Leg, settings: &Settings<Date>, settlement: Date) -> QlResult<Real> {
let Some(index) = CashFlows::next_cash_flow(leg, settings, Some(false), Some(settlement))?
else {
return Ok(0.0);
};
let payment_date = leg[index].date();
let mut accrued = 0.0;
for flow in leg[index..]
.iter()
.take_while(|flow| flow.date() == payment_date)
{
if let Some(coupon) = flow.as_coupon() {
accrued += coupon.accrued_amount(settlement)?;
}
}
Ok(accrued)
}
#[test]
fn the_treasury_bond_reproduces_its_basis_point_value_and_yield_value() {
let settings = Settings::new();
settings.set_evaluation_date(today());
let day_counter = Thirty360::with_convention(Convention::USA);
let leg = treasury_leg(&day_counter);
let (comp, freq) = (Compounding::Compounded, Frequency::Semiannual);
let settlement = settlement_date();
assert_eq!(settlement, Date::new(30, Month::January, 2024));
let accrued = accrued_amount(&leg, &settings, settlement).unwrap();
let npv = CLEAN_PRICE * FACE_AMOUNT / 100.0 + accrued;
assert!((accrued - 9375.0).abs() < 1e-6);
let irr = CashFlows::solve_yield(
&leg,
npv,
day_counter.clone(),
comp,
freq,
&settings,
Some(false),
Some(settlement),
None,
None,
None,
None,
)
.unwrap();
assert!((irr - 0.041301).abs() < 1e-6, "yield {irr}");
let rate = InterestRate::new(irr, day_counter, comp, freq).unwrap();
let cases = [
(settlement, -795.459834, -0.0012571287),
(
Date::new(12, Month::February, 2024),
-793.149033,
-0.0012607913,
),
];
for (settlement, expected_bpv, expected_yvbp) in cases {
let settlement = Some(settlement);
let bpv =
CashFlows::basis_point_value(&leg, &rate, &settings, Some(false), settlement, None)
.unwrap();
assert!((bpv - expected_bpv).abs() < 1e-6, "bpv {bpv}");
let yvbp = CashFlows::yield_value_basis_point(
&leg,
&rate,
&settings,
Some(false),
settlement,
None,
)
.unwrap()
* FACE_AMOUNT;
assert!((yvbp - expected_yvbp).abs() < 1e-9, "yvbp {yvbp}");
}
}
}