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::handle::Handle;
use crate::interestrate::{Compounding, InterestRate};
use crate::math::solver1d::{DerivativeSolver, Function1D, Solver1D};
use crate::math::solvers1d::brent::Brent;
use crate::math::solvers1d::newtonsafe::NewtonSafe;
use crate::quotes::{Quote, SimpleQuote};
use crate::require;
use crate::settings::Settings;
use crate::shared::{Shared, shared};
use crate::termstructures::yields::ZeroSpreadedTermStructure;
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 is_expired(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<bool> {
if leg.is_empty() {
return Ok(true);
}
for flow in leg.iter().rev() {
if !flow.has_occurred(settings, settlement_date, include_settlement_date_flows)? {
return Ok(false);
}
}
Ok(true)
}
pub fn accrued_amount(
leg: &Leg,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
) -> QlResult<Real> {
let Some(index) = Self::next_cash_flow(
leg,
settings,
include_settlement_date_flows,
settlement_date,
)?
else {
return Ok(0.0);
};
let settlement = reference_date(settings, settlement_date)?;
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)
}
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,
true,
)?;
Ok(totals.npv / discount)
}
#[allow(clippy::too_many_arguments)]
pub fn npv_at_z_spread(
leg: &Leg,
discount_curve: Handle<dyn YieldTermStructure>,
z_spread: Spread,
compounding: Compounding,
frequency: Frequency,
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);
}
require!(z_spread.is_finite(), "z-spread must be finite");
let quote = shared(SimpleQuote::new(z_spread));
let spreaded = ZeroSpreadedTermStructure::with_compounding(
discount_curve,
Handle::new(quote as Shared<dyn Quote>),
compounding,
frequency,
);
let value = Self::npv(
leg,
&spreaded,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
)?;
require!(value.is_finite(), "spreaded NPV must be finite");
Ok(value)
}
#[allow(clippy::too_many_arguments)]
pub fn z_spread(
leg: &Leg,
npv: Real,
discount_curve: Handle<dyn YieldTermStructure>,
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<Spread> {
require!(npv.is_finite(), "target NPV must be finite");
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
let quote = shared(SimpleQuote::new(0.0));
let spreaded = ZeroSpreadedTermStructure::with_compounding(
discount_curve,
Handle::new(Shared::clone("e) as Shared<dyn Quote>),
compounding,
frequency,
);
let failure = RefCell::new(None);
let objective = |z: Rate| {
quote.set_value(z);
match Self::npv(
leg,
&spreaded,
settings,
include_settlement_date_flows,
Some(settlement),
Some(npv_date),
) {
Ok(value) => npv - value,
Err(error) => {
failure.borrow_mut().get_or_insert(error);
Real::NAN
}
}
};
let mut solver = Brent::new().with_max_evaluations(max_iterations.unwrap_or(100));
let root = solver.solve(
objective,
accuracy.unwrap_or(1.0e-10),
guess.unwrap_or(0.0),
0.01,
);
match failure.into_inner() {
Some(error) => Err(error),
None => root,
}
}
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,
false,
)?;
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,
true,
)?;
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,
true,
)?;
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);
}
Ok(Self::yield_results(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
false,
)?
.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 stepwise = StepwiseYield::new(yield_rate, leg)?;
let mut bps = 0.0;
let mut discount: DiscountFactor = 1.0;
let mut last_date = npv_date;
let mut in_first_period = true;
for flow in leg {
if flow.has_occurred(settings, Some(settlement), include_settlement_date_flows)? {
continue;
}
let Some(coupon) = flow.as_coupon() else {
continue;
};
let step = stepwise_discount_time(
flow.as_ref(),
yield_rate.day_counter(),
npv_date,
last_date,
);
discount *= stepwise
.at(in_first_period, flow.date())
.discount_factor(step)?;
bps += coupon.nominal() * coupon.accrual_period() * discount;
last_date = flow.date();
in_first_period = false;
}
Ok(BASIS_POINT * bps)
}
#[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);
}
if duration_type == Duration::Macaulay {
require!(
yield_rate.compounding() == Compounding::Compounded,
"compounded rate required for a Macaulay duration"
);
}
let results = Self::yield_results(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
duration_type != Duration::Simple,
)?;
if results.npv == 0.0 {
return Ok(0.0);
}
Ok(match duration_type {
Duration::Simple => results.weighted_time / results.npv,
Duration::Modified => -results.first_derivative / results.npv,
Duration::Macaulay => {
let n = yield_rate.frequency() as i16 as Real;
(1.0 + yield_rate.rate() / n) * (-results.first_derivative / results.npv)
}
})
}
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 results = Self::yield_results(
leg,
yield_rate,
settings,
include_settlement_date_flows,
settlement_date,
npv_date,
true,
)?;
if results.npv == 0.0 {
return Ok(0.0);
}
Ok(results.second_derivative / results.npv)
}
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 * 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 yield_results(
leg: &Leg,
yield_rate: &InterestRate,
settings: &Settings<Date>,
include_settlement_date_flows: Option<bool>,
settlement_date: Option<Date>,
npv_date: Option<Date>,
derivatives: bool,
) -> QlResult<YieldResults> {
let (settlement, npv_date) = Self::yield_dates(settings, settlement_date, npv_date)?;
let stepwise = StepwiseYield::new(yield_rate, leg)?;
let mut results = YieldResults::default();
let mut discount: DiscountFactor = 1.0;
let mut first_derivative = 0.0;
let mut second_derivative = 0.0;
let mut t = 0.0;
let mut last_date = npv_date;
let mut in_first_period = true;
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,
);
t += step;
let rate = stepwise.at(in_first_period, flow.date());
let this_discount = rate.discount_factor(step)?;
if derivatives {
let this_first = rate.discount_factor_first_derivative(step)?;
let this_second = rate.discount_factor_second_derivative(step)?;
second_derivative = second_derivative * this_discount
+ 2.0 * first_derivative * this_first
+ discount * this_second;
first_derivative = first_derivative * this_discount + discount * this_first;
results.first_derivative += amount * first_derivative;
results.second_derivative += amount * second_derivative;
}
discount *= this_discount;
results.npv += amount * discount;
results.weighted_time += t * amount * discount;
last_date = flow.date();
in_first_period = false;
}
Ok(results)
}
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>,
coupon_amounts: bool,
) -> 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,
coupon_amounts,
)?;
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,
coupon_amounts: bool,
) -> 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)?;
match flow.as_coupon() {
Some(coupon) => {
totals.bps += coupon.nominal() * coupon.accrual_period() * discount;
if coupon_amounts {
totals.npv += flow.amount()? * discount;
}
}
None => {
let amount = flow.amount()? * discount;
totals.npv += amount;
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)
}
}
#[derive(Default)]
struct YieldResults {
npv: Real,
first_derivative: Real,
second_derivative: Real,
weighted_time: Real,
}
struct StepwiseYield {
main: InterestRate,
exception: InterestRate,
compounding: Compounding,
final_payment: Date,
}
impl StepwiseYield {
fn new(yield_rate: &InterestRate, leg: &Leg) -> QlResult<Self> {
let compounding = yield_rate.compounding();
let part = |compounding| {
InterestRate::new(
yield_rate.rate(),
yield_rate.day_counter().clone(),
compounding,
yield_rate.frequency(),
)
};
let (main, exception) = match compounding {
Compounding::SimpleThenCompounded | Compounding::CompoundedThenSimple => {
(part(Compounding::Compounded)?, part(Compounding::Simple)?)
}
_ => (yield_rate.clone(), yield_rate.clone()),
};
let final_payment = leg.last().map_or_else(Date::null, |flow| flow.date());
Ok(Self {
main,
exception,
compounding,
final_payment,
})
}
fn at(&self, in_first_period: bool, payment_date: Date) -> &InterestRate {
let exceptional = match self.compounding {
Compounding::SimpleThenCompounded => in_first_period,
Compounding::CompoundedThenSimple => payment_date == self.final_payment,
_ => false,
};
if exceptional {
&self.exception
} else {
&self.main
}
}
}
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::iborcoupon::IborCoupon;
use crate::cashflows::simplecashflow::{Redemption, SimpleCashFlow};
use crate::currency::Currency;
use crate::handle::Handle;
use crate::indexes::iborindex::IborIndex;
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::daycounters::actualactual::{ActualActual, Convention};
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);
}
fn unpriced_ibor_coupon(settings: Shared<Settings<Date>>) -> Shared<IborCoupon> {
let index = shared(IborIndex::new(
"unpriced".into(),
Period::new(6, TimeUnit::Months),
2,
Currency::eur(),
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
Actual360::new(),
Handle::empty(),
settings,
));
let (start, end) = periods()[0];
shared(
IborCoupon::new(
end,
NOMINAL,
start,
end,
None,
index,
1.0,
0.0,
None,
None,
None,
false,
None,
BusinessDayConvention::Unadjusted,
)
.unwrap(),
)
}
#[test]
fn the_bps_never_asks_a_coupon_for_its_amount() {
let (settings, curve) = (shared(settings()), curve(FORWARD));
let coupon = unpriced_ibor_coupon(settings.clone());
let (start, end) = periods()[0];
let expected = 1.0e-4 * NOMINAL * accrual(start, end) * df(end);
let leg: Leg = vec![
coupon as Shared<dyn CashFlow>,
shared(Redemption::new(NOMINAL, maturity()).unwrap()),
];
let bps = CashFlows::bps(&leg, &curve, &settings, None, None, None).unwrap();
assert!((bps - expected).abs() < 1e-12, "bps {bps} vs {expected}");
let npv = CashFlows::npv(&leg, &curve, &settings, None, None, None);
assert!(npv.unwrap_err().to_string().contains("pricer not set"));
assert!(CashFlows::npvbps(&leg, &curve, &settings, None, None, None).is_err());
assert!(CashFlows::atm_rate(&leg, &curve, &settings, None, None, None, None).is_err());
}
#[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_discounts_the_coupons_stepwise() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let yield_rate = simple_365(FORWARD);
let mut expected = 0.0;
let mut discount = 1.0;
let mut last = today();
for (start, end) in periods() {
discount /= 1.0 + FORWARD * f64::from(end - last) / 365.0;
expected += NOMINAL * accrual(start, end) * discount;
last = end;
}
let at_yield =
CashFlows::bps_at_yield(&leg, &yield_rate, &settings, None, None, None).unwrap();
assert!((at_yield - 1.0e-4 * expected).abs() < 1e-15);
}
#[test]
fn the_bps_at_yield_steps_over_flows_that_are_not_coupons() {
let settings = settings();
let yield_rate = simple_365(FORWARD);
let plain = fixed_leg(RATE);
let mut with_flow = plain.clone();
with_flow.insert(
1,
shared(SimpleCashFlow::new(NOMINAL, day(Month::October, 2026)).unwrap())
as Shared<dyn CashFlow>,
);
let bps = |leg: &Leg| {
CashFlows::bps_at_yield(leg, &yield_rate, &settings, None, None, None).unwrap()
};
assert_eq!(bps(&with_flow), bps(&plain));
}
#[test]
fn the_bps_at_yield_starts_at_the_npv_date_without_dividing_by_its_discount() {
let (settings, leg) = (settings(), fixed_leg(RATE));
let bps = CashFlows::bps_at_yield(
&leg,
&compounded(FORWARD),
&settings,
Some(false),
Some(today()),
Some(today() + 30),
)
.unwrap();
assert!((bps - 0.019587468533005217).abs() < 1e-15, "{bps}");
}
#[test]
fn a_skipped_plain_flow_keeps_the_first_period_of_a_composite_bps_at_yield() {
let settings = settings();
let mut leg = fixed_leg(RATE);
leg.insert(
0,
shared(SimpleCashFlow::new(NOMINAL, day(Month::April, 2026)).unwrap())
as Shared<dyn CashFlow>,
);
let rate = InterestRate::new(
FORWARD,
Actual365Fixed::new(),
Compounding::SimpleThenCompounded,
Frequency::Semiannual,
)
.unwrap();
let bps = CashFlows::bps_at_yield(&leg, &rate, &settings, Some(false), None, None).unwrap();
assert!((bps - 0.019539570365829526).abs() < 1e-15, "{bps}");
}
#[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_second_order_taylor_expansion() {
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 * 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!((bpv - actual).abs() < 1e-9);
}
#[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
);
}
fn semiannual_360(rate: Rate, compounding: Compounding) -> InterestRate {
InterestRate::new(rate, Actual360::new(), compounding, Frequency::Semiannual).unwrap()
}
fn single_flow_leg(days: i32) -> Leg {
vec![shared(SimpleCashFlow::new(NOMINAL, today() + days).unwrap()) as Shared<dyn CashFlow>]
}
#[test]
fn a_single_flow_is_discounted_simply_under_both_composite_yields() {
let settings = settings();
let dur = |leg: &Leg, rate: &InterestRate| {
CashFlows::duration(leg, rate, Duration::Modified, &settings, None, None, None).unwrap()
};
let conv = |leg: &Leg, rate: &InterestRate| {
CashFlows::convexity(leg, rate, &settings, None, None, None).unwrap()
};
let rate = |compounding| semiannual_360(0.04, compounding);
let simple = rate(Compounding::Simple);
for days in [150, 180, 210] {
let leg = single_flow_leg(days);
for composite in [
Compounding::SimpleThenCompounded,
Compounding::CompoundedThenSimple,
] {
assert_eq!(dur(&leg, &rate(composite)), dur(&leg, &simple));
assert_eq!(conv(&leg, &rate(composite)), conv(&leg, &simple));
}
}
}
#[test]
fn a_composite_yield_is_simple_on_its_own_end_of_the_leg() {
let settings = settings();
let leg: Leg = vec![
shared(SimpleCashFlow::new(NOMINAL, today() + 210).unwrap()) as Shared<dyn CashFlow>,
shared(SimpleCashFlow::new(NOMINAL, today() + 300).unwrap()),
];
let (r, t1, t2) = (0.04, 210.0 / 360.0, 90.0 / 360.0);
let simple = |t: Real| 1.0 / (1.0 + r * t);
let compounded = |t: Real| (1.0 + r / 2.0).powf(-2.0 * t);
let npv = |compounding| {
let rate = semiannual_360(r, compounding);
CashFlows::npv_at_yield(&leg, &rate, &settings, None, None, None).unwrap()
};
let stc = NOMINAL * simple(t1) * (1.0 + compounded(t2));
let cts = NOMINAL * compounded(t1) * (1.0 + simple(t2));
assert!((npv(Compounding::SimpleThenCompounded) - stc).abs() < 1e-12);
assert!((npv(Compounding::CompoundedThenSimple) - cts).abs() < 1e-12);
}
#[test]
fn the_non_coupon_flows_are_stepped_over_faked_reference_periods() {
let settings = settings();
let first = day(Month::April, 2026);
let accrual_end = day(Month::October, 2026);
let leg: Leg = vec![
shared(Redemption::new(30.0, first).unwrap()) as Shared<dyn CashFlow>,
shared(FixedRateCoupon::from_rate(
accrual_end,
NOMINAL,
RATE,
Actual360::new(),
first,
accrual_end,
None,
None,
None,
)) as Shared<dyn CashFlow>,
shared(SimpleCashFlow::new(20.0, accrual_end + 90).unwrap()) as Shared<dyn CashFlow>,
];
let yield_rate = InterestRate::new(
FORWARD,
ActualActual::with_convention(Convention::ISMA),
Compounding::Continuous,
Frequency::Annual,
)
.unwrap();
let t1 = 90.0 / 365.0;
let t2 = t1 + 0.5;
let t3 = t2 + 0.25;
let amounts = [30.0, NOMINAL * RATE * 183.0 / 360.0, 20.0];
let mut present_value = 0.0;
let mut weighted_time = 0.0;
let mut weighted_time_squared = 0.0;
for (amount, t) in amounts.iter().zip([t1, t2, t3]) {
let discounted = amount * (-FORWARD * t).exp();
present_value += discounted;
weighted_time += t * discounted;
weighted_time_squared += t * t * discounted;
}
let duration = CashFlows::duration(
&leg,
&yield_rate,
Duration::Simple,
&settings,
None,
None,
None,
)
.unwrap();
assert!((duration - weighted_time / present_value).abs() < 1e-14);
let convexity =
CashFlows::convexity(&leg, &yield_rate, &settings, None, None, None).unwrap();
assert!((convexity - weighted_time_squared / present_value).abs() < 1e-14);
}
}
#[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> {
CashFlows::accrued_amount(leg, settings, Some(false), Some(settlement))
}
#[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.098043, -0.0012571287),
(
Date::new(12, Month::February, 2024),
-792.789400,
-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}");
}
}
}
#[cfg(test)]
mod expiry_tests {
use super::*;
use crate::cashflows::SimpleCashFlow;
use crate::shared::shared;
use crate::time::date::Month;
fn today() -> Date {
Date::new(7, Month::July, 2026)
}
fn flow(date: Date) -> Shared<dyn CashFlow> {
shared(SimpleCashFlow::new(1.0, date).unwrap()) as Shared<dyn CashFlow>
}
#[test]
fn an_empty_leg_is_expired() {
let settings = Settings::new();
settings.set_evaluation_date(today());
assert!(CashFlows::is_expired(&Vec::new(), &settings, None, None).unwrap());
}
#[test]
fn a_leg_is_expired_only_once_its_last_flow_has_occurred() {
let settings = Settings::new();
settings.set_evaluation_date(today());
let leg: Leg = vec![flow(today() - 10), flow(today() + 10)];
assert!(!CashFlows::is_expired(&leg, &settings, None, None).unwrap());
let past: Leg = vec![flow(today() - 20), flow(today() - 10)];
assert!(CashFlows::is_expired(&past, &settings, None, None).unwrap());
}
#[test]
fn plain_payments_accrue_nothing() {
let settings = Settings::new();
settings.set_evaluation_date(today());
let leg: Leg = vec![flow(today() + 10), flow(today() + 20)];
assert_eq!(
CashFlows::accrued_amount(&leg, &settings, None, None).unwrap(),
0.0
);
}
}