use std::any::Any;
use crate::cashflow::{CashFlow, Leg};
use crate::cashflows::{AmortizingPayment, CashFlows, Redemption};
use crate::errors::QlResult;
use crate::instrument::{Instrument, InstrumentBase, InstrumentResults};
use crate::interestrate::Compounding;
use crate::math::comparison::close;
use crate::pricingengine::{Arguments, GenericEngine, Results};
use crate::pricingengines::bond::BondFunctions;
use crate::settings::Settings;
use crate::shared::{Shared, shared};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Natural, Rate, Real};
use crate::{fail, require};
pub struct BondArguments {
pub settlement_date: Option<Date>,
pub cashflows: Leg,
pub calendar: Calendar,
}
impl Arguments for BondArguments {
fn validate(&self) -> QlResult<()> {
require!(
self.settlement_date.is_some(),
"no settlement date provided"
);
require!(!self.cashflows.is_empty(), "no cash flow provided");
Ok(())
}
}
#[derive(Default)]
pub struct BondResults {
pub instrument: InstrumentResults,
pub settlement_value: Option<Real>,
}
impl Results for BondResults {
fn reset(&mut self) {
self.settlement_value = None;
self.instrument.reset();
}
fn as_instrument_results(&self) -> Option<&InstrumentResults> {
Some(&self.instrument)
}
}
pub type BondEngine = GenericEngine<BondArguments, BondResults>;
pub struct Bond {
base: InstrumentBase,
settings: Shared<Settings<Date>>,
settlement_days: Natural,
calendar: Calendar,
notional_schedule: Vec<Date>,
notionals: Vec<Real>,
cashflows: Leg,
redemptions: Leg,
maturity_date: Option<Date>,
issue_date: Option<Date>,
settlement_value: Option<Real>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BondPrice {
Clean(Real),
Dirty(Real),
}
impl BondPrice {
pub fn amount(&self) -> Real {
match self {
BondPrice::Clean(amount) | BondPrice::Dirty(amount) => *amount,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BondPriceType {
Clean,
Dirty,
}
impl Bond {
pub fn new(
settlement_days: Natural,
calendar: Calendar,
issue_date: Option<Date>,
coupons: Leg,
settings: Shared<Settings<Date>>,
) -> QlResult<Bond> {
let base = InstrumentBase::new();
settings.register_eval_date_observer(&base.observer());
let mut bond = Bond {
base,
settings,
settlement_days,
calendar,
notional_schedule: Vec::new(),
notionals: Vec::new(),
cashflows: coupons,
redemptions: Vec::new(),
maturity_date: None,
issue_date,
settlement_value: None,
};
if !bond.cashflows.is_empty() {
bond.cashflows.sort_by_key(|a| a.date());
if let Some(issue) = bond.issue_date {
require!(
issue < bond.cashflows[0].date(),
"issue date must be earlier than first payment date"
);
}
bond.maturity_date = Some(bond.cashflows.last().expect("non-empty").date());
}
for cashflow in &bond.cashflows {
bond.base.register_with(cashflow.observable());
}
Ok(bond)
}
pub fn from_coupons(
settlement_days: Natural,
calendar: Calendar,
issue_date: Option<Date>,
coupons: Leg,
settings: Shared<Settings<Date>>,
) -> QlResult<Bond> {
let mut bond = Bond::new(settlement_days, calendar, issue_date, coupons, settings)?;
bond.add_redemptions_to_cashflows(&[])?;
Ok(bond)
}
pub fn settlement_days(&self) -> Natural {
self.settlement_days
}
pub fn calendar(&self) -> &Calendar {
&self.calendar
}
pub fn settings(&self) -> &Settings<Date> {
&self.settings
}
pub fn settings_handle(&self) -> Shared<Settings<Date>> {
Shared::clone(&self.settings)
}
pub fn notionals(&self) -> &[Real] {
&self.notionals
}
pub fn cashflows(&self) -> &Leg {
&self.cashflows
}
pub fn redemptions(&self) -> &Leg {
&self.redemptions
}
pub fn issue_date(&self) -> Option<Date> {
self.issue_date
}
pub fn notional(&self, date: Option<Date>) -> QlResult<Real> {
let date = match date {
Some(date) => date,
None => self.settlement_date(None)?,
};
let Some(&last) = self.notional_schedule.last() else {
fail!("no notional schedule provided");
};
if date > last {
return Ok(0.0);
}
let mut index = 1;
while index < self.notional_schedule.len() && self.notional_schedule[index] < date {
index += 1;
}
if date < self.notional_schedule[index] {
Ok(self.notionals[index - 1])
} else {
Ok(self.notionals[index])
}
}
pub fn maturity_date(&self) -> QlResult<Date> {
match self.maturity_date {
Some(date) => Ok(date),
None => CashFlows::maturity_date(&self.cashflows),
}
}
pub(crate) fn set_maturity_date(&mut self, date: Date) {
self.maturity_date = Some(date);
}
pub fn next_cash_flow_date(&self, settlement: Option<Date>) -> QlResult<Option<Date>> {
let settlement = match settlement {
Some(date) => date,
None => self.settlement_date(None)?,
};
CashFlows::next_cash_flow_date(
&self.cashflows,
&self.settings,
Some(false),
Some(settlement),
)
}
pub fn settlement_date(&self, date: Option<Date>) -> QlResult<Date> {
let date = match date {
Some(date) => date,
None => {
let Some(date) = self.settings.evaluation_date() else {
fail!("no evaluation date set: a bond needs a settlement date");
};
date
}
};
let settlement = self.calendar.advance(
date,
self.settlement_days as Integer,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
Ok(match self.issue_date {
Some(issue) => settlement.max(issue),
None => settlement,
})
}
pub fn clean_price(&mut self) -> QlResult<Real> {
let settlement = self.settlement_date(None)?;
let dirty = self.dirty_price()?;
let accrued = self.accrued_amount(Some(settlement))?;
Ok(dirty - accrued)
}
pub fn dirty_price(&mut self) -> QlResult<Real> {
let settlement = self.settlement_date(None)?;
let current_notional = self.notional(Some(settlement))?;
if current_notional == 0.0 {
return Ok(0.0);
}
let value = self.settlement_value()?;
Ok(value * 100.0 / current_notional)
}
pub fn settlement_value(&mut self) -> QlResult<Real> {
self.calculate()?;
let Some(value) = self.settlement_value else {
fail!("settlement value not provided");
};
Ok(value)
}
pub fn accrued_amount(&self, date: Option<Date>) -> QlResult<Real> {
let settlement = match date {
Some(date) => date,
None => self.settlement_date(None)?,
};
let current_notional = self.notional(Some(settlement))?;
if current_notional == 0.0 {
return Ok(0.0);
}
let accrued = CashFlows::accrued_amount(
&self.cashflows,
&self.settings,
Some(false),
Some(settlement),
)?;
Ok(accrued * 100.0 / current_notional)
}
#[allow(clippy::too_many_arguments)]
pub fn yield_rate(
&self,
price: BondPrice,
day_counter: DayCounter,
compounding: Compounding,
frequency: Frequency,
settlement: Option<Date>,
accuracy: Option<Real>,
max_evaluations: Option<usize>,
guess: Option<Rate>,
) -> QlResult<Rate> {
if self.notional(settlement)? == 0.0 {
return Ok(0.0);
}
BondFunctions::yield_rate(
self,
price,
day_counter,
compounding,
frequency,
settlement,
Some(accuracy.unwrap_or(1.0e-8)),
max_evaluations,
guess,
)
}
pub fn add_redemptions_to_cashflows(&mut self, redemptions: &[Real]) -> QlResult<()> {
self.calculate_notionals_from_cashflows()?;
self.redemptions.clear();
for i in 1..self.notional_schedule.len() {
let r = if i < redemptions.len() {
redemptions[i]
} else if let Some(&last) = redemptions.last() {
last
} else {
100.0
};
let amount = (r / 100.0) * (self.notionals[i - 1] - self.notionals[i]);
let date = self.notional_schedule[i];
let payment: Shared<dyn CashFlow> = if i < self.notional_schedule.len() - 1 {
shared(AmortizingPayment::new(amount, date)?) as Shared<dyn CashFlow>
} else {
shared(Redemption::new(amount, date)?) as Shared<dyn CashFlow>
};
self.base.register_with(payment.observable());
self.cashflows.push(Shared::clone(&payment));
self.redemptions.push(payment);
}
self.cashflows.sort_by_key(|a| a.date());
Ok(())
}
fn calculate_notionals_from_cashflows(&mut self) -> QlResult<()> {
self.notional_schedule.clear();
self.notionals.clear();
let mut last_payment_date = Date::null();
self.notional_schedule.push(Date::null());
for flow in &self.cashflows {
let Some(coupon) = flow.as_coupon() else {
continue;
};
let notional = coupon.nominal();
if self.notionals.is_empty() {
self.notionals.push(notional);
last_payment_date = flow.date();
} else if !close(notional, *self.notionals.last().expect("non-empty")) {
self.notionals.push(notional);
self.notional_schedule.push(last_payment_date);
last_payment_date = flow.date();
} else {
last_payment_date = flow.date();
}
}
require!(!self.notionals.is_empty(), "no coupons provided");
self.notionals.push(0.0);
self.notional_schedule.push(last_payment_date);
Ok(())
}
}
impl Instrument for Bond {
fn base(&self) -> &InstrumentBase {
&self.base
}
fn base_mut(&mut self) -> &mut InstrumentBase {
&mut self.base
}
fn is_expired(&self) -> QlResult<bool> {
CashFlows::is_expired(&self.cashflows, &self.settings, None, None)
}
fn setup_arguments(&self, arguments: &mut dyn Arguments) -> QlResult<()> {
let Some(arguments) = (arguments as &mut dyn Any).downcast_mut::<BondArguments>() else {
fail!("wrong argument type");
};
arguments.settlement_date = Some(self.settlement_date(None)?);
arguments.cashflows = self.cashflows.clone();
arguments.calendar = self.calendar.clone();
Ok(())
}
fn setup_expired(&mut self) {
let expired = InstrumentResults {
value: Some(0.0),
error_estimate: Some(0.0),
..InstrumentResults::default()
};
self.base_mut().store_results(&expired);
self.settlement_value = Some(0.0);
}
fn fetch_results(&mut self, results: &dyn Results) -> QlResult<()> {
let Some(results) = (results as &dyn Any).downcast_ref::<BondResults>() else {
fail!("wrong result type");
};
self.settlement_value = results.settlement_value;
self.base_mut().store_results(&results.instrument);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cashflows::FixedRateCoupon;
use crate::patterns::observable::{AsObservable, Observable};
use crate::pricingengine::PricingEngine;
use crate::shared::{SharedMut, shared_mut};
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
fn today() -> Date {
Date::new(7, Month::July, 2026)
}
fn settings_today() -> Shared<Settings<Date>> {
let settings = shared(Settings::new());
settings.set_evaluation_date(today());
settings
}
fn coupons() -> Leg {
let dc = Actual360::new();
vec![
shared(FixedRateCoupon::from_rate(
Date::new(7, Month::July, 2027),
100.0,
0.05,
dc.clone(),
today(),
Date::new(7, Month::July, 2027),
None,
None,
None,
)) as Shared<dyn CashFlow>,
shared(FixedRateCoupon::from_rate(
Date::new(7, Month::July, 2028),
100.0,
0.05,
dc,
Date::new(7, Month::July, 2027),
Date::new(7, Month::July, 2028),
None,
None,
None,
)) as Shared<dyn CashFlow>,
]
}
fn par_bond() -> Bond {
Bond::from_coupons(
2,
NullCalendar::new(),
Some(Date::new(1, Month::July, 2026)),
coupons(),
settings_today(),
)
.unwrap()
}
struct StubEngine {
base: BondEngine,
settlement_value: Real,
}
impl AsObservable for StubEngine {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl PricingEngine for StubEngine {
fn arguments_mut(&mut self) -> &mut dyn Arguments {
self.base.arguments_mut()
}
fn results(&self) -> &dyn Results {
self.base.results()
}
fn reset(&mut self) {
self.base.reset();
}
fn calculate(&mut self) -> QlResult<()> {
let value = self.settlement_value;
let results = self.base.results_mut();
results.settlement_value = Some(value);
results.instrument.value = Some(value);
Ok(())
}
}
fn stub_engine(settlement_value: Real) -> SharedMut<StubEngine> {
shared_mut(StubEngine {
base: BondEngine::new(
BondArguments {
settlement_date: None,
cashflows: Vec::new(),
calendar: NullCalendar::new(),
},
BondResults::default(),
),
settlement_value,
})
}
#[test]
fn a_par_bond_appends_a_full_redemption_and_builds_its_notional_schedule() {
let bond = par_bond();
assert_eq!(bond.cashflows().len(), 3, "two coupons plus the redemption");
assert_eq!(bond.redemptions().len(), 1);
assert_eq!(bond.redemptions()[0].amount().unwrap(), 100.0);
assert_eq!(
bond.redemptions()[0].date(),
Date::new(7, Month::July, 2028)
);
assert_eq!(bond.notionals(), &[100.0, 0.0]);
assert_eq!(
bond.maturity_date().unwrap(),
Date::new(7, Month::July, 2028)
);
assert_eq!(bond.issue_date(), Some(Date::new(1, Month::July, 2026)));
}
#[test]
fn settlement_date_and_notional_track_the_schedule() {
let bond = par_bond();
let settlement = bond.settlement_date(None).unwrap();
assert_eq!(settlement, Date::new(9, Month::July, 2026));
assert_eq!(bond.notional(Some(settlement)).unwrap(), 100.0);
assert_eq!(
bond.notional(Some(Date::new(8, Month::July, 2028)))
.unwrap(),
0.0,
"the notional is redeemed at maturity"
);
assert!(!bond.is_expired().unwrap());
}
#[test]
fn the_price_accessors_read_the_engine_settlement_value() {
let mut bond = par_bond();
bond.base_mut().set_pricing_engine(stub_engine(98.5));
assert_eq!(bond.settlement_value().unwrap(), 98.5);
assert_eq!(bond.dirty_price().unwrap(), 98.5);
let settlement = bond.settlement_date(None).unwrap();
let accrued = bond.accrued_amount(Some(settlement)).unwrap();
assert!(accrued > 0.0, "the first coupon is accruing");
assert_eq!(bond.clean_price().unwrap(), 98.5 - accrued);
}
#[test]
fn a_derived_bond_can_scale_the_redemption() {
let mut bond =
Bond::new(2, NullCalendar::new(), None, coupons(), settings_today()).unwrap();
assert!(
bond.redemptions().is_empty(),
"new leaves redemptions unset"
);
bond.add_redemptions_to_cashflows(&[100.0, 98.0]).unwrap();
assert_eq!(bond.redemptions().len(), 1);
assert_eq!(bond.redemptions()[0].amount().unwrap(), 98.0);
}
#[test]
fn an_unset_evaluation_date_fails_the_settlement_date() {
let settings = shared(Settings::new());
let bond = Bond::from_coupons(2, NullCalendar::new(), None, coupons(), settings).unwrap();
assert_eq!(
bond.settlement_date(None).unwrap_err().message(),
"no evaluation date set: a bond needs a settlement date"
);
}
#[test]
fn the_arguments_reject_a_missing_settlement_date_and_empty_leg() {
let mut arguments = BondArguments {
settlement_date: None,
cashflows: Vec::new(),
calendar: NullCalendar::new(),
};
assert_eq!(
arguments.validate().unwrap_err().message(),
"no settlement date provided"
);
arguments.settlement_date = Some(today());
assert_eq!(
arguments.validate().unwrap_err().message(),
"no cash flow provided"
);
arguments.cashflows = coupons();
assert!(arguments.validate().is_ok());
}
}